mime type code pulled fom remote

This commit is contained in:
laxmanhalaki 2026-03-03 18:11:51 +05:30
commit f679317d4a
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)
* 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
const file = req.file;
if (!file) {
@ -79,14 +79,14 @@ export function malwareScanMiddleware(req: Request, res: Response, next: NextFun
req.scanEventId = scanEventId;
// 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)
* 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()
const files = req.files;
if (!files || (Array.isArray(files) && files.length === 0)) {
@ -100,7 +100,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next:
// Handle array of files
if (Array.isArray(files)) {
performMultiScan(files, scanEventId, req, res, next);
await performMultiScan(files, scanEventId, req, res, next);
return;
}
@ -115,7 +115,7 @@ export function malwareScanMultipleMiddleware(req: Request, res: Response, next:
return next();
}
performMultiScan(allFiles, scanEventId, req, res, next);
await performMultiScan(allFiles, scanEventId, req, res, next);
}
// ── Core scan logic ──
@ -132,7 +132,7 @@ async function performScan(
try {
// Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns)
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
const validation = await validateFile(
file.originalname,
file.mimetype,
file.buffer || null,
@ -304,7 +304,7 @@ async function performMultiScan(
// Step 0: Pre-scan file validation
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
const validation = await validateFile(
file.originalname,
file.mimetype,
file.buffer || null,

View File

@ -63,7 +63,7 @@ router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchem
* @desc Submit dealer proposal (Step 1)
* @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
@ -75,7 +75,7 @@ router.post('/:requestId/completion', authenticateToken, uploadLimiter, validate
{ name: 'activityPhotos', maxCount: 10 },
{ name: 'invoicesReceipts', maxCount: 10 },
{ 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
@ -105,7 +105,7 @@ router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(reques
* @access Private
*/
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

View File

@ -22,6 +22,6 @@ const controller = new DocumentController();
// multipart/form-data: file, requestId, optional category
// 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;

View File

@ -7,8 +7,11 @@
* - Path traversal blocking (e.g., ../../etc/passwd)
* - Magic bytes / file signature validation
* - Filename sanitization
* - Configuration-driven allowed file types
*/
import { getConfigValue } from '../configReader.service';
// ── Types ──
export interface FileValidationResult {
@ -123,19 +126,29 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
* Validate an uploaded file for security concerns.
* This runs BEFORE ClamAV and catches things ClamAV won't flag.
*/
export function validateFile(
export async function validateFile(
originalName: string,
mimeType: string,
fileBuffer: Buffer | null,
fileSizeBytes: number,
maxSizeMB: number = 50,
): FileValidationResult {
): Promise<FileValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
// 1. Extract and validate extension
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) {
errors.push('File has no extension');
@ -160,13 +173,14 @@ export function validateFile(
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;
if (ext && EXTENSION_MIME_MAP[ext]) {
const allowedMimes = EXTENSION_MIME_MAP[ext];
if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') {
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}". ` +
`Expected: ${allowedMimes.join(' or ')}`
);
@ -186,15 +200,26 @@ export function validateFile(
// Check if magic bytes match claimed extension
if (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) {
const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset));
if (!matchesAny) {
// Warning only — some legitimate files have variant headers
// ClamAV will do the real malware check
warnings.push(
`File header does not match ".${ext}" signature — file may be corrupted or mislabeled`
// Block uploads where the file's actual magic bytes do not match the expected ones
errors.push(
`File header does not match ".${ext}" signature — file may be a different type disguised as ".${ext}"`
);
}
} 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;
}
}
}
}