nginx: preserve security baseline in every location; install on VPS

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 12:11:46 -04:00
parent 23250d8782
commit 59fcc15ca6
2 changed files with 48 additions and 0 deletions

View File

@ -43,6 +43,18 @@ location /proxy/arxiv/ {
proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)"; proxy_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on; 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 popup_proxy;
proxy_cache_valid 200 30d; proxy_cache_valid 200 30d;
proxy_cache_valid any 5m; 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_set_header User-Agent "levineuwirth.org popup-proxy (ln@levineuwirth.org)";
proxy_ssl_server_name on; 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 popup_proxy;
proxy_cache_valid 200 7d; proxy_cache_valid 200 7d;
proxy_cache_valid any 5m; proxy_cache_valid any 5m;
@ -96,6 +120,18 @@ location /proxy/pubmed/ {
# caching this is rarely exercised, but the burst guards a hot page. # caching this is rarely exercised, but the burst guards a hot page.
limit_req zone=pubmed burst=3 nodelay; 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 popup_proxy;
proxy_cache_valid 200 30d; proxy_cache_valid 200 30d;
proxy_cache_valid any 5m; proxy_cache_valid any 5m;

View File

@ -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 # instantaneous. Same reasoning applies to fingerprinted fonts and the
# locally vendored ML model files. # locally vendored ML model files.
location ^~ /pdfjs/ { 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; add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off; access_log off;
} }
location ^~ /fonts/ { 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; add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off; access_log off;
} }
location ^~ /models/ { 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; add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off; access_log off;
} }
@ -74,6 +84,8 @@ location ^~ /models/ {
# keeps them responsive to deploys (~1h staleness window for warm clients) # keeps them responsive to deploys (~1h staleness window for warm clients)
# without forcing a fetch on every page navigation. # without forcing a fetch on every page navigation.
location ~* \.(?:css|js|mjs|woff2?|svg|webp|png|jpg|jpeg|ico)$ { 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; add_header Cache-Control "public, max-age=3600, must-revalidate" always;
access_log off; access_log off;
} }