diff --git a/backend/package.json b/backend/package.json index f4b32a0..e41670f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,7 @@ "fastify-plugin": "^4.5.1", "@fastify/autoload": "^5.8.0", "get-jwks": "^9.0.0", - "file-type": "^19.8.0" + "file-type": "^16.5.4" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/backend/src/features/documents/api/documents.controller.ts b/backend/src/features/documents/api/documents.controller.ts index a231a6d..c6955e0 100644 --- a/backend/src/features/documents/api/documents.controller.ts +++ b/backend/src/features/documents/api/documents.controller.ts @@ -6,6 +6,8 @@ import { logger } from '../../../core/logging/logger'; import path from 'path'; import { Transform, TransformCallback } from 'stream'; import crypto from 'crypto'; +import FileType from 'file-type'; +import { Readable } from 'stream'; export class DocumentsController { private readonly service = new DocumentsService(); @@ -203,17 +205,75 @@ export class DocumentsController { return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' }); } - const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); + // Define allowed MIME types and their corresponding magic byte signatures + const allowedTypes = new Map([ + ['application/pdf', new Set(['application/pdf'])], + ['image/jpeg', new Set(['image/jpeg'])], + ['image/png', new Set(['image/png'])], + ]); + const contentType = mp.mimetype as string | undefined; - if (!contentType || !allowed.has(contentType)) { - logger.warn('Unsupported file type for upload', { + if (!contentType || !allowedTypes.has(contentType)) { + logger.warn('Unsupported file type for upload (header validation)', { operation: 'documents.upload.unsupported_type', user_id: userId, document_id: documentId, content_type: contentType, file_name: mp.filename, }); - return reply.code(415).send({ error: 'Unsupported Media Type' }); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: 'Only PDF, JPEG, and PNG files are allowed' + }); + } + + // Read first 4100 bytes to detect file type via magic bytes + const chunks: Buffer[] = []; + let totalBytes = 0; + const targetBytes = 4100; + + for await (const chunk of mp.file) { + chunks.push(chunk); + totalBytes += chunk.length; + if (totalBytes >= targetBytes) { + break; + } + } + + const headerBuffer = Buffer.concat(chunks); + + // Validate actual file content using magic bytes + const detectedType = await FileType.fromBuffer(headerBuffer); + + if (!detectedType) { + logger.warn('Unable to detect file type from content', { + operation: 'documents.upload.type_detection_failed', + user_id: userId, + document_id: documentId, + content_type: contentType, + file_name: mp.filename, + }); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: 'Unable to verify file type from content' + }); + } + + // Verify detected type matches claimed Content-Type + const allowedDetectedTypes = allowedTypes.get(contentType); + if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) { + logger.warn('File content does not match Content-Type header', { + operation: 'documents.upload.type_mismatch', + user_id: userId, + document_id: documentId, + claimed_type: contentType, + detected_type: detectedType.mime, + file_name: mp.filename, + }); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: `File content (${detectedType.mime}) does not match claimed type (${contentType})` + }); } const originalName: string = mp.filename || 'upload'; @@ -235,10 +295,19 @@ export class DocumentsController { } const counter = new CountingStream(); - mp.file.pipe(counter); + + // Create a new readable stream from the header buffer + remaining file chunks + const headerStream = Readable.from([headerBuffer]); + const remainingStream = mp.file; + + // Pipe header first, then remaining content through counter + headerStream.pipe(counter, { end: false }); + headerStream.on('end', () => { + remainingStream.pipe(counter); + }); const storage = getStorageService(); - const bucket = 'documents'; // Filesystem storage ignores bucket, but keep for interface compatibility + const bucket = 'documents'; const version = 'v1'; const unique = cryptoRandom(); const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`; @@ -261,6 +330,7 @@ export class DocumentsController { vehicle_id: doc.vehicle_id, file_name: originalName, content_type: contentType, + detected_type: detectedType.mime, file_size: counter.bytes, storage_key: key, });