Files
MOPC-Portal/src/lib/minio.ts

180 lines
6.0 KiB
TypeScript
Raw Normal View History

import * as Minio from 'minio'
// MinIO client singletons (lazy-initialized to avoid build-time errors)
const globalForMinio = globalThis as unknown as {
minio: Minio.Client | undefined
minioPublic: Minio.Client | undefined
}
// Internal endpoint for server-to-server communication
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000'
// Public endpoint for browser-accessible URLs (pre-signed URLs)
// If not set, falls back to internal endpoint
export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT
function createClientFromUrl(endpoint: string): Minio.Client {
const url = new URL(endpoint)
const accessKey = process.env.MINIO_ACCESS_KEY
const secretKey = process.env.MINIO_SECRET_KEY
if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production')
}
return new Minio.Client({
endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:',
accessKey: accessKey || 'minioadmin',
secretKey: secretKey || 'minioadmin',
})
}
/**
* Get the internal MinIO client (for server-side operations: bucket ops, delete, etc.)
*/
export function getMinioClient(): Minio.Client {
if (!globalForMinio.minio) {
globalForMinio.minio = createClientFromUrl(MINIO_ENDPOINT)
}
return globalForMinio.minio
}
/**
* Get the public MinIO client (for generating presigned URLs).
* Uses MINIO_PUBLIC_ENDPOINT so the AWS V4 signature matches the Host
* header the browser will send. Without this, presigned URLs generated
* against the internal hostname fail with SignatureDoesNotMatch.
*/
function getPublicMinioClient(): Minio.Client {
if (!globalForMinio.minioPublic) {
globalForMinio.minioPublic = createClientFromUrl(MINIO_PUBLIC_ENDPOINT)
}
return globalForMinio.minioPublic
}
// Backward-compatible export — lazy getter via Proxy (internal client)
export const minio: Minio.Client = new Proxy({} as Minio.Client, {
get(_target, prop, receiver) {
return Reflect.get(getMinioClient(), prop, receiver)
},
})
// Default bucket name
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files'
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Generate a pre-signed URL for file download or upload.
* Uses the public MinIO client so the AWS V4 signature is computed against
* the public hostname the browser's Host header will match the signature.
*/
export async function getPresignedUrl(
bucket: string,
objectKey: string,
method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900, // 15 minutes default
options?: { downloadFileName?: string }
): Promise<string> {
const publicClient = getPublicMinioClient()
if (method === 'GET') {
const respHeaders = options?.downloadFileName
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
: undefined
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
} else {
return publicClient.presignedPutObject(bucket, objectKey, expirySeconds)
}
}
/**
* Check if a bucket exists, create if not
*/
export async function ensureBucket(bucket: string): Promise<void> {
const exists = await minio.bucketExists(bucket)
if (!exists) {
await minio.makeBucket(bucket)
console.log(`Created MinIO bucket: ${bucket}`)
}
}
/**
* Delete an object from MinIO
*/
export async function deleteObject(
bucket: string,
objectKey: string
): Promise<void> {
await minio.removeObject(bucket, objectKey)
}
/**
* Sanitize a name for use as a MinIO path segment.
* Removes special characters, replaces spaces with underscores, limits length.
*/
fix(security): file storage authorization hardening Three separate issues in the file storage layer: 1. IDOR via client-controlled object key in applicant.saveFileMetadata and file.replaceFile. Both procedures accepted `bucket` and `objectKey` from the client and stored them on a new ProjectFile row attached to the caller's own project. Because file.getDownloadUrl authorizes via `findFirst({ bucket, objectKey })` -> projectId, an attacker could bind another team's storage object to their own project row and then download the foreign object through the legitimate authorization path. Now both procedures require `bucket === BUCKET_NAME` and the `objectKey` to start with the project's sanitized title prefix (matches the prefix that generateObjectKey produces server-side). New helper `objectKeyBelongsToProject` exported from src/lib/minio.ts; `sanitizePath` is now exported as well so the helper can reuse it. 2. Missing per-round scope on file.getBulkDownloadUrls. The single-file getDownloadUrl restricts a juror to files in rounds with sortOrder <= their assigned round, but the bulk variant only checked that an Assignment row existed for the project. A juror assigned only to EVALUATION could pull URLs for LIVE_FINAL/DELIBERATION confidential files via this endpoint. Now applies the same per-round filter when the caller's access to the project is jury-only (mentors / team members / award jurors retain unrestricted access, matching getDownloadUrl semantics). 3. Same omission on the standalone /api/files/bulk-download REST route. Same fix applied there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:30:00 +02:00
export function sanitizePath(name: string): string {
return (
name
.trim()
.replace(/[^a-zA-Z0-9\-_ ]/g, '')
.replace(/\s+/g, '_')
.substring(0, 100) || 'unnamed'
)
}
/**
* Generate a unique object key for a project file.
*
* Structure: {ProjectName}/{RoundName}/{timestamp}-{fileName}
* - projectName: human-readable project title (sanitized)
* - roundName: round name for submission context (sanitized), defaults to "general"
* - fileName: original file name (sanitized)
*
* Existing files with old-style keys (projects/{id}/...) are unaffected
* because retrieval uses the objectKey stored in the database.
*/
export function generateObjectKey(
projectName: string,
fileName: string,
roundName?: string
): string {
const timestamp = Date.now()
const sanitizedProject = sanitizePath(projectName)
const sanitizedRound = roundName ? sanitizePath(roundName) : 'general'
const sanitizedFile = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}`
}
/**
* Generate a unique object key for a mentor-workspace file.
*
* Structure: {ProjectName}/mentorship/{timestamp}-{fileName}
*
* Mirrors generateObjectKey but pins the round-name slot to "mentorship"
* so all mentor workspace files for a project live under one folder.
*/
export function generateMentorObjectKey(
projectTitle: string,
fileName: string,
): string {
return generateObjectKey(projectTitle, fileName, 'mentorship')
}
fix(security): file storage authorization hardening Three separate issues in the file storage layer: 1. IDOR via client-controlled object key in applicant.saveFileMetadata and file.replaceFile. Both procedures accepted `bucket` and `objectKey` from the client and stored them on a new ProjectFile row attached to the caller's own project. Because file.getDownloadUrl authorizes via `findFirst({ bucket, objectKey })` -> projectId, an attacker could bind another team's storage object to their own project row and then download the foreign object through the legitimate authorization path. Now both procedures require `bucket === BUCKET_NAME` and the `objectKey` to start with the project's sanitized title prefix (matches the prefix that generateObjectKey produces server-side). New helper `objectKeyBelongsToProject` exported from src/lib/minio.ts; `sanitizePath` is now exported as well so the helper can reuse it. 2. Missing per-round scope on file.getBulkDownloadUrls. The single-file getDownloadUrl restricts a juror to files in rounds with sortOrder <= their assigned round, but the bulk variant only checked that an Assignment row existed for the project. A juror assigned only to EVALUATION could pull URLs for LIVE_FINAL/DELIBERATION confidential files via this endpoint. Now applies the same per-round filter when the caller's access to the project is jury-only (mentors / team members / award jurors retain unrestricted access, matching getDownloadUrl semantics). 3. Same omission on the standalone /api/files/bulk-download REST route. Same fix applied there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:30:00 +02:00
/**
* Verify a client-supplied object key actually belongs to a project's
* sanitized prefix. Used to prevent cross-tenant binding where an attacker
* passes another team's `bucket+objectKey` into a metadata-save procedure.
*/
export function objectKeyBelongsToProject(
objectKey: string,
projectTitle: string,
): boolean {
const sanitized = sanitizePath(projectTitle)
return objectKey.startsWith(`${sanitized}/`)
}