Skip to content

fix(web): nginx drops security headers on /assets/ and duplicates Referrer-Policy - #144

Closed
sun-970 wants to merge 1 commit into
bytefolk:mainfrom
sun-970:fix/nginx-security-headers-135
Closed

sun-970 wants to merge 1 commit into
bytefolk:mainfrom
sun-970:fix/nginx-security-headers-135

Conversation

@sun-970

@sun-970 sun-970 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Tracking

Refs #135

Problem

Two defects in web/nginx/default.conf.template:

  1. /assets/ loses all security headers. nginx add_header inheritance is suppressed when a nested block defines any add_header of its own. location /assets/ declares Cache-Control, so the three server-level security headers (X-Content-Type-Options, Referrer-Policy, X-Frame-Options) are not inherited.

  2. Proxied API responses carry duplicate Referrer-Policy with conflicting values. location /v1/ inherits the server-level trio and proxies to the API, which sets its own Referrer-Policy: no-referrer. The response carries both no-referrer (from API) and same-origin (from nginx). RFC 9191 says the receiver uses the first value, so the effective policy depends on ordering rather than intent.

Fix

  • /assets/: Repeat the security header trio explicitly in this block.
  • /v1/: Suppress inherited security headers using nginx's documented empty-value rule (add_header with empty string value is skipped), so only the API's own headers reach the client.
  • Documentation: Header ownership rule is documented in the config template.

Requirements met

Req Statement Evidence
REQ-001 One owner per header per response; no duplicate security headers /v1/ suppresses inherited headers via empty-value add_header
REQ-002 Every nginx-served static response carries the security trio /assets/ now repeats the trio explicitly
REQ-003 API headers remain correct when nginx is absent No change to API middleware
REQ-004 Ownership rule documented next to the config Comments in default.conf.template

Non-goals

  • No change to the API middleware (its header set and ordering are correct)
  • No CSP on the SPA side (separate design question)
  • No HSTS/TLS changes

…errer-Policy on proxied API responses

- /assets/ location now repeats the security header trio explicitly
  because its own Cache-Control add_header breaks server-level inheritance
- /v1/ location suppresses inherited security headers with empty-value
  add_header entries so only the API's own headers reach the client
- Header ownership rule documented in the config template

Refs bytefolk#135
@waterbro-8

Copy link
Copy Markdown
Collaborator

Review — the change is correct; holding on a scope collision with #143

I verified this at runtime rather than by reading the config, because #135 was filed as evidence:e2-source specifically for lack of header dumps.

Method: built nginx 1.27.4 from source (the exact version in web/Dockerfile:17), rendered web/nginx/default.conf.template from main and from 73ad7af, and captured live responses. The upstream is a stub that reproduces securityHeadersMiddleware (server/internal/api/util.go:52-62) header-for-header. Both configs pass nginx -t.

Both defects reproduce as described, and the fix resolves them

probe main this PR
GET /assets/app.abcdef12.js no security headers at all (only Expires and two Cache-Control) X-Content-Type-Options, Referrer-Policy, X-Frame-Options all present
GET /v1/version (proxied) 7 header lines — API's nosniff/DENY/no-referrer/CSP, then nginx appends nosniff/same-origin/DENY exactly the API's values, no duplicates
GET / (SPA document) trio trio
GET /healthz trio trio
GET /assets/nope.js (404) none trio — always earns its keep here
POST 5 KB against client_max_body_size 1k (nginx-generated 413) trio no security headers
GET /v1/version with upstream down (nginx-generated 502) trio no security headers
  • AC-001, AC-002, AC-005: met, observed.
  • AC-003: covered independently by the existing TestSecurityHeadersMiddleware and TestSecurityHeadersOnCORSPreflight, which pass on main and are untouched by this diff.

Honest caveats so this is reproducible: my minimal local build excludes the HTTP rewrite module, so location = /healthz was rendered with try_files instead of return (the shipped image does include the module; the block is untouched by this PR either way). The stub answers 200 to everything, so I did not exercise an API-generated 4xx/5xx through the proxy.

One narrowing this introduces — worth a decision, not a redesign

Cutting inheritance in /v1/ also removes the trio from nginx-originated error responses on that prefix: 413 when an upload exceeds MEM_MAX_BODY_SIZE (routine for a file store) and 502/504 while memd is down or restarting. By REQ-002's own wording those are nginx-served responses, and they now carry nothing where main carried three headers.

Exploitability is low — static nginx error pages, server_tokens off, no attacker-controlled content — and it is a defensible consequence of "the API is authoritative". But it is a strict reduction relative to today that neither the AC list in #135 nor this PR considers. Two clean options:

  1. State it in the ownership comment (/v1/ error pages generated by nginx are deliberately bare), or
  2. error_page 413 502 503 504 = @v1_error; with a named location that repeats the trio. proxy_intercept_errors stays off, so genuine upstream responses never reach it and the duplication does not come back.

Two nits

  • /assets/ now emits two Cache-Control lines (expires 1ymax-age=31536000, then add_header Cache-Control "public, immutable"). Recipients combine the fields, so there is no user-visible bug, but it is a second owner for one header in the very block this PR edits, under the rule this PR introduces. Either fold them or scope the comment to "one owner per security header".
  • CHANGELOG.md puts ### Fixed above ### Changed. The file declares Keep a Changelog 1.1.0, where Fixed sits between Changed and Security.

On severity

nginx appends after upstream headers, so on main the first Referrer-Policy a client sees is already the API's no-referrer, which is the value RFC 9191 makes decisive. Defect 2 is therefore a hygiene/ambiguity problem rather than an active weakening — consistent with severity:s2-medium at most.

The actual blocker: #143 fixes the same defect with the opposite design

#143 (from sun-970, refs #136) changes the same web/nginx/default.conf.template and CHANGELOG.md, and answers the ownership question backwards: it flips server-level Referrer-Policy to no-referrer globally and uses proxy_hide_header to strip the API's trio so the edge overwrites the API. #144 passes the API's values through untouched. These are mutually exclusive, and #135 / #136 read as two records of one defect.

So before either one moves, the decision #135 asked for has to actually be made — who owns security headers on proxied responses, the API or the edge? — and one of the two PRs closed. My own read favors this PR's rule (the API stays correct without nginx, per REQ-003, and the edge stops inventing policy), with #143's server/internal/api/security_headers_edge_test.go and docs/DEPLOYMENT.md changes folded in, since repository AGENTS.md rule 4 asks for tests and docs alongside the fix.

Related process items, all currently observable:

No objection to the diff itself, and the header dumps above can be attached to #135 to move it to evidence:e3-reproduced. Merge decision and approval belong to the code owners, and rule 6 wants that approval from someone other than the author.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Closing under the fork-workflow decision recorded on 2026-09-03: repository
automation is not being enabled for fork pull requests, so a fork head cannot
carry a CI result, and every acceptance gate in this repository is written
against checks that ran. Nothing in this comment is a judgment that the work is
wrong; where it is right, it is re-landed on an organization branch instead.

#135 and #136, re-landed as #161.

The diagnosis here was correct and is the one #135 recorded: location /assets/
declares its own add_header, which replaces the inherited set rather than
adding to it, and same-origin on the proxy contradicted the API's
no-referrer, with both reaching a client on /v1/.

#161 takes the same fix through the ownership split that was decided for #136 on
2026-09-03 — nginx owns the three shared headers uniformly, proxy_hide_header
de-duplicates the proxied path, Content-Security-Policy,
X-XSS-Protection and Content-Disposition stay with the API — and adds the
part this branch did not have: a harness that starts a real nginx and measures
28 header assertions across five surfaces, including one that only fails on an
error response. This branch shipped a configuration change with no test, which
is the reason it cannot be accepted as-is even though the change itself is
right.

The commits are not lost. A closed fork PR keeps its head ref:

git fetch https://github.com/bytefolk/mem.git refs/pull/144/head:pr-144

Every file in this branch was therefore available to the re-doing work, whether
or not it was used.

@waterbro-8 waterbro-8 closed this Sep 3, 2026
PeterGuy326 pushed a commit that referenced this pull request Sep 3, 2026
… headers (#161)

## 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@1332bf4` 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

- `Refs` the decided split; `Closes #135` / `Closes #136` on merge.
- No Go code changed, so the existing `security_headers_test.go` and
`content_*` tests are unaffected.
- `Web` is red on this head and identically red on `main` itself (`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 #159; the readback is on #138.
- Opening this PR is not a claim that it should be merged. Independent
review is the reviewers'; I have not requested or recorded one.

---------

Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants