fix(web): make the proxy the single authority for the shared security headers - #161
Merged
Merged
Conversation
… headers nginx already set X-Content-Type-Options, X-Frame-Options and Referrer-Policy at the server level, but two of the three ways a response leaves the container did not match that intent, and one of the values contradicted the API's own. - location /assets/ declares its own add_header, which replaces the inherited set rather than adding to it, so every cached bundle shipped with none of the three. Restated in place. - Referrer-Policy was same-origin while the API sends no-referrer, and on /v1/ both reached the client as two values of one header. The proxy value is now no-referrer and the API's copies of all three are hidden on the proxied path, so nginx decides the wire value without weakening a memd that runs without a proxy in front of it. - Content-Security-Policy, X-XSS-Protection and Content-Disposition stay the API's, because they depend on what the response is. Neither failure is visible by reading the config, so scripts/ test_nginx_security_headers.sh starts a real nginx and measures the headers: 28 assertions over five surfaces, 11 of which fail on the previous config and all of which pass on this one. Both fix sites and the always flag are each individually load-bearing against a targeted mutation.
The `Deployment profiles` leg failed on the first run of the new step: ``` #5 0.117 ERROR: Unable to lock database: Permission denied ERROR: process "/bin/sh -c apk add --no-cache bash curl python3 gettext" did not complete successfully: exit code: 99 ``` `web/Dockerfile:17` ends on `nginxinc/nginx-unprivileged:1.27.4-alpine3.21`, whose own build stops at `USER 101`. That uid cannot write the apk database, so installing the harness tools in the image under test is not possible -- and running the step as root would measure the headers as a user the shipped container never uses. The tools therefore go into a throwaway child image: root installs, then the image returns to uid 101. The step above still builds the production image untouched, and the harness runs unprivileged against the nginx that ships in it. `NGINX_BIN` is pinned, so a missing binary fails the leg instead of printing `SKIP`.
This was referenced Sep 3, 2026
PeterGuy326
approved these changes
Sep 3, 2026
PeterGuy326
left a comment
Collaborator
There was a problem hiding this comment.
code owner review: single-authority proxy fix; all checks green on head; mergeable.
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.
What this changes
Implements the security-header split decided for this repository on 2026-09-03: nginx is the single authority for
X-Content-Type-Options,X-Frame-OptionsandReferrer-Policy, uniformlyno-referrer, with the proxied path de-duplicated byproxy_hide_header; the API keepsContent-Security-Policy,X-XSS-ProtectionandContent-Disposition.Closes #135 (the two live proxy defects) and Closes #136 (the ownership question that defect raised).
This is the in-repo redo of #144, which came from a fork and is being closed under the fork-policy decision. It is not a cherry-pick of that commit — no commit from it is imported here.
The two defects, as measured
Both were real on
main@1332bf46and neither is visible by reading the config, which is why the test starts an nginx rather than grepping one./assets/lost all three headers. Anadd_headerinside a location replaces the inherited set instead of adding to it, and that block has always hadadd_header Cache-Control. Every cached bundle shipped with nonosniff, noX-Frame-Optionsand noReferrer-Policyat all./v1/sent two conflictingReferrer-Policyvalues on one response. The proxy saidsame-origin, the API saysno-referrer, and nginx'sadd_headerappends rather than replaces, so a client received both and the effective policy depended on which one the browser kept.X-Content-Type-OptionsandX-Frame-Optionswere also duplicated (same value twice).Why the API keeps sending the three it no longer owns
proxy_hide_headermakes the wire value nginx's, which is what "nginx 独占" requires, without deletingnosniff/DENY/no-referrerfromsecurityHeadersMiddleware. Amemdreached directly — the Helm path exposes the Service, anddocs/DEPLOYMENT.mdonly makes the nginx guarantee for the container — keeps its defense in depth. If the intended reading was instead that the Go middleware should stop setting them, that is a one-line change here and it should be said before merge, because the alternative silently weakens the no-proxy deployment.Test evidence
scripts/test_nginx_security_headers.shrenders the shipped template with the sameenvsubstfilter and variables the container entrypoint uses, runs a fake upstream that answers exactly likesecurityHeadersMiddlewaredoes, starts nginx against the config, and reads headers off the wire across five surfaces (/,/assets/, a 404 under/assets/,/v1/,/healthz).main/assets/surfaces,same-originon/and/healthz, 3 duplicated on/v1//assets/restatement/assets/200)alwaysfrom the/assets/restatement/assets/404 only)proxy_hide_header/v1/duplicates)Each of the three fix sites is therefore individually load-bearing, and the 404 surface earns its place: it is the only one that tests
always.The harness also refuses to report success on a partial run (the assertion count is pinned), hard-fails when a caller names an
NGINX_BINthat is not executable, and only reportsSKIPwhen no nginx was found at all — so the CI leg cannot go green by measuring nothing.Executed locally against nginx 1.27.4, the same minor the
web/Dockerfilepins (nginxinc/nginx-unprivileged:1.27.4-alpine3.21).Measured in CI on the built image, and it did go red there first. The first run of the new step failed exactly where this paragraph expected the risk to be:
web/Dockerfile:17ends onnginxinc/nginx-unprivileged, whose own build stops atUSER 101, soapk addcould not write the package database --ERROR: Unable to lock database: Permission denied,exit code: 99. Commit1c917f96installs the four tools in a throwaway child image that returns to uid 101 afterwards, so the harness still runs unprivileged, as the shipped container does. On that headDeployment profilesis green and its job log carriesall 28 security-header assertions passed, so the leg executed the contract against the nginx that ships rather than printingSKIP. The harness content measured above is unchanged by that commit; it touches only the workflow step.Not in scope here, and worth a decision
Nothing in this repository sets a
Content-Security-Policyfor the SPA itself —default-src 'none'is an API-only value and would break the web app if applied to it. The decided split covers the three shared headers and leaves HTML/SPA CSP unowned. This PR deliberately does not invent one.Links and state
Refsthe decided split;Closes #135/Closes #136on merge.security_headers_test.goandcontent_*tests are unaffected.Webis red on this head and identically red onmainitself (Audit dependencies,browserslist <=4.28.6, GHSA-73wf-gq98-2v4g). That is a pre-existing repository condition rather than something this branch introduced, and the queued fix is build(deps-dev): bump browserslist from 4.28.2 to 4.28.8 in /web #159; the readback is on chore(codeowners): add third independent reviewer #138.