fix(frontend): scope nginx root to server + add cache-control headers - #1773
Conversation
`root` was declared inside `location /`, so it never reached sibling locations. Any new location that serves from disk -- /assets/, /index.html, /config.js, /site.webmanifest -- would instead inherit nginx's compiled-in default root (/etc/nginx/html, which does not exist in the nginx:stable image) and 404 everything it serves, while `nginx -t` still reports success. Hoisted `root` to server scope. That total mis-scope fails CLOSED, and it is worth stating precisely rather than overselling it: with `root` back inside `location /`, /, /index.html, /config.js, /user/foo AND /assets/* all 404 (the try_files fallback re-runs location matching into `location = /index.html`, which has no root), so the readiness probe on / never goes green and the rollout's synthetic analysis fails its very first probe. What neither in-cluster gate can see is breakage confined to /assets/, or a wrong or leaked Cache-Control value on a path that still returns 200. That narrower case is what the CI job below is for. Asset TTL is deliberately a conservative `public, max-age=300` rather than the eventual `public, max-age=31536000, immutable`. Vite bundles are content-hashed so the long TTL is correct in principle, but `immutable` lands in browser caches where it cannot be invalidated -- a wrong value is unrecallable for a year. It gets raised in a follow-up once curl parity has been captured against the running pod and this location matching is confirmed in situ. Also: JSON access log on both vhosts (server-scope `access_log` overrides, whereas a second http-scope one would append and double-log every request), X-Forwarded-For client resolution trusting 10/8 because this one image is promoted across five different /16s, and a `validate-frontend-nginx` CI job that runs the shipped conf in a stock nginx image and probes real requests -- the only gate that sees an /assets/-confined regression or a wrong Cache-Control value on a path that still returns 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
03a6e14 to
bc8e82e
Compare
|
Pushed a review-fix pass. Five issues, all in comments / test-script assertions / PR-and-commit text — no change to served nginx behaviour. Force-pushed as What changed1. Inverted XFF trust guidance corrected (
2. A false rationale, repeated in four places, replaced with the true narrower one. The claim that "the synthetic analysis only probes ( 3. Compression probe hardened — 4. JSON access-log check hardened — matched lines are now counted and required to be 5. PR body arithmetic and one false regression claim. "25/25" over a 24-row table is now 24/24, with the CI script's 22 assertions stated separately. Probe 12 ( Mutation verificationBoth hardenings were verified by mutation, not assumed. Every mutation below was run against the previous script (all passed, exit 0 — the bug) and then against the hardened script (all fail, exit 1). FIX 3 — compression. nginx's default
Why the fixture sizes matter, measured on FIX 4 — access log.
Sanity checks that did not regress: the real config still passes (22/22 assertions, exit 0), and re-nesting One incidental fix found by running the hardened script: No behaviour change — proofStatus plus every response header was captured for 17 request variants ( Independently confirmed at the source level: every change to |
20-lens review complete: 11 findings survived refutation, 0 blockers — all now fixedTwenty independent lenses, each finding then put through an adversarial refutation pass. Every survivor was in a comment, the PR text, or test coverage — the nginx config itself needed no change, and that is now proven rather than asserted: 17 request variants (status + every response header, identical fixture tree both runs) diff byte-identical, and filtering comments out of The correction that matters mostThis PR previously claimed — following the issue — that a mis-scoped The real justification for this script is narrower and does hold: neither in-cluster gate can see breakage confined to Two probes could false-pass — both mutation-verified before and after
The access-log check passed on zero lines ( A nuance worth recording: nginx's default Also fixed
Deliberately unchanged
CI green on |
ryanjjung
left a comment
There was a problem hiding this comment.
Approving, though the test config is pretty complicated and I'm not sure I followed every line of it.
Scope: this does NOT affect the ECS + CloudFront + S3 deployment
Production and staging are untouched by this PR. They do not use nginx at all — the SPA is served from S3, not from a container. Traced end to end:
frontend/docker/etc/nginx/conf.d/appointments.confhas exactly one consumer:frontend/deploy.dockerfile:28.frontend/deploy.dockerfilehas exactly one builder:.github/workflows/publish-images.yml:69, which publishes the multi-arch GHCR frontend image — the EKS path only.deploy-production.yml:41-42deploys the frontend withaws s3 sync ./dist "s3://$FRONTEND_BUCKET"+create-invalidation. No image, no nginx.deploy-staging.yml:46-47does the same:aws s3 sync frontend/dist s3://$FRONTEND_BUCKET.docker build ./backend -f ./backend/Dockerfile), a different Dockerfile this PR does not touch.frontend/Dockerfile, is the local-dev vite server and never reads the nginx config.The conf is also not an input to
vite build, so the bundle is byte-for-byte unaffected — this is aCOPYinto the nginx image and nothing else.The other two files are equally inert for prod:
frontend/docker/test-nginx-config.shruns only in CI, and thevalidate.ymladdition is a new job in the PR-validation workflow — it does not touchdeploy-production.ymlordeploy-staging.yml.This is deliberate: the ECS/CloudFront/S3 pipeline stays untouched until the EKS cutover (thunderbird/platform-infrastructure#707). To be precise about what that means: this change is active on the EKS tb-dev deployment — that is its purpose — and inert everywhere else.
Why
rootwas declared insidelocation /, which meant it never reached sibling locations. Every new location in this change that serves from disk —/assets/,/index.html,/config.js,/site.webmanifest— would have inherited nginx's compiled-in default root (/etc/nginx/html, which does not exist in thenginx:stableimage) and 404'd everything it serves, whilenginx -tstill reports success.rootis hoisted to server scope, with a comment recording why it must stay there. Alongside it: realCache-Controlheaders per content class, a JSON access log on both vhosts, X-Forwarded-For client resolution, and a CI job that probes actual requests.To be precise about the blast radius rather than overselling it: a total root mis-scope fails closed. Reproduced in a container — with
rootback insidelocation /,/,/index.html,/config.js,/user/fooand/assets/*all 404 (open() "/etc/nginx/html/index.html" failed), because thetry_filesfallback re-runs location matching intolocation = /index.html, which has no root. So the rollout's readiness probe on/never goes green andanalysis-frontend-syntheticfails its very first probe.What neither in-cluster gate can see is breakage confined to
/assets/, or a wrong or leakedCache-Controlvalue on a path that still returns 200. Those promote green — and that, not the total mis-scope, is the real reason the behavioural test below exists. (The upstream issue has been corrected to match; this PR now uses the same framing.)Evidence — probe matrix against the real image
podman build -f frontend/deploy.dockerfile(exit 0), nginx 1.30.4, realdist/:cache-controlcontent-type/assets/index-D5xKgVJQ.jspublic, max-age=300/assets/AvailabilityView-Dzsaous6.csspublic, max-age=300/assets/missing-000.js/assets/anything.js.map/assets/AvailabilityView-vM6NC5iH.js.map(on disk)/no-store/index.htmlno-store/user/foono-store/config.jsno-store/favicon.svgpublic, max-age=300/site.webmanifestpublic, max-age=300/sitemap.txt(MIME map not clobbered)public, max-age=300Accept-Encoding: gzip, deflate, brsent on every probepublic, max-age=300content-encodingon any probed path, and bundle keepsContent-Lengthnginx -tnginx -Twarn/emerg/duplicate lines = 0Host: stage.apt.mtredirect/api/v1/health→error_page(unchanged)jq-invalid access log lines = 0cf_popfield gone from log"remote_addr":"203.0.113.9"from XFF198.51.100.7)24/24 pass — that is 24 data rows in the table above (an earlier revision of this body said "25/25", which was simply an arithmetic error). Separately, the CI script
frontend/docker/test-nginx-config.shasserts 22 checks and additionally hard-fails onnginx -t; the two counts are not the same thing, because the matrix above is a manual run against the realdist/while the script runs against a synthetic tree.Probes 14/17/18 genuinely fail on
origin/main— itsaccess_logis commented out, so lines come out in the inherited combined format (10.0.0.74 - - [...] "GET /probe14 HTTP/1.1" 200), and with noset_real_ip_fromtheremote_addrstays the peer's10.0.0.74.Probe 12 is not a regression check — it is a no-regression check. An earlier revision of this body listed it alongside 14/17/18 as failing pre-edit. That was wrong: the shortlink
rewriteis untouched by this PR, and the response is byte-identical onorigin/main— 302,Location: https://appointment-stage.tb.pro/user/abc,Content-Length: 145. It is in the matrix to prove the second vhost still behaves exactly as it did, which is worth asserting given alog_formatand threereal_ipdirectives were added at http scope where they reach both server blocks.Re-nesting
rootinsidelocation /(the original blocker) still givesnginx: syntax is okbut 9 probe FAILs, including/assets/*404 and the deep route not serving the shell.New CI job
validate-frontend-nginxrunsfrontend/docker/test-nginx-config.shunder the existingfrontend/**path filter: the shipped conf in a stocknginx:stableover a syntheticdist/tree. No npm build, no registry auth, no AWS creds, and no interpolation of untrusted${{ github.event.* }}.Two of its assertions were originally able to pass vacuously and have been hardened. Both hardenings were mutation-verified — confirmed failing against each mutation and still passing against the real conf:
Compression probe. It offered
Accept-Encodingat one call site and checked a 23-byte fixture, so it missed every realistic way of turning origin gzip on.Accept-Encoding: gzip, deflate, bris now folded into the sharedprobe()helper so every probe asserts noContent-Encoding; the SPA-shell fixture is padded past 1000 bytes and the bundle fixture is ~64 KB (both sizes are load-bearing against agzip_min_length); and the bundle response must still carry aContent-Length, since it is the loss of that header under nginx's chunked gzip — not the value ofContent-Encoding— that lets CloudFront cache a partial object (#814).JSON access-log check. Its
badcounter only incremented while reading agrep-filtered stream, so zero matched lines read as PASS. It now counts matched lines and requires> 0, selects log lines by excluding entrypoint/[notice]output rather than include-matching^{(so a non-JSON format is counted as bad instead of vanishing), asserts viajq -ethat a captured line hasx_forwarded_for,cf_id,request_time,upstream_response_timeanduser_agent, and probes each vhost withX-Forwarded-For: 203.0.113.9to assert the loggedremote_addrresolves to it. The localjq-missingSKIPbranch is kept but now hard-fails whenCIis set, so a silently-skipped assertion cannot recur.Deliberate choices
gzip is intentionally not enabled at the origin. Compression belongs at CloudFront: nginx's
gziprewrites the body afterContent-Lengthis set, which breaks range/partial-object requests and makes byte-level cache validation inconsistent — the edge does Brotli better and closer to the viewer. Probe 10b asserts noContent-Encodingcomes back on any path even when the client offersgzip, deflate, br, and that the bundle keeps itsContent-Length.The
/assets/TTL is deliberatelymax-age=300for now. Vite bundles are content-hashed, soimmutable, max-age=31536000is correct in principle and is the follow-up — but only after curl parity is captured from the running pod, becauseimmutablelands in browser caches, where a wrong value cannot be invalidated by any CloudFront invalidation and is unrecallable for a year.no-store(notno-cache) on the SPA shell is deliberate: it is the only directive that also keeps the deploy pointer out of shared/CDN storage, and the 304 revalidation it forfeits is on a ~1.6 KB document.Read
$remote_addr, never the leftmostX-Forwarded-Forelement. The log-format comment previously said the opposite.real_iphas already resolved$remote_addrto the address the ALB observed; the leftmost XFF entry is whatever the viewer sent and is therefore the one hop an attacker fully controls. Verified from a 10/8 peer:X-Forwarded-For: 1.2.3.4, 203.0.113.9logsremote_addr=203.0.113.9while1.2.3.4is the forgery. Once CloudFront fronts this origin the trustworthy element is the rightmost-but-one — the hop CloudFront appended — or forwardCloudFront-Viewer-Address.Deferred, tracked not lost
CloudFront origin-facing ranges in
set_real_ip_from,no-storeon/api/v1/and/fxa, and attachingAllViewerExceptHostHeaderto the default +/assets/*behaviours all belong with the CDN work that owns the cache policies, not this file. One genuine pre-existing issue found and left alone as out of scope: the proxy blocks pass$proxy_add_x_forwarded_for, which lets a client seed the chain and bypass IP rate limiting — filing separately.Part of thunderbird/platform-infrastructure#814
🤖 Generated with Claude Code