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>
59 lines
3.1 KiB
Plaintext
59 lines
3.1 KiB
Plaintext
# Work Package Suite — NGINX site config
|
|
# 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;
|
|
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
# ── Security response headers (defense-in-depth) ─────────────────────────
|
|
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
|
|
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
|
|
# sharply limit what injected script could load or exfiltrate. Tighten toward
|
|
# nonce-based scripts once inline handlers are refactored.
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
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;
|
|
}
|
|
|
|
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
|
location /api/ {
|
|
proxy_pass http://api:8000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
# This container is only ever reached via the TLS-terminating external
|
|
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
|
|
# here is always "http") so the API marks the session cookie Secure.
|
|
proxy_set_header X-Forwarded-Proto https;
|
|
client_max_body_size 5m;
|
|
}
|
|
}
|