Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions controllers/invoice/invoiceExportService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions test/invoiceBulkExport.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
MAX_EXPORTS_PER_HOUR,
MAX_ORDER_IDS,
validateExportWindow,
validateOptionalFilters,
resolveExportActor,
checkExportRateLimit,
resetExportRateLimit,
Expand Down Expand Up @@ -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 }
Expand Down
32 changes: 31 additions & 1 deletion utils/invoiceExportGuard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>} actorKey -> timestamps of export attempts in the current window */
const exportAttempts = new Map();
Expand All @@ -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 };
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -228,6 +257,7 @@ module.exports = {
MAX_ORDER_IDS,
RATE_WINDOW_MS,
validateExportWindow,
validateOptionalFilters,
resolveExportActor,
checkExportRateLimit,
resetExportRateLimit,
Expand Down
8 changes: 8 additions & 0 deletions utils/invoiceZipBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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}`);
Expand Down
Loading