mime type issue resolved

This commit is contained in:
laxmanhalaki 2026-02-26 21:22:44 +05:30
parent 3c8fed7d2f
commit 2fee63dc44
4 changed files with 45 additions and 20 deletions

View File

@ -66,7 +66,7 @@ function deleteTempFile(tempPath: string): void {
* Malware scan middleware for single file uploads (multer.single) * Malware scan middleware for single file uploads (multer.single)
* Works with memory storage writes buffer to temp scans deletes temp * Works with memory storage writes buffer to temp scans deletes temp
*/ */
export function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): void { export async function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
// Skip if no file uploaded // Skip if no file uploaded
const file = req.file; const file = req.file;
if (!file) { if (!file) {
@ -79,14 +79,14 @@ export function malwareScanMiddleware(req: Request, res: Response, next: NextFun
req.scanEventId = scanEventId; req.scanEventId = scanEventId;
// Handle the async scan // Handle the async scan
performScan(file, scanEventId, req, res, next); await performScan(file, scanEventId, req, res, next);
} }
/** /**
* Malware scan middleware for multiple file uploads (multer.array / multer.fields) * Malware scan middleware for multiple file uploads (multer.array / multer.fields)
* Scans all files and blocks if ANY file is infected * Scans all files and blocks if ANY file is infected
*/ */
export function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): void { export async function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
// Handle multer.array() // Handle multer.array()
const files = req.files; const files = req.files;
if (!files || (Array.isArray(files) && files.length === 0)) { if (!files || (Array.isArray(files) && files.length === 0)) {
@ -100,7 +100,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next:
// Handle array of files // Handle array of files
if (Array.isArray(files)) { if (Array.isArray(files)) {
performMultiScan(files, scanEventId, req, res, next); await performMultiScan(files, scanEventId, req, res, next);
return; return;
} }
@ -115,7 +115,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next:
return next(); return next();
} }
performMultiScan(allFiles, scanEventId, req, res, next); await performMultiScan(allFiles, scanEventId, req, res, next);
} }
// ── Core scan logic ── // ── Core scan logic ──
@ -132,7 +132,7 @@ async function performScan(
try { try {
// Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns) // Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns)
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10); const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile( const validation = await validateFile(
file.originalname, file.originalname,
file.mimetype, file.mimetype,
file.buffer || null, file.buffer || null,
@ -304,7 +304,7 @@ async function performMultiScan(
// Step 0: Pre-scan file validation // Step 0: Pre-scan file validation
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10); const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile( const validation = await validateFile(
file.originalname, file.originalname,
file.mimetype, file.mimetype,
file.buffer || null, file.buffer || null,

View File

@ -63,7 +63,7 @@ router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchem
* @desc Submit dealer proposal (Step 1) * @desc Submit dealer proposal (Step 1)
* @access Private * @access Private
*/ */
router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), malwareScanMiddleware, asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController))); router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
/** /**
* @route POST /api/v1/dealer-claims/:requestId/completion * @route POST /api/v1/dealer-claims/:requestId/completion
@ -75,7 +75,7 @@ router.post('/:requestId/completion', authenticateToken, uploadLimiter, validate
{ name: 'activityPhotos', maxCount: 10 }, { name: 'activityPhotos', maxCount: 10 },
{ name: 'invoicesReceipts', maxCount: 10 }, { name: 'invoicesReceipts', maxCount: 10 },
{ name: 'attendanceSheet', maxCount: 1 }, { name: 'attendanceSheet', maxCount: 1 },
]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); ]), asyncHandler(malwareScanMultipleMiddleware), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
/** /**
* @route GET /api/v1/dealer-claims/:requestId/io/validate * @route GET /api/v1/dealer-claims/:requestId/io/validate
@ -105,7 +105,7 @@ router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(reques
* @access Private * @access Private
*/ */
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController))); router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), malwareScanMiddleware, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), asyncHandler(malwareScanMiddleware), asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
/** /**
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send * @route POST /api/v1/dealer-claims/:requestId/credit-note/send

View File

@ -22,6 +22,6 @@ const controller = new DocumentController();
// multipart/form-data: file, requestId, optional category // multipart/form-data: file, requestId, optional category
// Middleware chain: auth → multer → malware scan → controller // Middleware chain: auth → multer → malware scan → controller
router.post('/', authenticateToken, upload.single('file'), malwareScanMiddleware, asyncHandler(controller.upload.bind(controller))); router.post('/', authenticateToken, upload.single('file'), asyncHandler(malwareScanMiddleware), asyncHandler(controller.upload.bind(controller)));
export default router; export default router;

View File

@ -7,8 +7,11 @@
* - Path traversal blocking (e.g., ../../etc/passwd) * - Path traversal blocking (e.g., ../../etc/passwd)
* - Magic bytes / file signature validation * - Magic bytes / file signature validation
* - Filename sanitization * - Filename sanitization
* - Configuration-driven allowed file types
*/ */
import { getConfigValue } from '../configReader.service';
// ── Types ── // ── Types ──
export interface FileValidationResult { export interface FileValidationResult {
@ -123,19 +126,29 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
* Validate an uploaded file for security concerns. * Validate an uploaded file for security concerns.
* This runs BEFORE ClamAV and catches things ClamAV won't flag. * This runs BEFORE ClamAV and catches things ClamAV won't flag.
*/ */
export function validateFile( export async function validateFile(
originalName: string, originalName: string,
mimeType: string, mimeType: string,
fileBuffer: Buffer | null, fileBuffer: Buffer | null,
fileSizeBytes: number, fileSizeBytes: number,
maxSizeMB: number = 50, maxSizeMB: number = 50,
): FileValidationResult { ): Promise<FileValidationResult> {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
// 1. Extract and validate extension // 1. Extract and validate extension
const ext = originalName.split('.').pop()?.toLowerCase() || ''; const ext = originalName.split('.').pop()?.toLowerCase() || '';
const allowedExtensions = Object.keys(EXTENSION_MIME_MAP);
// Get allowed extensions from config, fallback to default EXTENSION_MIME_MAP keys
const allowedTypesConfig = await getConfigValue('ALLOWED_FILE_TYPES', '');
let allowedExtensions: string[];
if (allowedTypesConfig) {
// e.g., "pdf, docx, jpg" -> ["pdf", "docx", "jpg"]
allowedExtensions = allowedTypesConfig.split(',').map(e => e.trim().toLowerCase()).filter(e => e);
} else {
allowedExtensions = Object.keys(EXTENSION_MIME_MAP);
}
if (!ext) { if (!ext) {
errors.push('File has no extension'); errors.push('File has no extension');
@ -160,13 +173,14 @@ export function validateFile(
errors.push('File is empty (0 bytes)'); errors.push('File is empty (0 bytes)');
} }
// 4. MIME type ↔ extension mismatch detection (warning only — browsers/multer can report wrong MIME) // 4. MIME type ↔ extension mismatch detection
let mimeMatchesExtension = true; let mimeMatchesExtension = true;
if (ext && EXTENSION_MIME_MAP[ext]) { if (ext && EXTENSION_MIME_MAP[ext]) {
const allowedMimes = EXTENSION_MIME_MAP[ext]; const allowedMimes = EXTENSION_MIME_MAP[ext];
if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') { if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') {
mimeMatchesExtension = false; mimeMatchesExtension = false;
warnings.push( // Block if the uploaded file's claimed mimetype doesn't match its extension
errors.push(
`MIME type mismatch: file claims ".${ext}" but has MIME "${mimeType}". ` + `MIME type mismatch: file claims ".${ext}" but has MIME "${mimeType}". ` +
`Expected: ${allowedMimes.join(' or ')}` `Expected: ${allowedMimes.join(' or ')}`
); );
@ -186,15 +200,26 @@ export function validateFile(
// Check if magic bytes match claimed extension // Check if magic bytes match claimed extension
if (ext) { if (ext) {
const expectedSignatures = MAGIC_BYTES.filter(m => m.ext === ext); const expectedSignatures = MAGIC_BYTES.filter(m => m.ext === ext);
// If we know the expected signatures for this extension, enforce them
if (expectedSignatures.length > 0) { if (expectedSignatures.length > 0) {
const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset)); const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset));
if (!matchesAny) { if (!matchesAny) {
// Warning only — some legitimate files have variant headers // Block uploads where the file's actual magic bytes do not match the expected ones
// ClamAV will do the real malware check errors.push(
warnings.push( `File header does not match ".${ext}" signature — file may be a different type disguised as ".${ext}"`
`File header does not match ".${ext}" signature — file may be corrupted or mislabeled`
); );
} }
} else {
// If we DON'T strictly know the signatures for this extension,
// we should check if its bytes match a KNOWN DIFFERENT file type
for (const { ext: knownExt, bytes: knownBytes, offset: knownOffset } of MAGIC_BYTES) {
if (knownExt !== ext && matchesBytes(fileBuffer, knownBytes, knownOffset)) {
errors.push(
`File signature mismatch: claims to be ".${ext}" but header matches ".${knownExt}"`
);
break;
}
}
} }
} }