fix(audit-final): pre-merge hardening + expense receipt UI
Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -225,8 +225,11 @@ async function resolveRecipientEmail(
|
||||
return primary.value;
|
||||
}
|
||||
|
||||
async function checkSendRateLimit(userId: string): Promise<void> {
|
||||
const result = await checkRateLimit(userId, {
|
||||
async function checkSendRateLimit(portId: string, userId: string): Promise<void> {
|
||||
// Per-(port, user) so a multi-port rep can't be DoS'd by another tenant
|
||||
// burning their global cap. Audit caught this — the original
|
||||
// single-key version locked a user out across every port they touched.
|
||||
const result = await checkRateLimit(`${portId}:${userId}`, {
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 50,
|
||||
keyPrefix: 'docsend',
|
||||
@@ -369,7 +372,8 @@ async function performSend(args: {
|
||||
// ─── Public sender: berth PDF ────────────────────────────────────────────────
|
||||
|
||||
export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult> {
|
||||
await checkSendRateLimit(input.sentBy);
|
||||
// Rate-limit AFTER validation so a typo'd recipient or missing-PDF rep
|
||||
// doesn't burn a slot on a send that would have failed anyway.
|
||||
const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient);
|
||||
|
||||
// Resolve berth + active version.
|
||||
@@ -406,6 +410,8 @@ export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult
|
||||
// Subject pulls in the mooring number for inbox triage.
|
||||
const subject = `Berth ${berth.mooringNumber} — spec sheet`;
|
||||
|
||||
await checkSendRateLimit(input.portId, input.sentBy);
|
||||
|
||||
return performSend({
|
||||
portId: input.portId,
|
||||
recipientEmail,
|
||||
@@ -436,7 +442,7 @@ export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult
|
||||
// ─── Public sender: brochure ─────────────────────────────────────────────────
|
||||
|
||||
export async function sendBrochure(input: SendBrochureInput): Promise<SendResult> {
|
||||
await checkSendRateLimit(input.sentBy);
|
||||
// Rate-limit AFTER validation (audit finding); typos shouldn't burn slots.
|
||||
const recipientEmail = await resolveRecipientEmail(input.portId, input.recipient);
|
||||
|
||||
// Resolve brochure + most-recent version.
|
||||
@@ -456,6 +462,14 @@ export async function sendBrochure(input: SendBrochureInput): Promise<SendResult
|
||||
'No default brochure configured for this port. Upload one in /admin/brochures.',
|
||||
);
|
||||
}
|
||||
// The partial unique index on `is_default` only enforces uniqueness when
|
||||
// archived_at IS NULL — an archived row can still carry is_default=true
|
||||
// and would silently be returned here without this guard.
|
||||
if (def.archivedAt) {
|
||||
throw new ValidationError(
|
||||
'Default brochure is archived. Choose a non-archived brochure as the default first.',
|
||||
);
|
||||
}
|
||||
brochureRow = def;
|
||||
}
|
||||
|
||||
@@ -488,6 +502,8 @@ export async function sendBrochure(input: SendBrochureInput): Promise<SendResult
|
||||
const bodyHtml = renderEmailBody(expanded);
|
||||
const subject = `${brochureRow.label} — brochure`;
|
||||
|
||||
await checkSendRateLimit(input.portId, input.sentBy);
|
||||
|
||||
return performSend({
|
||||
portId: input.portId,
|
||||
recipientEmail,
|
||||
|
||||
Reference in New Issue
Block a user