diff --git a/controllers/invoice/invoiceExportService.js b/controllers/invoice/invoiceExportService.js index 2df309f..1bb4cd7 100644 --- a/controllers/invoice/invoiceExportService.js +++ b/controllers/invoice/invoiceExportService.js @@ -106,6 +106,10 @@ async function requestBulkZipExport(req) { }); } catch (err) { logger.error({ error: err.message }, 'Failed to write invoice export audit log'); + // Do not acknowledge a sensitive financial export if its mandatory audit + // record could not be written. The queued job may still be visible to the + // worker, but the caller receives no successful export response. + return jsonError(500, 'Failed to record invoice export audit log'); } return { @@ -156,6 +160,7 @@ async function requestBulkMetadataExport(req) { }); } catch (err) { logger.error({ error: err.message }, 'Failed to write invoice export audit log'); + return jsonError(500, 'Failed to record invoice export audit log'); } return { diff --git a/test/invoiceBulkExport.test.js b/test/invoiceBulkExport.test.js index 8cbef45..110b297 100644 --- a/test/invoiceBulkExport.test.js +++ b/test/invoiceBulkExport.test.js @@ -13,6 +13,7 @@ const { MAX_EXPORTS_PER_HOUR, MAX_ORDER_IDS, validateExportWindow, + validateOptionalFilters, resolveExportActor, checkExportRateLimit, resetExportRateLimit, @@ -133,6 +134,12 @@ async function runCapTests() { }); assertCond(tooManyIds.status === 400, `orderIds over ${MAX_ORDER_IDS} → 400`); + const impossibleDate = validateExportWindow({ startDate: '2026-02-30', endDate: '2026-03-01' }); + assertCond(impossibleDate.status === 400, 'Non-existent ISO date → 400'); + + const injection = validateOptionalFilters({ vendorId: { $ne: null } }); + assertCond(injection.status === 400, 'Object-valued filter (query injection) → 400'); + const overRows = evaluateBulkExportRequest( adminReq({ startDate: '2026-01-01', endDate: '2026-01-07' }), { count: MAX_EXPORT_ROWS + 1, recordRateLimit: false } diff --git a/utils/invoiceExportGuard.js b/utils/invoiceExportGuard.js index d8d8faf..5aba60f 100644 --- a/utils/invoiceExportGuard.js +++ b/utils/invoiceExportGuard.js @@ -10,6 +10,9 @@ const MAX_ORDER_IDS = Number(process.env.INVOICE_EXPORT_MAX_ORDER_IDS) || 100; const RATE_WINDOW_MS = 60 * 60 * 1000; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const OBJECT_ID = /^[a-f\d]{24}$/i; +const INVOICE_TYPES = new Set(['vendor', 'platform']); +const RECIPIENT_TYPES = new Set(['vendor', 'admin']); /** @type {Map} actorKey -> timestamps of export attempts in the current window */ const exportAttempts = new Map(); @@ -19,7 +22,8 @@ function parseIsoDate(value, label) { return { ok: false, message: `${label} must be an ISO date (YYYY-MM-DD)` }; } const parsed = new Date(`${value}T00:00:00.000Z`); - if (Number.isNaN(parsed.getTime())) { + // Date otherwise normalizes values such as 2026-02-31 into March. + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { return { ok: false, message: `${label} is not a valid date` }; } return { ok: true, date: parsed, raw: value }; @@ -72,6 +76,9 @@ function validateExportWindow({ startDate, endDate, orderIds } = {}) { message: `orderIds cannot exceed ${MAX_ORDER_IDS} entries` }; } + if (orderIds.some((id) => typeof id !== 'string' || !OBJECT_ID.test(id))) { + return { ok: false, status: 400, message: 'orderIds must contain valid order IDs' }; + } } return { @@ -84,6 +91,23 @@ function validateExportWindow({ startDate, endDate, orderIds } = {}) { }; } +function validateOptionalFilters(body = {}) { + for (const field of ['vendorId', 'uniId']) { + const value = body[field]; + if (value !== undefined && value !== null && + (typeof value !== 'string' || !OBJECT_ID.test(value))) { + return { ok: false, status: 400, message: `${field} must be a valid ID` }; + } + } + if (body.invoiceType !== undefined && !INVOICE_TYPES.has(body.invoiceType)) { + return { ok: false, status: 400, message: 'invoiceType must be vendor or platform' }; + } + if (body.recipientType !== undefined && !RECIPIENT_TYPES.has(body.recipientType)) { + return { ok: false, status: 400, message: 'recipientType must be vendor or admin' }; + } + return { ok: true }; +} + function resolveExportActor(req = {}) { if (req.admin && (req.admin.adminId || req.admin._id)) { const id = req.admin.adminId || req.admin._id; @@ -179,6 +203,11 @@ function evaluateBulkExportRequest(req, { count, recordRateLimit = true } = {}) return { ok: false, status: window.status, message: window.message, actor }; } + const optionalFilters = validateOptionalFilters(req.body || {}); + if (!optionalFilters.ok) { + return { ok: false, status: optionalFilters.status, message: optionalFilters.message, actor }; + } + if (recordRateLimit) { const rate = checkExportRateLimit(actor.key); if (!rate.allowed) { @@ -228,6 +257,7 @@ module.exports = { MAX_ORDER_IDS, RATE_WINDOW_MS, validateExportWindow, + validateOptionalFilters, resolveExportActor, checkExportRateLimit, resetExportRateLimit, diff --git a/utils/invoiceZipBuilder.js b/utils/invoiceZipBuilder.js index 97e0069..e83f54c 100644 --- a/utils/invoiceZipBuilder.js +++ b/utils/invoiceZipBuilder.js @@ -12,6 +12,7 @@ const Invoice = require('../models/invoice/Invoice'); const Vendor = require('../models/account/Vendor'); const Uni = require('../models/account/Uni'); const Order = require('../models/order/Order'); +const { MAX_EXPORT_ROWS } = require('./invoiceExportGuard'); const { isValidCloudinaryUrl, isValidRazorpayUrl } = require('./urlValidation'); const logger = require('./pinoLogger'); @@ -98,8 +99,15 @@ async function buildBulkInvoiceZip(job) { .populate({ path: 'uniId', select: 'fullName', model: Uni }) .populate({ path: 'orderId', select: 'orderNumber', model: Order }) .sort({ createdAt: -1 }) + // Invoices can be inserted after the enqueue-time count. Keep the worker + // bounded too, so that race cannot create an unbounded archive. + .limit(MAX_EXPORT_ROWS + 1) .lean(); + if (invoices.length > MAX_EXPORT_ROWS) { + throw new Error(`Export exceeds the maximum of ${MAX_EXPORT_ROWS} invoices at processing time`); + } + const cleanStart = sanitizeFilename(job.startDate || 'start'); const cleanEnd = sanitizeFilename(job.endDate || 'end'); const tempDir = path.join(os.tmpdir(), `bulk_invoices_${job.id}`);