NGINX: set Cache-Control via a map, not a nested location, so security headers survive

The Cache-Control rule I added in the previous commit used a nested
`location ~* \.(html|css|js|webmanifest)$`. nginx does NOT inherit add_header into a
block that declares its own add_header, so every HTML, CSS and JS response would have
been served WITHOUT the CSP, HSTS, X-Frame-Options, Referrer-Policy and nosniff headers
from the Phase S hardening — the headers dropped for exactly the files that matter most,
and silently, since the pages would still work.

Now computed by `map $uri $wp_cache_control` at http level and applied with one
server-level add_header alongside the security headers, so nothing is scoped away. An
empty value makes nginx omit the header entirely, so images and fonts stay cacheable.
Applied to both the Docker config (nginx/conf.d/wp-suite.conf) and the bare-metal one
(nginx-wp-suite.conf), which carries the same header set.

Caught while checking whether the stack was safe to redeploy. Not verified with
`nginx -t` — this machine has neither nginx nor docker — so DEPLOYMENT.md now records
the rule and the one-line curl that confirms both headers are present after a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 22:37:18 -07:00
parent e3527a6e1d
commit a9b22f2add
3 changed files with 48 additions and 10 deletions

View File

@@ -495,3 +495,14 @@ things enforce that, and all three are needed:
than a plain one.
If you change the shell file list in `sw.js`, bump `CACHE`.
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
> at http level, applied with a single server-level `add_header`. Do **not** move it
> into a `location` block: nginx does not inherit `add_header` into a block that
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
> files. After deploying, confirm both are present on one response:
>
> ```bash
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
> ```

View File

@@ -14,6 +14,22 @@
# 5. sudo nginx -t && sudo systemctl reload nginx
# ─────────────────────────────────────────────────────────────────────────────
# Cache-Control per file type. Computed in a map rather than a nested location
# because nginx's add_header is NOT inherited into a block that declares its own —
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
# empty value makes nginx omit the header, so images and fonts stay cacheable.
#
# Code must revalidate on every load: with no Cache-Control the browser applies
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
# gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index -> index.html
}
# Redirect plain HTTP to HTTPS
server {
listen 80;
@@ -40,6 +56,8 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;

View File

@@ -2,6 +2,23 @@
# This container sits behind an external reverse proxy that handles SSL.
# It listens on port 80 (plain HTTP on the internal Docker network).
# Cache-Control per file type, computed here rather than in a nested location.
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
# those files. Computing the value here keeps every header in ONE scope. An empty value
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
#
# Code assets must revalidate on every load: with no Cache-Control at all the browser
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
# file gets the LONGEST lifetime — which is how a page ends up running against a
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
map $uri $wp_cache_control {
default "";
~*\.(?:html|css|js|webmanifest)$ "no-cache";
~*/$ "no-cache"; # directory index → index.html
}
server {
listen 80;
server_name wp.controls.dev;
@@ -19,19 +36,11 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
# Empty for anything that isn't code, in which case nginx omits the header.
add_header Cache-Control $wp_cache_control always;
location / {
try_files $uri $uri/ =404;
# Code assets must revalidate on every load. With no Cache-Control the browser
# applies HEURISTIC freshness (roughly 10% of the file's age), so the least
# recently changed file gets the LONGEST lifetime — which is exactly how a page
# ends up running against a stylesheet or script from a previous deploy.
# ETag/Last-Modified still make the revalidation a cheap 304.
location ~* \.(html|css|js|webmanifest)$ {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
}
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)