mime type code pulled fom remote
This commit is contained in:
commit
f679317d4a
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user