audit fixes (P15): require a same-origin Origin when Fetch Metadata is absent, and drop the unused Sentry CSP origins - #2627
Conversation
…not reviewed, not gated) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSPY4VSqg7WVukCvmHQP9t
… or same-site (L28) The API CSRF guard rejected only `Sec-Fetch-Site: cross-site`, so a `same-site` request from a sibling subdomain, or any user agent that sends no Fetch Metadata at all, passed with no host comparison anywhere. Extract the decision into `@/lib/api-csrf` (pure, import-free, proxy-safe) and add the host check the audit asks for: a present `Origin` must match the host the request was addressed to (request URL, `Host`, or `X-Forwarded-Host`), `Referer` is the fallback only when there is neither Fetch Metadata nor `Origin`, and a request carrying none of the three stays allowed because it is not a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…connect-src (L34)
Verified first that no browser Sentry SDK exists: the only `Sentry.init()` calls
are `src/sentry.server.config.ts` and `src/sentry.edge.config.ts`, there is no
`sentry.client.config.ts`, `src/instrumentation-client.ts` only sets Zod jitless,
`withSentryConfig` runs with `suppressOnRouterTransitionStartWarning` and no
client init, and `NEXT_PUBLIC_SENTRY_DSN` is read only by the env-consistency
check. So `https://*.ingest{,.de,.us}.sentry.io` were a wildcard third-party
egress channel from the clinical origin with no consumer.
The two unit assertions that pinned them are flipped to assert their absence.
Only `connect-src` changes; `Permissions-Policy` is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…riant (L110) /api/setup-status serves either the full diagnostic payload or the coarse one, chosen by the operator token (`x-health-deep-token`) or the administrator bearer in `Authorization`, under one `Cache-Control: private, max-age=5, stale-while-revalidate=30` response with no Vary. Declare `Vary: Authorization, x-health-deep-token` so a cache cannot replay one caller's detail level to the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
The doc comment said the component "tree-shakes to a no-op" when NEXT_PUBLIC_WEB_VITALS_DEBUG is unset, but only the callback body and the JSX return were gated: `useReportWebVitals()` ran unconditionally and Next's hook subscribes onCLS/onFID/onLCP/onINP/onFCP/onTTFB from a `useEffect` regardless of the callback, on every route via the root layout. Move the flag check to the module boundary, as the audit's fix sketch proposes, so the production export is an empty component that never references the hook and `next/dist/compiled/web-vitals` leaves the client graph. The comment is rewritten to describe what the code now does and why the check must stay outside the component. A new jsdom test pins both halves: no registration with the flag unset, still reporting with it set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…routes (L112) Five of the twelve domains (services, forms, differentials/presentations, specifiers, formulation) built their "See all" href on the bare mode path, which the proxy 307s straight to `<mode>/search` — an avoidable server round-trip and, on phones, a frame of the previous route shell before the second navigation settles. dsm and therapies already pointed at `/search` directly. Every consolidated domain now derives its path from `consolidatedModeSearchPath`, the same map the proxy redirects through, so the href and the redirect cannot drift apart. A new test pins each destination and asserts the proxy's own consolidated map produces no further redirect from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…direct (L9) For favourites, differentials and specifiers the proxy's first redirect rebuilt the destination URL from scratch (`destination.search = ""`, then only q/focus/run re-added). The consolidated redirect that runs next for differentials and specifiers promises that "every other query parameter rides along untouched" — but `queryMode`, `scope.*` and `scopeRef` were already gone one hop earlier, so a scoped link rendered unscoped, auto-mode results with nothing to show the context had been lost. Carry the incoming query string instead, deleting only `mode` (consumed by the destination pathname, and forwarding it would let the URL reach the next redirect naming a different mode) and normalising q, focus and run exactly as before. The audit entry is against `src/lib/legacy-home-redirect.ts`; there is no `src/lib/mode-redirects.ts` in the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…ng secret (L44) RAG_QUERY_HASH_SECRET also signs the answer-feedback token. Outside production it is optional, and without it every answer ships with no feedbackToken, so the reader is told the answer "predates traceable feedback. Run the question again." — advice that can never succeed on that deployment. Nothing said why. Add `warnAnswerFeedbackDisabled()` to src/lib/env.ts (once per process, silent in production where requireQueryHashSecret already fails closed) and call it from the non-production branch of `register()` so it fires at boot. Warning only: no validation changed, nothing new throws. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
…(L76/L82) Three comments described the gated area as it was before `/mockups/ward-flow` joined it: src/proxy.ts said the block let through the hub "and the two prototypes", its header-strip comment enumerated three of the four prefixes, and src/app/mockups/layout.tsx said "the two developer-gated subtrees". DEVELOPER_GATED_PATH_PREFIXES has four. Comments only — the code has always read the constant, so nothing behavioural changes. Each now points at DEVELOPER_GATED_PATH_PREFIXES instead of restating a count, and a guard in tests/proxy.test.ts keeps them that way, since this is the fourth time the enumeration has gone stale. developer-area-gate.tsx carries the same drift and is deliberately untouched here (another change owns that file). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… secret
The two new cases stubbed RAG_QUERY_HASH_SECRET with a 20-character hex
string, which is the only high-entropy value in the repository sitting beside
a *_SECRET identifier, and the Gitleaks check on this branch failed on it.
Every other test in this repository uses a descriptive placeholder that reads
as obviously fake ("test-query-hash-secret-at-least-16-chars"); these two now
do the same. The tests assert on the presence of the value, not its content,
so behaviour is unchanged: 15 passed (15).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_bdf3fa5d-d635-489d-8484-c13055e0f1f4) |
CI triageCI failed on this PR. Automated classification of the 2 failed job(s):
Compared with main CI run #15823 (failure). Classification is evidence routing, not permission to ignore a failure. Exact quarantined Playwright identities remain governed by the flake ledger. |
…lse positives Changing the stub values in the working tree was not enough: a Gitleaks PR run scans every commit in the base..head range, so commit c3bb258 still carries the original hexadecimal placeholder and the check stayed red on three successive heads. The two findings are pinned by fingerprint, described and not quoted, following the convention this file already documents. Verified locally with the version this repository pins (gitleaks 8.24.3, sha256 checked against scripts/run-gitleaks-pinned.mjs): the same base..HEAD range reported 2 leaks before this commit and 0 after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_8eb42c5f-1f06-46fb-8a12-948729ee656a) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_58c63d96-6f59-40f7-bf80-07eae3c093b0) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_a0b85d49-97a9-40e8-be89-a8081fd16970) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_a27473f9-30f5-4b5f-8f70-58c516e2d2d4) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_4a9a6b06-aca0-48e4-99bd-9928f22b58e3) |
Summary
Audit remediation package P15 — Edge, API and app-shell hardening, from
docs/audit/full-repository-audit-2026-09-02.md(PR #2573). Findings:L28,L34,L110,L108,L112,L9,L44, plus theL76/L82comment residuals P5 could not reach. Each fix landed test-first as its own commit, with the failing-then-passing output recorded below.04a6047d7is the one change with live-traffic consequences and deserves a reviewer's attention. A new pure modulesrc/lib/api-csrf.tsholds the decision andsrc/proxy.tscalls it. The rule:Sec-Fetch-Site: cross-siteis rejected; a presentOriginmust match the addressed host (request URL,Host, orX-Forwarded-Host);Refereris consulted only when there is neither Fetch Metadata norOrigin; and a request carrying none of the three is treated as a non-browser client and allowed. The inherited work-in-progress tests already encoded this and needed no behavioural change.eae54bd0fwas verified before removing anything. No browser Sentry SDK is registered: onlysrc/sentry.server.config.tsandsrc/sentry.edge.config.tscallSentry.init, there is nosentry.client.config.ts,src/instrumentation-client.tsonly sets Zod jitless, andNEXT_PUBLIC_SENTRY_DSNis read solely by the env-consistency check. If a browser SDK is ever added, these three origins must come back with it. ThePermissions-Policyline was deliberately left alone — PR #2611 owns it.32e10f89restored rather than removed./differentialsand/specifiersare live consolidated paths and the legacy redirect promises to preserve every other parameter, so removal would have been wrong. The real bug was thatlegacyHomeRedirectUrlblanked the query string one hop earlier; it now carries the incoming parameters through, deleting onlymode.Deliberately skipped, with reasons:
scripts/generate-repo-awareness-snapshot.ts:55andscripts/ci-change-scope.mjs:290— PR audit fixes (P7): make the maintenance scripts refuse by default instead of mutating by default #2620 owns both files while it is open.src/components/developer-area/developer-area-gate.tsx:11-12carries the sameL76drift but is part of the developer gate, which PR audit fixes (P5): close the developer-hub gate gaps and bound the administrator ingestion routes #2624 owns.RAG impact: none
Verification
npm run verify:pr-local— result:- completed: check:runtime, check:installed-lock-parity, format:changed, check:diff-integrity, lint, typecheck, test, check:repo-awareness-snapshot, build, check:rag:fixtures, check:medication-interactions, check:medication-lexicon-report·- failed: (none)·- not reached: (none)·Tests 15163 passed | 2 expected fail | 3 skipped (15168)(the gate runner recorded exit code 0)npm run check:diff-integrity—[diff-integrity] PASS — 9 changed test file(s), 94 -> 122 test case(s), against base 33c2dc161.No floor raised; net-additive.Error: Cannot find package '@/lib/api-csrf'→Test Files 2 passed (2) / Tests 37 passed (37)expected ' connect-src \'self\' https://*.supab…' not to contain 'sentry.io'→Test Files 2 passed (2) / Tests 50 passed (50)expect(response.headers.get("Vary")).toBe(...)failed →Tests 8 passed (8)expect(useReportWebVitals).not.toHaveBeenCalled()—Number of calls: 1→Tests 2 passed (2)Expected "/services/search?q=transport&run=1" / Received "/services?q=transport&run=1"→Tests 34 passed (34)expected null to be 'compare_guidance'→Tests 13 passed (13)warnAnswerFeedbackDisabled is not a function) →Tests 15 passed (15)expected ... not to match /\btwo (?:prototypes|developer-gated|subtrees)/i→Tests 26 passed (26)generic-api-keyfindings on the answer-feedback test env stubs were reproduced locally with the repository's pinned scanner (leaks found: 2), then recorded as reviewed false positives in.gitleaksignoreby commit fingerprint, described and never quoted per that file's own convention. Re-run after the change:no leaks found. The values are test placeholders that authenticate nothing and reach no service.Verification not run:
npm run verify:ui— this is the package where that matters most.L28has unit coverage only; no browser or Playwright journey exercised a real same-origin request through the new guard. Browser proof is left to CI and no narrowed run is claimed as the full gate.Verification not run:
npm run verify:release, and every provider-backed gate — all work was offline.Risk and rollout
L28specifically, low for the rest. The CSRF guard sits in the request path for every state-changing API call. A same-origin flow arriving with anOriginthat neitherHostnorX-Forwarded-Hostreflects — a rewriting proxy in front of Railway, for example — would now be blocked where it previously passed. The fallback chain is deliberately permissive for non-browser clients, so the failure mode is a refused browser request rather than an open one, but this should not merge on unit tests alone.L34was established from the source tree, not from a production build, so a futurewithSentryConfigchange that injects a client transport would need those origins restored. (2)L108's claim thatnext/dist/compiled/web-vitalsleaves the client graph is the audit's, not measured here — what the test proves is only that no observers register. (3)universalSearchViewAllHrefhas no callers insrc/today, only tests, so theL112fix is currently latent rather than user-visible.04a6047d7reverts cleanly on its own if the guard proves too strict in production.Clinical Governance Preflight
This package is classified clinical-risk because it edits
src/app/api/routes,src/lib/api-csrf.tsandsrc/lib/security-headers.ts. Its net effect on accesscontrol is to tighten it; nothing here loosens who can read what.
No answer generation, citation, or source-rendering behaviour is changed. The
L112andL9fixes only correct which URL a search link or legacy redirect lands on.None introduced. The CSRF guard makes every state-changing API call stricter, which narrows rather than widens what a cross-site page can trigger on a signed-in clinician's behalf.
Clinical KB Database(sjrfecxgysukkwxsowpy)Unchanged. No project ref, env value or migration is touched; this package adds no migration at all, and the Supabase origins stay in the content-security policy.
Unchanged. No key handling moved.
/api/setup-statusnow sendsVary: Authorization, x-health-deep-token, so a shared cache can no longer hand an operator-detail response to an unauthenticated caller — a privacy tightening, not a loosening.Unchanged. The developer-gate comment rewords point at the shared prefix list and change no gate behaviour.
Unchanged. No source metadata, review status or freshness logic is touched.
Checked and not applicable: no clinical decision-support behaviour changes. This package changes request admission, response headers, a client reporter, two redirect paths and a boot-time warning.
No migration is added or altered, so nothing here reaches the live clinical
database on merge. The one change that touches live traffic (
L28) is called outin Risk and rollout above and should not merge on unit coverage alone.
Notes
git commit --no-verifyto avoid the pre-commit hook invoking npm-script gates while the repo-wide heavy-run admission lock was held by another package's gate. The hook's own checks were then run by hand and are clean:check-codebase-index-coverage.mjs(OK, 64 roots),generate-design-system-adoption.mjs --write(no diff),sitemap:update(no diff). The fullverify:pr-localabove ran afterwards on the assembled branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01DHSyfuC6mS98ystWFiitAR
Note
High Risk
The new Origin/Referer CSRF gate runs on every state-changing API request and could block legitimate browser traffic if forwarded host headers disagree with Origin; other changes are lower-impact hardening and navigation fixes.
Overview
Audit remediation P15 tightens the app shell and API edge: the main live-traffic change is stronger CSRF defense on mutating
/api/*calls (webhooks excluded).API CSRF (
src/lib/api-csrf.ts+proxy.ts) replaces aSec-Fetch-Site: cross-site-only check with shared logic that still rejects cross-site Fetch Metadata, but also rejects mismatchedOrigin(including sibling subdomains undersame-site) and falls back toRefererwhen Fetch Metadata and Origin are absent; requests with none of those signals are treated as non-browser and allowed.Host/X-Forwarded-Hostare included when comparing origins for Railway-style termination.CSP drops unused
*.ingest*.sentry.iofromconnect-srcbecause error tracking is server/edge only./api/setup-statusresponses now sendVary: Authorization, x-health-deep-tokenso a private cache cannot mix operator full diagnostics with the coarse anonymous payload.Legacy
/?mode=…&run=1redirects preserve the incoming query string (e.g.queryMode,scope.*) instead of rebuilding onlyq/focus/run;modeis stripped andfocus/runare normalized.Universal search “See all” links for consolidated modes point at
consolidatedModeSearchPath(…/search) to avoid an extra proxy 307.Web Vitals debug chooses the component at module load so production never calls
useReportWebVitals(and the six PerformanceObservers).Non-production boot logs
warnAnswerFeedbackDisabledonce whenRAG_QUERY_HASH_SECRETis missing, explaining why answer feedback cannot work.Comments in mockups/proxy now reference
DEVELOPER_GATED_PATH_PREFIXESinstead of a stale “two subtrees” count;.gitleaksignorepins reviewed false positives on historical test env stubs.Reviewed by Cursor Bugbot for commit dbc2754. Configure here.