security: fix CodeQL alerts and harden defaults - #7
Closed
Vlad G (vladpm) wants to merge 3 commits into
Closed
Conversation
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.
Vlad G (vladpm)
force-pushed
the
security/codeql-fixes
branch
from
August 13, 2026 20:24
cd4c04e to
b9178be
Compare
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.
Contributor
Author
Thom McKiernan (thommck)
added a commit
that referenced
this pull request
Aug 24, 2026
Platform updates + security hardening (supersedes #7)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes both open High CodeQL alerts on
mainand hardens the platform for a production security bar (independent-tester sweep, two commits).Commit 1 — CodeQL alerts + immediate defaults (
c98d2ab)js/incomplete-multi-character-sanitization—scripts/check-markdown-links.mjs:20Heading 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-logging—scripts/identity/bootstrap.mjs:902Broke the taint path from Microsoft Graph credential material to the top-level catch:
setAzdValueno longer lets the rawvaluereach any thrown error,credential.keyIdis no longer interpolated into the rollback error, and the finalconsole.errorinmain().catchredacts 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-Policyvianext.config.jsand disabledx-powered-by.Content-Dispositionheader injectionNew
src/lib/http/content-disposition.ts(RFC 6266filename+filename*) applied everywhere the app returns aContent-Dispositionheader 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
postcssto8.5.26and added a scopedoverridesentry fornanoidso 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.assertUuidon 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-insightGET and POST;admin/modules/{id}/toggle,admin/modules/{id}/version;admin/policies/{id},activate,source;ai/chatconversationId and analysisId.assertSlugguardsrequirementKeyandsectionKeyagainst path-traversal characters.assertContentLengthreturns 413 beforereq.formData()/req.json(), closing an unbounded-memory upload vector.fileSignatureMatchesMimeis 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 spoofedContent-Typecannot bypass the MIME allow-list.CSRF (same-origin) on every mutation
isTrustedMutationOriginwas previously wired to only some admin policy routes. Now applied to: register, upload,ai/chat,ai/licence-analysisPOST,ai/application-insightPOST,applications/{id}/answersPUT,applications/{id}/submitPOST,applications/{id}/licencePOST,admin/modules/{id}/togglePOST,admin/modules/{id}/versionPOST. Cross-origin requests get 403 before body parsing.Auth hardening
httpOnly,sameSite=lax,securein production, and the__Host-/__Secure-prefixes;useSecureCookiesis toggled byNODE_ENV.maxAgereduced from 8 h to 4 h.Credentials.authorizealways runsbcrypt.compare, even when the user does not exist, so response timing cannot be used to enumerate accounts.userIddropped from the response body, IP + user agent captured in the audit log, and the catch block no longer echoes rawerror.message.Race condition on submit
Application submission now uses an optimistic
updateManyfiltered onstatus = DRAFTandapplicantId = 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 carryRetry-AfterandX-RateLimit-Reset.AI hardening
sanitiseUserContentstrips prompt-injection markers (<system>,<policy>,<licence>, "ignore previous instructions"), removesCR, and clips oversized input before it reaches the model — wrapped aroundchatOfficer,chatApplicant,analyseLicenceandassessCompliance.JSON.parseof AI responses now uses a reviver that drops__proto__,constructor, andprototypekeys as a prototype-pollution defence.ai/chatpropagates only sanitised error text on failure.^[a-z]{2}(-[A-Za-z0-9]{2,8})?$.Response headers (continued)
next.config.jsnow emits a strictContent-Security-Policyalongside the six headers from commit 1:default-src 'self',frame-ancestors 'self',form-action 'self',object-src 'none',connect-src 'self',img-srclimited toblob:and Azure Blob Storage./api/health/readyno longer reveals database-connected/-disconnected state.Path traversal
safeRelativeCallbackUrlnow double-decodes and rejects..,\,NUL, and control characters, and explicitly rejects protocol-relative URLs.safeAuthRedirectgained the same traversal filter..env.exampleRemoved dangerous defaults:
NEXT_PUBLIC_DEMO_MODE,NEXT_PUBLIC_SHOW_SAMPLE_BANNER,AUTH_ENABLE_DEMO_CREDENTIALS,DEMO_PASSWORDnow default to a production-safe posture;NEXTAUTH_SECRETis blank with a generate-me comment (the previouschange-me-in-production…string was a valid value that would still start a deployment).Auditable dependency posture
npm auditstill flags GHSA-2v37-7h3g-55p8 through thepostcss → nanoidchain (postcss 8.x requiresnanoid ^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.mjsenforcesnpm audit --audit-level=lowbut 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 rawnpm audit.Tests
Added
tests/security-helpers.test.tscoveringassertUuid,assertSlug,assertContentLength,fileSignatureMatchesMime,checkRateLimit,requestClientAddress,safeRelativeCallbackUrl(including encoded / double-encoded traversal), andcontentDispositionHeader. Total suite now 57 tests / 18 suites, all pass.Verification
Both commits verified locally in a clean worktree.
npm run lint— cleannpm run typecheck— cleannpm run docs:check— cleannpm run validate:release— cleannpm run test— 57 tests / 18 suites, all passnpm run audit:allowlist— one documented allowlisted advisory, no unresolvedNODE_ENV=production npm run build— 27 routes, no errors/api/health/readyreturns{"status":"ready"}onlyRequired 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.