Skip to content

fix(web): make the proxy the single authority for the shared security headers - #161

Merged
PeterGuy326 merged 3 commits into
mainfrom
fix/135-136-proxy-security-headers
Sep 3, 2026
Merged

PeterGuy326 merged 3 commits into
mainfrom
fix/135-136-proxy-security-headers

Conversation

@waterbro-8

@waterbro-8 waterbro-8 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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-Options and Referrer-Policy, uniformly no-referrer, with the proxied path de-duplicated by proxy_hide_header; the API keeps Content-Security-Policy, X-XSS-Protection and Content-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@1332bf46 and neither is visible by reading the config, which is why the test starts an nginx rather than grepping one.

  1. /assets/ lost all three headers. An add_header inside a location replaces the inherited set instead of adding to it, and that block has always had add_header Cache-Control. Every cached bundle shipped with no nosniff, no X-Frame-Options and no Referrer-Policy at all.
  2. /v1/ sent two conflicting Referrer-Policy values on one response. The proxy said same-origin, the API says no-referrer, and nginx's add_header appends rather than replaces, so a client received both and the effective policy depended on which one the browser kept. X-Content-Type-Options and X-Frame-Options were also duplicated (same value twice).

Why the API keeps sending the three it no longer owns

proxy_hide_header makes the wire value nginx's, which is what "nginx 独占" requires, without deleting nosniff/DENY/no-referrer from securityHeadersMiddleware. A memd reached directly — the Helm path exposes the Service, and docs/DEPLOYMENT.md only 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.sh renders the shipped template with the same envsubst filter and variables the container entrypoint uses, runs a fake upstream that answers exactly like securityHeadersMiddleware does, starts nginx against the config, and reads headers off the wire across five surfaces (/, /assets/, a 404 under /assets/, /v1/, /healthz).

run result
template as shipped on main 11 of 28 assertions fail — 6 missing across the two /assets/ surfaces, same-origin on / and /healthz, 3 duplicated on /v1/
this branch 28 of 28 pass
drop the /assets/ restatement 3 fail (/assets/ 200)
drop always from the /assets/ restatement 3 fail (/assets/ 404 only)
drop proxy_hide_header 3 fail (/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_BIN that is not executable, and only reports SKIP when 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/Dockerfile pins (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:17 ends on nginxinc/nginx-unprivileged, whose own build stops at USER 101, so apk add could not write the package database -- ERROR: Unable to lock database: Permission denied, exit code: 99. Commit 1c917f96 installs 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 head Deployment profiles is green and its job log carries all 28 security-header assertions passed, so the leg executed the contract against the nginx that ships rather than printing SKIP. 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-Policy for 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

… 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`.

@PeterGuy326 PeterGuy326 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code owner review: single-authority proxy fix; all checks green on head; mergeable.

@PeterGuy326
PeterGuy326 merged commit 87d235b into main Sep 3, 2026
15 checks passed
@PeterGuy326
PeterGuy326 deleted the fix/135-136-proxy-security-headers branch September 3, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants