Repository navigation
feat(web): serve a per-request CSP nonce so Emotion's styles apply under style-src 'self' - #874
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced 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 |
|
@coderabbitai review |
|
…ring Every_response_carries_the_security_headers compared the response against SecurityHeaders.BuildContentSecurityPolicy(nonce) — the same function under test — so a directive dropped from the builder changed both sides of the assertion identically and stayed green. Confirmed by mutation: removing form-action 'self' from the builder left the test passing until the expected policy was rewritten by hand as a literal string with only the nonce substituted, after which the same mutation went red. Local Codex review on #874.
…/HEAD Two gaps in the #873 SPA shell, both measured against a real loopback Kestrel listener before being fixed: - UseSpaShell's exact-match check misses a raw "GET //index.html" request (Kestrel does not collapse a double leading slash the way it collapses a "/./" segment, which the app never even sees), so it fell through to UseStaticFiles. PhysicalFileProvider still resolved it to the on-disk index.html and served it raw — no nonce meta, plus an ETag/Last-Modified pair #873 deliberately never emits on this document. IndexHtmlHidingFileProvider wraps the static-file provider so index.html is unreachable under any spelling; the request then falls through further to MapFallback, which answers with the templated shell like any other client-side route. - MapFallback(spaShell.WriteAsync) carried no HTTP method restriction, so POST / and DELETE /index.html both returned a 200 HTML shell. The StaticFileMiddleware + MapFallbackToFile pair this replaced served only GET/HEAD (measured against that pre-#873 pipeline directly: it answers 405 with "Allow: GET, HEAD"). UseSpaShell's exact-match branch now falls through for any other method, and MapFallback carries the same HttpMethodMetadata, so both entry points answer the one 405. /./index.html needed no fix: Kestrel's own PathNormalizer collapses it to /index.html before the app runs, which SpaShellTests now pins directly. Local Codex review on #874.
Local Codex review (CodeRabbit rate limited)Three findings from a local Codex pass, verified against the code and against 1. 2. 3. Verification. Round 2 (local Codex pass, at 4. Verification. Review loop stopped deliberately here: round 1 found two product defects Owner decision on #874 (2026-09-14), not a review finding. The hidden CSP-probe
PR body's Scope and Verification updated to match: the Login.tsx bullet now describes the removal Verification. Commit |
…onstant Style_src_keeps_self_and_adds_exactly_one_nonce asserted the decoded nonce length equals SecurityHeaders.NonceByteCount — the same constant under test — so shrinking NonceByteCount from 16 to 4 (32-bit nonces, well below CSP's "unguessable" floor) changed both sides of the assertion identically and stayed green. Confirmed by mutation: set the constant to 4, the test stayed green until the assertion was rewritten against a literal minimum of 16 decoded bytes, after which the same mutation went red. Local Codex review round 2 on #874, at d02cad9.
Owner decision on #874 (2026-09-14): the hidden `.sr-only`, `aria-hidden` MUI Chip on the login screen was production markup that existed only for a test — the #873 CSP-nonce Playwright spec needed a real Emotion-styled element to read a computed background from, and a probe is not the same thing as a real component. Removed rather than relocated. csp-nonce.spec.ts keeps the nonce-agreement assertions (the served document's meta nonce matches its own response header's style-src nonce, online and after an offline service-worker reload) and the no-CSP-violation assertion; the computed-background assertions and the brandColour/PROBE helpers they needed are gone with the probe. The end-to-end "an MUI component's computed style applies under the real CSP, online and offline" assertion moves to #864's first rendered MUI component — amendment comment on that issue, and in docs/decisions/674-ui-component-library.md. PR #874 body's Scope and Verification amended to match: the Login.tsx bullet, the two Playwright mutation rows that depended on the probe (kept the four that don't), and the six probe screenshots replaced with a sentence — this PR changes nothing a user sees.
MUI styles through Emotion, which injects a <style> element at runtime. Under `style-src 'self'` the browser refuses to parse it, so no MUI styling reaches the screen and nothing reports it. `BuildContentSecurityPolicy(nonce)` replaces the policy constant and `GetOrCreateNonce` mints 16 bytes of RandomNumberGenerator output per request, cached on `HttpContext.Items` so the document and the header read one value. Nothing else in the policy moves, and the test now pins the whole string rebuilt from the one token allowed to vary.
`SpaShell` reads the built index.html once at boot, splits it at <head>, and answers `/`, `/index.html` and the SPA fallback with a `<meta name="csp-nonce">` carrying that response's own nonce. `/index.html` is in that set on purpose: workbox precaches it and the service worker then answers every navigation from the cached response, so an untemplated copy there costs MUI its styling on every client that installed the worker. The shell emits no ETag and no Last-Modified. A validator derived from the file would tell a client its cached copy is fresh while the live header no longer admits that copy's nonce. #141's `no-cache` is unchanged and hashed /assets/* stay static and immutable.
`FarmThemeProvider` reads `<meta name="csp-nonce">` once at module load and wraps its children in `CacheProvider` with a cache created from that value. A missing meta yields no nonce, which is Vite's dev document and, in a production build, the fail-closed outcome. `@emotion/cache` moves from a transitive of `@emotion/react` to a declared dependency; the regenerated lock adds one line and pins nothing new. The login screen renders one hidden MUI Chip. Emotion injects nothing until an MUI component renders, so the end-to-end check needs one to read. It is `.sr-only` and `aria-hidden` because this slice is groundwork sequenced before every screen slice, and a visible Chip would make a styling decision #864 owns.
The defect is invisible to every other instrument here: the DOM is correct and only the computed style is wrong. So the spec reads a rendered background in a real browser under the real header, and compares the document's nonce with the one its own response admits. The offline half is not redundant. The service worker answers navigations from the precached shell, and that is only safe because workbox caches one whole Response, so the header and the body it stores were minted together. mutants.ts records why these two mutants are source rebuilds rather than network-boundary ones: a page.route rewrite lands on the first load and lapses once the worker takes over, which is the half that matters.
The amendment carries the finding, the two rejected options, and the invariant that every Emotion cache in this app carries the page nonce. It also states the consequence a reader will not infer: for a client running the service worker the nonce is per precache entry, not per request. architecture.md gains the SpaShell placement row, because a middleware whose order is load-bearing belongs in the drawn pipeline.
…ring Every_response_carries_the_security_headers compared the response against SecurityHeaders.BuildContentSecurityPolicy(nonce) — the same function under test — so a directive dropped from the builder changed both sides of the assertion identically and stayed green. Confirmed by mutation: removing form-action 'self' from the builder left the test passing until the expected policy was rewritten by hand as a literal string with only the nonce substituted, after which the same mutation went red. Local Codex review on #874.
…/HEAD Two gaps in the #873 SPA shell, both measured against a real loopback Kestrel listener before being fixed: - UseSpaShell's exact-match check misses a raw "GET //index.html" request (Kestrel does not collapse a double leading slash the way it collapses a "/./" segment, which the app never even sees), so it fell through to UseStaticFiles. PhysicalFileProvider still resolved it to the on-disk index.html and served it raw — no nonce meta, plus an ETag/Last-Modified pair #873 deliberately never emits on this document. IndexHtmlHidingFileProvider wraps the static-file provider so index.html is unreachable under any spelling; the request then falls through further to MapFallback, which answers with the templated shell like any other client-side route. - MapFallback(spaShell.WriteAsync) carried no HTTP method restriction, so POST / and DELETE /index.html both returned a 200 HTML shell. The StaticFileMiddleware + MapFallbackToFile pair this replaced served only GET/HEAD (measured against that pre-#873 pipeline directly: it answers 405 with "Allow: GET, HEAD"). UseSpaShell's exact-match branch now falls through for any other method, and MapFallback carries the same HttpMethodMetadata, so both entry points answer the one 405. /./index.html needed no fix: Kestrel's own PathNormalizer collapses it to /index.html before the app runs, which SpaShellTests now pins directly. Local Codex review on #874.
…onstant Style_src_keeps_self_and_adds_exactly_one_nonce asserted the decoded nonce length equals SecurityHeaders.NonceByteCount — the same constant under test — so shrinking NonceByteCount from 16 to 4 (32-bit nonces, well below CSP's "unguessable" floor) changed both sides of the assertion identically and stayed green. Confirmed by mutation: set the constant to 4, the test stayed green until the assertion was rewritten against a literal minimum of 16 decoded bytes, after which the same mutation went red. Local Codex review round 2 on #874, at d02cad9.
Owner decision on #874 (2026-09-14): the hidden `.sr-only`, `aria-hidden` MUI Chip on the login screen was production markup that existed only for a test — the #873 CSP-nonce Playwright spec needed a real Emotion-styled element to read a computed background from, and a probe is not the same thing as a real component. Removed rather than relocated. csp-nonce.spec.ts keeps the nonce-agreement assertions (the served document's meta nonce matches its own response header's style-src nonce, online and after an offline service-worker reload) and the no-CSP-violation assertion; the computed-background assertions and the brandColour/PROBE helpers they needed are gone with the probe. The end-to-end "an MUI component's computed style applies under the real CSP, online and offline" assertion moves to #864's first rendered MUI component — amendment comment on that issue, and in docs/decisions/674-ui-component-library.md. PR #874 body's Scope and Verification amended to match: the Login.tsx bullet, the two Playwright mutation rows that depended on the probe (kept the four that don't), and the six probe screenshots replaced with a sentence — this PR changes nothing a user sees.
d591c8e to
feb46ef
Compare
|
Rebased onto main after #871 merged (head feb46ef). One conflict, in |
…ch walk resolves it The guard resolves a route handler through its declaring type; a method group on a local variable (spaShell.WriteAsync) has none it can see and the walk fails closed. A local function keeps the same delegate and the same GET/HEAD metadata.
|
CI on the rebase failed the application leg: #872's |
🤖 I have created a release *beep* *boop* --- ## [0.1.2](v0.1.1...v0.1.2) (2026-09-16) ### Features * **data:** standardize business record chronology ([#820](#820)) ([6231b31](6231b31)) * **infra:** optional leader-lease endpoint for pooled deploys ([#869](#869)) ([e9bc6a7](e9bc6a7)) * **sim:** seed a second farm for the README dashboard capture ([#867](#867)) ([de407c6](de407c6)) * **web:** adopt MUI, themed from the farm palette tokens ([#674](#674)) ([#860](#860)) ([6c83c5c](6c83c5c)) * **web:** convert Daily entry to MUI, field-first on the phone ([#888](#888)) ([b66f8b8](b66f8b8)) * **web:** convert the Dashboard and app shell to MUI ([#829](#829)) ([#883](#883)) ([2e94277](2e94277)) * **web:** retire the Slack-blue link colour for ink + a rule underline ([#884](#884)) ([c08f9d8](c08f9d8)) * **web:** serve a per-request CSP nonce so Emotion's styles apply under style-src 'self' ([#874](#874)) ([ba4e6f3](ba4e6f3)) * **web:** visual language theme overrides for the MUI revamp ([#864](#864)) ([#882](#882)) ([0bb6b73](0bb6b73)) * **web:** whole-app MUI baseline, theme policy guard and the [#740](#740) phone action rule ([#823](#823)) ([#871](#871)) ([af565e4](af565e4)) ### Bug fixes * **auth:** fail closed on unresolved flock-scope actors ([#787](#787)) ([#868](#868)) ([16d0350](16d0350)) * **auth:** make farm configuration owner-only ([#870](#870)) ([42f9036](42f9036)) * **e2e:** repoint the canary at the markup two PRs replaced ([#844](#844)) ([18b45dc](18b45dc)) * **i18n:** tl glossary uses the standard passive of ilagay ([#813](#813)) ([20dec10](20dec10)), closes [#738](#738) * **sim:** stop the k6-baseline EXIT trap masking a clean run as failed ([#838](#838)) ([f5ec96f](f5ec96f)) * **web:** declare the rule tokens the Dashboard reads, and guard undeclared custom properties ([#885](#885)) ([5bead1f](5bead1f)) ### Performance * **ci:** start the serialized integration collection first ([#861](#861)) ([1dcc7f6](1dcc7f6)), closes [#839](#839) ### Documentation * **auth:** record the OAuth 2.1 decision for MCP authentication ([#801](#801)) ([0510854](0510854)) * **designs:** MUI revamp design doc, component map, layout system, IA ([#862](#862)) ([da49481](da49481)) * **readme:** recapture the daily entry, reports and sales screenshots ([#865](#865)) ([f18e336](f18e336)) * **specs:** correct the sales_order_items column list in §10.5 ([#812](#812)) ([afe4a02](afe4a02)), closes [#737](#737) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Why
The Production CSP is
style-src 'self'. MUI styles through Emotion, which injects a<style>element at runtime, and the browser refuses to parse it under that policy — so no
sx,styled()orstyleOverridesvalue reaches the screen, and nothing reports it. Measured on thesim harness in #871: the element was in
<head>,htmlcomputedbox-sizing: content-box, and/daily-entrylaid out 419px wide in a 390px frame. It stayed invisible only because no MUIcomponent had rendered yet.
The owner's answer (2026-09-14) keeps the policy strict and gives
style-srca per-response nonce.SecurityHeadersmints it,index.htmlis served templated with the same value in a<meta>, andFarmThemeProviderhands it to Emotion's cache.script-srcis untouched.Closes #873
Scope
SecurityHeaders.BuildContentSecurityPolicy(nonce)replaces theContentSecurityPolicyconstant.GetOrCreateNonce(HttpContext)mints 16 bytes ofRandomNumberGeneratoroutput per request andcaches it on
HttpContext.Items, so the document and the header read one value.src/Cluckwork.Api/Hosting/SpaShell.csreadswwwroot/index.htmlonce at boot, splits it at<head>, and answers/,/index.htmland the SPA fallback.app.UseDefaultFiles()is gone;MapFallbackToFilesurvives only for the no-wwwrootcase. Hashed/assets/*are untouched./index.htmlis in that set deliberately: workbox precaches it and the service worker thenanswers every navigation from that cached response, so an untemplated copy there would cost MUI
its styling on every client that installed the worker.
web/src/theme/FarmThemeProvider.tsxreads the meta once at module load intocreateCache({ key: "mui", nonce, prepend: true })behindCacheProvider.@emotion/cachebecomes a declared dependency; the regenerated lock adds one line and pins nothing new.
web/src/routes/Login.tsxrenders no MUI component. Amendment (feat(web): serve a per-request CSP nonce so Emotion's styles apply under style-src 'self' #874 review, 2026-09-14): anearlier revision of this slice added a hidden
.sr-only,aria-hiddenMUIChipsolely so thePlaywright spec had a real Emotion-styled element to read a computed background from. The owner
removed it as production markup that exists only for a test. The end-to-end assertion — an MUI
component's computed style applies under the real CSP, online and offline — moves to web: visual language for the MUI revamp, chosen and encoded as theme overrides #864's first
rendered component; see the amendment comment on that issue.
docs/decisions/674-ui-component-library.mdgains an amendment;docs/architecture.mdgains theSpaShellplacement row.bootstrap.sh,docker-compose.sim.yml,verify-harness.sh) and Developer experience: add an Aspire AppHost for local orchestration and observability #565's AppHost need nothing. No GLOSSARY or Help change: this adds noconcept and no user-visible behaviour.
Tradeoffs
'unsafe-inline'was rejected — it reopens CSS injection including attribute-selectorexfiltration. Build-time CSS extraction was rejected too: it fights #674's runtime
palette-from-the-document design.
The shell carries no
ETagand noLast-Modified. A validator derived from the file would tell aclient its cached copy is fresh while the live header no longer admits that copy's nonce, which is
the failure this change exists to prevent. #141's
no-cacheis unchanged.For a client running the service worker the nonce is per precache entry, not per request.
navigateFallbackanswers navigations from the cached shell, online and offline. That is safebecause workbox caches one whole
Response, so its header and its body were minted together — theoffline test asserts that rather than assuming it. Not precaching the shell would retire #142's
offline guarantee, a worse trade.
Blast Radius
Every response's CSP header and every SPA document. An
index.htmlthat cannot be templated failsthe boot rather than serving an unstyled app.
/api/*and/health/*keep their 404 guards aheadof the fallback (#266), pinned by tests.
/INDEX.HTMLstill 404s: the path match is ordinal,mirroring the case-sensitive file lookup it replaces on Linux.
Verification
dotnet build Cluckwork.slnclean.dotnet test Cluckwork.slnundersg docker: 2625 passed, 0failed.
npm run typecheckandnpm run test:coverageinweb/: 3001 passed, ratchet green andnot re-baselined.
verify-harness.shgreen. The full Playwright smoke suite on a sim stack rebuiltat this head: 48 passed, 1 skipped (the dispatch-only expiry spec).
Four mutations, each run red before the claim.
createCachedropsnoncenonceattributenullstyle-srcdrops the nonceSpaShelldrops the metaAmendment (#874 review, 2026-09-14): this table originally carried two further rows — the same
two mutations (
style-srcdrops the nonce;SpaShelldrops the meta), run as source mutationswith an image rebuild against the Playwright layer — plus a probe-background measurement table and
six screenshots of a hidden MUI
Chipon the login screen, showing its computed background underthe real CSP with and without the nonce. Both depended on that
Chip, which existed only to bemeasured; the owner removed it as production markup that exists only for a test (see Scope). The
nonce-agreement assertions
csp-nonce.spec.tskeeps still exercise the same two mutations — adropped header nonce or a dropped meta both break the document-matches-its-own-header comparison
the surviving assertions make — so the regression class stays covered; what is gone is the specific
claim that a computed background caught them, since the apparatus that measured it no longer
exists.
This PR changes nothing a user sees.