Skip to content

fix(security): PDF magic-byte validation, per-email OTP limits, structured logging - #94

Merged
Sam-Aitech merged 2 commits into
mainfrom
security/audit-followup
Aug 18, 2026
Merged

fix(security): PDF magic-byte validation, per-email OTP limits, structured logging#94
Sam-Aitech merged 2 commits into
mainfrom
security/audit-followup

Conversation

@Sam-Aitech

Copy link
Copy Markdown
Owner

Context

Follow-up to an external security audit of server/routes/verification.ts and server/ipRateLimit.ts. Verified every finding against current main before changing anything.

Findings already closed — no action needed

# Finding Already fixed by Evidence
1 Path traversal via multer dest: 'uploads/' SEC-001 sanitizeUploadPath() + assertSafeUploadFilename() in uploadGuard.ts, canonicalizes and verifies containment
3 X-Forwarded-For trusts first IP without proxy validation SEC-005/006 getClientIp() uses req.ip via app.set('trust proxy', 1), not manual header parsing — the file doesn't parse XFF at all
5 Error info disclosure (pre-existing) errorHandler.ts masks all 500s as "Internal Server Error"; only intentional ApiError 4xx messages reach the client
7 SESSION_SECRET non-null assertion SEC-011 if (!sessionSecret) throw new Error(...) — explicit check, not !
10 Missing Helmet.js (pre-existing) Installed and configured with CSP/HSTS/frame-ancestors at server/index.ts:134

Finding #8 (unsafe-eval in dev CSP) is intentionally scoped to !isProduction for Vite HMR — left as-is, already the minimal necessary exception.

Findings fixed here

SEC-030 — PDF uploads validated by content, not just client-supplied MIME type. fileFilter only checked file.mimetype, a header the client sets and can spoof. Added assertPdfMagicBytes() to uploadGuard.ts — reads the file's actual first 5 bytes on disk, requires the literal %PDF- signature. Applied to all three upload endpoints: /api/verify, /api/admin/extract-metadata, /api/admin/trusted-patterns.

SEC-031 — OTP requests only rate-limited per IP, not per target email. otpLimiter caps requests per caller IP (5/15min), but a caller distributed across IPs — or behind a shared NAT/proxy — could send unlimited OTP emails to one victim address. Added otpEmailLimiter, keyed on the target email (case-insensitive, IP-keyed fallback when no email present), applied to /api/auth/email/send-otp and /api/auth/admin/send-otp alongside the existing IP limiter.

SEC-032 — Bare console.* calls replaced with structured logger. 12 calls across admin.ts, consolidatedNotificationEngine.ts, sponsorEtlClient.ts.

An incident during testing — disclosed, not hidden

While adding tests for assertPdfMagicBytes(), running the existing uploadGuard.test.ts deleted a real file: uploads/2ad13bcc825b22e904a0ce0c0ab247db, a genuine Apache FOP Certificate of Sponsorship PDF that had been committed to the repo. The test's beforeEach/afterEach ran fs.rmSync(UPLOADS_DIR, ...) against the real uploads directory (cwd/uploads, since UPLOADS_DIR was never overridden before the module import) — a pre-existing test-isolation gap, not something this PR's new code introduced.

I restored the file from git before committing. The test now sets UPLOADS_DIR to a temp directory via a dynamic import before any test runs, matching the isolation pattern cosVerification.test.ts already used — this class of accident can't recur.

Flagging, not fixing: uploads/ is not in .gitignore, meaning real user-uploaded documents — potentially containing personal/immigration data — can end up committed to git history. That's a bigger and more sensitive issue than anything in the original audit, and fixing it properly (gitignore + history purge) is a destructive, user-facing decision I'm not making unilaterally in a drive-by commit. Recommend a separate, deliberate pass.

Tests

19 new tests: 5 for assertPdfMagicBytes (valid header, spoofed content, empty file, truncated file, header not at offset 0), 5 for otpEmailLimiter (per-email limiting, independence across emails from the same IP, case-insensitivity, IP-keyed fallback), plus the isolation fix to the existing 4 tests in uploadGuard.test.ts.

Test Files  35 passed (35)
Tests       367 passed | 1 todo (368)
tsc         clean
eslint      clean
build       clean

🤖 Generated with Claude Code

…tured logging

Follow-up to an external security audit of server/routes/verification.ts and
server/ipRateLimit.ts. Most findings (path traversal, X-Forwarded-For trust,
error disclosure, SESSION_SECRET, missing Helmet) were already closed by
SEC-001/002/005/006/011 in earlier work — verified each against current HEAD
before touching anything. Three findings were real and are fixed here:

- SEC-030: multer's fileFilter only checks the client-supplied mimetype, which
  any client can spoof. Added assertPdfMagicBytes() to uploadGuard.ts, which
  reads the file's actual first 5 bytes on disk and requires the literal
  %PDF- signature. Applied to all three upload endpoints: /api/verify,
  /api/admin/extract-metadata, /api/admin/trusted-patterns.

- SEC-031: otpLimiter caps OTP requests per caller IP (5/15min), but has no
  cap per target email — a caller distributed across IPs, or behind a shared
  NAT/proxy, could send unlimited OTP emails to one victim address. Added
  otpEmailLimiter, keyed on the target email (case-insensitive) with an
  IP-keyed fallback when no email is present, applied to both
  /api/auth/email/send-otp and /api/auth/admin/send-otp.

- SEC-032: replaced 12 bare console.log/warn/error calls in admin.ts,
  consolidatedNotificationEngine.ts, and sponsorEtlClient.ts with the
  structured logger.

Also fixed a pre-existing footgun uncovered while adding tests: the existing
uploadGuard.test.ts ran rm/mkdir cycles directly against the real UPLOADS_DIR
(cwd/uploads when unset), since it imported the module before setting an
override. Running it deleted a genuine Apache FOP CoS PDF that was checked
into uploads/ at some point (uploads/ is not gitignored, meaning real
uploaded documents can end up in git history — flagged separately, not
touched here since purging history is the user's call). The test now sets
UPLOADS_DIR to a temp directory before a dynamic import, matching the
isolation pattern already used in cosVerification.test.ts. The deleted file
was restored from git before this commit.

19 new tests: 5 for assertPdfMagicBytes, 5 for otpEmailLimiter, plus the
uploadGuard isolation fix. 367 total, tsc and eslint clean, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread server/routes/verification.ts Fixed
…tests

CodeQL alert #340: server/routes/verification.ts's assertPdfMagicBytes()
call flagged as path injection (js/path-injection). GitHub's actual inline
suppression syntax requires the `codeql[rule-id]` comment to trail the SAME
line as the flagged call, not precede it. Most of this codebase's existing
suppressions use the preceding-line style, which silently does nothing —
they never got caught because the default-setup CodeQL check only flags
NEW alerts introduced by a diff, and none of those pre-existing lines were
touched by a prior PR. My new call was new code, so it's the first to
expose the gap. Fixed the three call sites this PR introduces (verification.ts,
two in admin.ts) using the same-line style already proven to work elsewhere
in the file (e.g. admin.ts:144, admin.ts:196). Did not touch the pre-existing
preceding-line comments elsewhere — out of scope for this PR, flagging
separately.

Also addressed 2 SonarCloud MAJOR findings (S5976): consolidated 3 near-
duplicate tests in otpEmailLimiter.test.ts and 4 in uploadGuard.test.ts into
parametrized it.each blocks. Same coverage, same test count (19), less
duplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Comment thread server/routes/verification.ts Dismissed
@Sam-Aitech
Sam-Aitech merged commit 841c109 into main Aug 18, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants