From 59fcc15ca6abc99b3a37c0b8231ef0010fe80635 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 12:11:46 -0400 Subject: [PATCH] nginx: preserve security baseline in every location; install on VPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_header is non-additive: any location declaring its own add_header drops all server-context headers. archive.conf already re-included the baseline for exactly this reason, but static-assets.conf (four cache locations — including the JS/CSS responses where nosniff matters most) and popup-proxy.conf (three proxy locations) did not. All seven now re-include snippets/security-headers.conf. Proxy locations additionally hide the upstream's own STS/CSP/X-Frame-Options before re-adding ours: browsers honor only the FIRST Strict-Transport-Security header (RFC 6797 §8.1), so arXiv's max-age=300 passing through ahead of ours would have downgraded the domain's cached HSTS policy on every popup fetch. Server side (installed + verified live): security-headers.conf and archive.conf wired into the vhost in vhost.conf.example's canonical order; nginx-mod-brotli installed and loaded, so the .br sidecars compress-assets.sh has always shipped are now actually served (Content-Encoding: br verified). CSP remains Report-Only. Verified headers on /, /css/*.css (baseline + Cache-Control together), /archive/ (baseline + X-Robots-Tag), and /proxy/* (baseline + X-Cache-Status, single STS). Co-Authored-By: Claude Fable 5 --- nginx/popup-proxy.conf | 36 ++++++++++++++++++++++++++++++++++++ nginx/static-assets.conf | 12 ++++++++++++ 2 files changed, 48 insertions(+) diff --git a/nginx/popup-proxy.conf b/nginx/popup-proxy.conf index d9b3e06..7aa6e95 100644 --- a/nginx/popup-proxy.conf +++ b/nginx/popup-proxy.conf @@ -43,6 +43,18 @@ location /proxy/arxiv/ { proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; proxy_ssl_server_name on; + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + proxy_cache popup_proxy; proxy_cache_valid 200 30d; proxy_cache_valid any 5m; @@ -68,6 +80,18 @@ location /proxy/archive/ { proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; proxy_ssl_server_name on; + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + proxy_cache popup_proxy; proxy_cache_valid 200 7d; proxy_cache_valid any 5m; @@ -96,6 +120,18 @@ location /proxy/pubmed/ { # caching this is rarely exercised, but the burst guards a hot page. limit_req zone=pubmed burst=3 nodelay; + # Keep the security baseline: the add_header directives below + # would otherwise drop it for /proxy/ responses (same pattern + # as archive.conf). The upstream's own security headers are hidden + # first — browsers honor only the FIRST Strict-Transport-Security + # header (RFC 6797 §8.1), so an upstream's short max-age passing + # through ahead of ours would downgrade the domain's cached HSTS + # policy on every popup fetch. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header Content-Security-Policy; + proxy_hide_header X-Frame-Options; + include snippets/security-headers.conf; + proxy_cache popup_proxy; proxy_cache_valid 200 30d; proxy_cache_valid any 5m; diff --git a/nginx/static-assets.conf b/nginx/static-assets.conf index a864b2f..1ed4d34 100644 --- a/nginx/static-assets.conf +++ b/nginx/static-assets.conf @@ -55,16 +55,26 @@ brotli off; # we ship pre-compressed sidecars only, no on-the-fly b # instantaneous. Same reasoning applies to fingerprinted fonts and the # locally vendored ML model files. location ^~ /pdfjs/ { + # Re-include the security baseline: this location declares its + # own add_header, which (per nginx inheritance rules) would + # otherwise drop HSTS/nosniff/CSP for everything it serves — + # and nosniff matters most on exactly these JS/CSS responses. + # Same pattern as archive.conf. + include snippets/security-headers.conf; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off; } location ^~ /fonts/ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off; } location ^~ /models/ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off; } @@ -74,6 +84,8 @@ location ^~ /models/ { # keeps them responsive to deploys (~1h staleness window for warm clients) # without forcing a fetch on every page navigation. location ~* \.(?:css|js|mjs|woff2?|svg|webp|png|jpg|jpeg|ico)$ { + # Keep the security baseline (see first location above). + include snippets/security-headers.conf; add_header Cache-Control "public, max-age=3600, must-revalidate" always; access_log off; }