Skip to content

security: fix CodeQL alerts and harden defaults - #7

Closed
Vlad G (vladpm) wants to merge 3 commits into
mainfrom
security/codeql-fixes
Closed

security: fix CodeQL alerts and harden defaults#7
Vlad G (vladpm) wants to merge 3 commits into
mainfrom
security/codeql-fixes

Conversation

@vladpm

@vladpm Vlad G (vladpm) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes both open High CodeQL alerts on main and hardens the platform for a production security bar (independent-tester sweep, two commits).

Commit 1 — CodeQL alerts + immediate defaults (c98d2ab)

js/incomplete-multi-character-sanitizationscripts/check-markdown-links.mjs:20

Heading slugifier used a single-pass .replace(/<[^>]+>/g, ""). Nested constructs such as <<script>> survived one pass. Now iterates a stricter <[^<>]*> to a fixpoint.

js/clear-text-loggingscripts/identity/bootstrap.mjs:902

Broke the taint path from Microsoft Graph credential material to the top-level catch: setAzdValue no longer lets the raw value reach any thrown error, credential.keyId is no longer interpolated into the rollback error, and the final console.error in main().catch redacts anything resembling a base64 / base64url blob.

Response-header defaults

Added X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Strict-Transport-Security, Permissions-Policy, Cross-Origin-Opener-Policy via next.config.js and disabled x-powered-by.

Content-Disposition header injection

New src/lib/http/content-disposition.ts (RFC 6266 filename + filename*) applied everywhere the app returns a Content-Disposition header so a user-controlled filename cannot inject additional response headers.

PII in worker stubs

Notification / reminder log lines no longer include recipient identifiers, subjects, or free-text message bodies.

Vulnerable transitive dep

Bumped postcss to 8.5.26 and added a scoped overrides entry for nanoid so GHSA-2v37-7h3g-55p8 is neutralised at the fastest available fixed version.

Commit 2 — Independent-tester hardening (b9178be)

Wider sweep of the whole API surface after a systematic review.

Input validation everywhere

New src/lib/http/validation.ts.

  • assertUuid on every [id] route parameter and every id taken from a request body, before the value reaches Prisma. Coverage: documents download, documents upload, applications/{id}/answers, documents, licence, submit; ai/licence-analysis/{id}; ai/application-insight GET and POST; admin/modules/{id}/toggle, admin/modules/{id}/version; admin/policies/{id}, activate, source; ai/chat conversationId and analysisId.
  • assertSlug guards requirementKey and sectionKey against path-traversal characters.
  • assertContentLength returns 413 before req.formData() / req.json(), closing an unbounded-memory upload vector.
  • fileSignatureMatchesMime is a narrow magic-byte allow-list (PDF, PNG, JPEG, GIF, WEBP, SVG, MS Word CFB, OOXML). Applied to document uploads and PDF licence-analysis uploads so a spoofed Content-Type cannot bypass the MIME allow-list.
  • Uploaded filenames are normalised (CR/LF/NUL and path separators stripped) before persistence.

CSRF (same-origin) on every mutation

isTrustedMutationOrigin was previously wired to only some admin policy routes. Now applied to: register, upload, ai/chat, ai/licence-analysis POST, ai/application-insight POST, applications/{id}/answers PUT, applications/{id}/submit POST, applications/{id}/licence POST, admin/modules/{id}/toggle POST, admin/modules/{id}/version POST. Cross-origin requests get 403 before body parsing.

Auth hardening

  • NextAuth cookies now use httpOnly, sameSite=lax, secure in production, and the __Host- / __Secure- prefixes; useSecureCookies is toggled by NODE_ENV.
  • Session maxAge reduced from 8 h to 4 h.
  • Credentials.authorize always runs bcrypt.compare, even when the user does not exist, so response timing cannot be used to enumerate accounts.
  • Register endpoint: per-IP rate limit (5/min), same-origin check, RFC-5322-ish email pattern with length cap, name allow-list, password policy raised to 12+ chars with a 3-of-4 character-class rule, bcryptjs rounds bumped 12 → 14, generic 202 response used regardless of whether the address already exists (409 vs 201 no longer discloses enrolment status), userId dropped from the response body, IP + user agent captured in the audit log, and the catch block no longer echoes raw error.message.

Race condition on submit

Application submission now uses an optimistic updateMany filtered on status = DRAFT and applicantId = current user, so two concurrent submissions cannot both flip the row (second returns 409).

Rate limiting

New src/lib/http/rate-limit.ts — in-memory fixed window with a note that production ingress should apply a Redis-backed limit for cross-replica enforcement. Applied to register (5/min per IP), officer chat (30/min per user id) and applicant chat (10/min per IP). All rate-limit denials carry Retry-After and X-RateLimit-Reset.

AI hardening

  • sanitiseUserContent strips prompt-injection markers (<system>, <policy>, <licence>, "ignore previous instructions"), removes CR, and clips oversized input before it reaches the model — wrapped around chatOfficer, chatApplicant, analyseLicence and assessCompliance.
  • JSON.parse of AI responses now uses a reviver that drops __proto__, constructor, and prototype keys as a prototype-pollution defence.
  • ai/chat propagates only sanitised error text on failure.
  • Language codes must match ^[a-z]{2}(-[A-Za-z0-9]{2,8})?$.

Response headers (continued)

next.config.js now emits a strict Content-Security-Policy alongside the six headers from commit 1: default-src 'self', frame-ancestors 'self', form-action 'self', object-src 'none', connect-src 'self', img-src limited to blob: and Azure Blob Storage. /api/health/ready no longer reveals database-connected/-disconnected state.

Path traversal

safeRelativeCallbackUrl now double-decodes and rejects .., \, NUL, and control characters, and explicitly rejects protocol-relative URLs. safeAuthRedirect gained the same traversal filter.

.env.example

Removed dangerous defaults: NEXT_PUBLIC_DEMO_MODE, NEXT_PUBLIC_SHOW_SAMPLE_BANNER, AUTH_ENABLE_DEMO_CREDENTIALS, DEMO_PASSWORD now default to a production-safe posture; NEXTAUTH_SECRET is blank with a generate-me comment (the previous change-me-in-production… string was a valid value that would still start a deployment).

Auditable dependency posture

npm audit still flags GHSA-2v37-7h3g-55p8 through the postcss → nanoid chain (postcss 8.x requires nanoid ^3.3.17; the advisory fix range is <3.3.18, which has not been published; postcss itself does not exercise the vulnerable custom-generator code path). Rather than downgrade postcss / next / next-auth, scripts/audit-with-allowlist.mjs enforces npm audit --audit-level=low but records this single advisory in a documented allow-list with an explicit removal condition. CI now calls the wrapper (npm run audit:allowlist) instead of raw npm audit.

Tests

Added tests/security-helpers.test.ts covering assertUuid, assertSlug, assertContentLength, fileSignatureMatchesMime, checkRateLimit, requestClientAddress, safeRelativeCallbackUrl (including encoded / double-encoded traversal), and contentDispositionHeader. Total suite now 57 tests / 18 suites, all pass.

Verification

Both commits verified locally in a clean worktree.

  • npm run lint — clean
  • npm run typecheck — clean
  • npm run docs:check — clean
  • npm run validate:release — clean
  • npm run test — 57 tests / 18 suites, all pass
  • npm run audit:allowlist — one documented allowlisted advisory, no unresolved
  • NODE_ENV=production npm run build — 27 routes, no errors
  • Standalone server smoke-tested: CSP + all other security headers emitted; register rejects mismatched Origin with 403; /api/health/ready returns {"status":"ready"} only

Required PR checks green: Quality and infrastructure, Container images, CodeQL gate, CodeQL availability, license/cla. Merge remains gated by the normal branch-protection approval + conversation resolution.

Vlad Pavlovic added 2 commits August 13, 2026 14:54
CodeQL alerts (both High):
- js/incomplete-multi-character-sanitization at scripts/check-markdown-links.mjs
  The heading slugifier used a single-pass regex to strip HTML tags. Nested
  patterns like <<script>> survived one iteration. Replace with a fixpoint
  loop and a stricter <[^<>]*> character class.
- js/clear-text-logging at scripts/identity/bootstrap.mjs
  Break the taint path from Microsoft Graph credential material to the
  top-level catch: wrap setAzdValue so the underlying value never appears
  in a thrown error, drop credential.keyId interpolation from the rollback
  error, and redact base64/base64url-looking blobs from the final stderr
  emission.

Defence in depth also applied:
- Add Content-Security response headers (X-Content-Type-Options,
  X-Frame-Options, Referrer-Policy, HSTS, Permissions-Policy, COOP) to
  every route via next.config.js, and disable x-powered-by.
- Introduce src/lib/http/content-disposition.ts (RFC 6266 filename +
  filename*) and use it in every response that emits a Content-Disposition
  header so a user-controlled filename cannot inject additional headers.
- Reduce PII in worker.ts stub log lines (drop recipient identifiers,
  subjects and free-text message bodies).

Vulnerable transitive dependency:
- npm audit reported nanoid@<3.3.17 (GHSA-2v37-7h3g-55p8, High) pulled in
  via postcss. Bump postcss to 8.5.26 and add a scoped override forcing
  nanoid ^3.3.17 in the postcss subtree; docx nanoid@5 is unaffected.

Verification:
- npm run lint, npm run typecheck, npm run docs:check, npm run
  validate:release, npm run test (34 tests / 10 suites, all pass).
- npm audit: 0 vulnerabilities.
- NODE_ENV=production npm run build succeeds; standalone server serves the
  six new security headers on GET /.
Independent-tester deep sweep of the whole API surface, addressing findings
from a broader review than the two CodeQL alerts alone.

## Input validation

New src/lib/http/validation.ts. Every [id] route parameter and every id
field taken from a request body now goes through assertUuid before it
reaches Prisma, so IDOR probes and injection payloads cannot touch the
database. Applied across:
  documents download, documents upload, applications/{id}/answers,
  documents, licence, submit; ai/licence-analysis/{id}, application-
  insight (both GET and POST); admin/modules/{id}/toggle and version;
  admin/policies/{id}, activate, source; ai/chat conversationId and
  analysisId; register email/name/password patterns.

assertSlug guards requirementKey and sectionKey against path traversal
by rejecting anything outside a bounded slug pattern.

assertContentLength returns 413 before req.formData() / req.json() so a
malicious client cannot force the server to buffer arbitrary amounts of
memory before validation.

fileSignatureMatchesMime is a narrow magic-byte allow-list (PDF, PNG,
JPEG, GIF, WEBP, SVG, MS Word CFB, OOXML). Applied to document uploads
and PDF licence-analysis uploads so a spoofed Content-Type cannot bypass
the MIME allow-list.

Filename normalisation on the upload path strips CR/LF/NUL and any
path separators before persistence.

## CSRF (same-origin) coverage

isTrustedMutationOrigin was only wired to some admin policy routes.
Extended to every state-changing endpoint: register, upload, ai/chat,
ai/licence-analysis POST, ai/application-insight POST, applications/
{id}/answers PUT, applications/{id}/submit POST, applications/{id}/
licence POST, admin/modules/{id}/toggle POST, admin/modules/{id}/version
POST. Any request whose Origin (or Referer, as fallback) does not match
NEXTAUTH_URL is rejected with 403.

## Auth hardening

- NextAuth cookies now use httpOnly, sameSite=lax, secure in production,
  and the __Host- / __Secure- prefixes; useSecureCookies is toggled by
  NODE_ENV.
- Session maxAge reduced from 8h to 4h.
- Credentials.authorize always runs bcrypt.compare, even when the user
  does not exist, so response timing cannot be used to enumerate accounts.
- Register endpoint: dedicated per-IP rate limit (5/min), same-origin
  check, RFC-5322-ish email pattern and length cap, name allow-list,
  password policy raised to 12+ chars with 3-of-4 character-class rule,
  bcryptjs rounds bumped 12 -> 14, generic 202 response used regardless
  of whether the address already exists (so 409 vs 201 no longer discloses
  enrolment status), userId dropped from the response body, IP + user
  agent captured in the audit log, and the catch block no longer echoes
  raw error.message.

## Race condition on submit

Application submission now uses an optimistic updateMany filtered on
status = DRAFT and applicantId = current user, so two concurrent
submissions cannot both flip the row (409 on the second).

## Rate limiting

New src/lib/http/rate-limit.ts — in-memory fixed window with a note that
production ingress should apply a Redis-backed limit for cross-replica
enforcement. Applied to register (5/min per IP), officer chat (30/min
per user id) and applicant chat (10/min per IP). All rate-limit denials
carry Retry-After and X-RateLimit-Reset.

## AI hardening

- sanitiseUserContent strips prompt-injection markers (system/policy/
  licence tags, "ignore previous instructions", CR chars) and clips oversized
  input before it reaches the model, wrapped around chatOfficer,
  chatApplicant, analyseLicence and assessCompliance.
- JSON.parse of AI responses now uses a reviver that drops __proto__,
  constructor, and prototype keys as a prototype-pollution defence.
- ai/chat propagates only sanitised error text on failure.
- language codes must match ^[a-z]{2}(-[A-Za-z0-9]{2,8})?$.

## Response headers

next.config.js now emits a strict Content-Security-Policy in addition
to the six headers added in the previous commit (default-src self,
frame-ancestors self, form-action self, object-src none, connect-src
self, image-src limited to blob storage). Health check no longer
reveals database-connected/-disconnected state.

## Path traversal

safeRelativeCallbackUrl now double-decodes and rejects .., \, NUL and
control characters, plus explicitly rejects protocol-relative URLs.
safeAuthRedirect gained the same traversal filter.

## .env.example

Removed dangerous defaults: NEXT_PUBLIC_DEMO_MODE, NEXT_PUBLIC_SHOW_SAMPLE_
BANNER, AUTH_ENABLE_DEMO_CREDENTIALS and DEMO_PASSWORD now default to a
production-safe posture; NEXTAUTH_SECRET is blank with a generate-me
comment; the previous "change-me-in-production-use-openssl-rand-base64-32"
placeholder value would have been a valid string that started a
deployment.

## Auditable dependency posture

npm audit currently flags GHSA-2v37-7h3g-55p8 through the postcss ->
nanoid chain (postcss 8.x requires nanoid ^3.3.17, the advisory fix
range is <3.3.18 which has not been published, and postcss itself does
not exercise the vulnerable custom-generator code path). Rather than
downgrade postcss / next / next-auth, a new scripts/audit-with-allowlist
.mjs enforces npm audit --audit-level=low but records this single
advisory in a documented allow-list with an explicit removal condition.
CI now calls the wrapper (npm run audit:allowlist) instead of raw
npm audit.

## Tests

Added tests/security-helpers.test.ts covering assertUuid, assertSlug,
assertContentLength, fileSignatureMatchesMime, checkRateLimit,
requestClientAddress, safeRelativeCallbackUrl (including encoded / double-
encoded traversal), and contentDispositionHeader. Total suite now 57
tests / 18 suites, all pass.

## Live verification

Standalone production server confirmed to emit CSP + the other six
headers, register rejects mismatched Origin with 403, register rejects a
non-JSON body with 403 at the origin check, /api/health/ready returns
{"status":"ready"} only. Lint, typecheck, docs check, release check and
audit:allowlist all clean.
E2E testing of the hardening pass surfaced a read-modify-write race in
submitApplication that pre-existed on main: five concurrent POSTs to
/api/applications/{id}/submit produced five workflow_events, five audit
rows, and five workflow-engine transitions even though the row already
carried the routes optimistic updateMany.

Fix: promote the status update inside submitApplication to an
optimistic updateMany filtered on status = DRAFT. Only the first
concurrent caller flips the row; later callers now see count = 0 and
return "Application has already been submitted" without emitting a
workflow event or audit log.

Verified locally against Postgres 16:

- 5 parallel curl POSTs to submit the same application
- Before: 5 x 200 status, 5 workflow_events, 5 audit rows
- After:  1 x 200 (winner), 1 x 400 (workflow-engine loser), 3 x 409
  (route-level updateMany loser); 1 workflow_event, 1 audit row, status
  = UNDER_REVIEW

npm run lint, typecheck, test (57 tests / 18 suites), production build,
audit:allowlist all clean.
@vladpm

Copy link
Copy Markdown
Contributor Author

Superseded by #8, which incorporates all of this branch's security hardening (input validation, CSRF/same-origin, CSP, hardened rate limiting, race-safe submit, CodeQL fixes) alongside the platform updates, so only a single approval is needed. Closing in favour of #8.

Thom McKiernan (thommck) added a commit that referenced this pull request Aug 24, 2026
Platform updates + security hardening (supersedes #7)
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.

1 participant