nginx: ship security baseline, reference vhost, and tighter cache

- Add nginx/security-headers.conf — server_tokens off, HSTS (1y +
  preload), X-Content-Type-Options, X-Frame-Options DENY,
  Referrer-Policy, Permissions-Policy, and a usage-scoped CSP. CSP
  ships in Report-Only mode; promote to enforcing once the report
  stream is clean for a week. CSP allowlists are derived from actual
  usage (cdn.jsdelivr.net for KaTeX/Vega, *.basemaps.cartocdn.com for
  Leaflet tiles); 'unsafe-inline' and 'unsafe-eval' are documented
  inline.
- Add nginx/vhost.conf.example — reference vhost showing the canonical
  include order. The live vhost on the VPS remains the source of
  truth; this file documents the structure so the VPS config can be
  reproduced or audited from the repo.
- Shorten unfingerprinted CSS/JS cache from 24h to 1h. Bug fixes ship
  to warm clients within an hour; if assets are ever fingerprinted,
  this can move to immutable.
- Refresh README repo layout — add nginx/ entry, drop stale paper/
  and spec.md references that never existed in the working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-07 15:08:03 -04:00
parent fd84b7e6d2
commit 87819501a5
4 changed files with 140 additions and 7 deletions

View File

@ -67,8 +67,10 @@ the VPS rsync target consumed by `make deploy`. Never commit it.
- `tools/` — Python tooling (embeddings, importers) and shell scripts.
- `data/` — generated and source data (commonplace.yaml, annotations.json,
bibliographies, similar-links.json).
- `paper/` — LaTeX source for in-progress academic papers.
- `spec.md` — full architectural notes and design intent.
- `nginx/` — vhost snippets shipped to the VPS (`security-headers.conf`,
`static-assets.conf`, `popup-proxy.conf`). The live vhost on the VPS
is the source of truth; see `nginx/vhost.conf.example` for the
canonical structure and the include order these snippets expect.
## Architecture pointers
@ -79,8 +81,6 @@ the VPS rsync target consumed by `make deploy`. Never commit it.
- `build/Filters/Images.hs` does WebP `<picture>` wrapping; requires
the `.webp` companions produced by `tools/convert-images.sh`.
For deeper architectural detail, see `spec.md`.
## License
See `LICENSE`.

View File

@ -0,0 +1,70 @@
# security-headers.conf — security baseline for the levineuwirth.org vhost.
#
# Place at /etc/nginx/snippets/security-headers.conf and `include` it
# inside the server { } block of the vhost, alongside the other
# snippets shipped from this repo:
#
# server {
# server_name levineuwirth.org;
# root /var/www/levineuwirth.org;
# ...
# include snippets/security-headers.conf;
# include snippets/static-assets.conf;
# include snippets/popup-proxy.conf;
# }
# Hide the nginx version from error pages and the Server header.
server_tokens off;
# HSTS — one year, with subdomains, preload-eligible. Only safe to
# enable once HTTPS is the only listener (the :80 vhost should already
# 301 → https). Once preload is requested via hstspreload.org, the
# max-age cannot be lowered without removing from the list manually.
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains; preload" always;
# Block MIME-sniffing — required for safe text/plain and svg responses.
add_header X-Content-Type-Options "nosniff" always;
# Defense against clickjacking. Modern browsers honor CSP frame-ancestors
# (set below); X-Frame-Options is kept for legacy clients.
add_header X-Frame-Options "DENY" always;
# Strip the Referer header to "scheme + host" on cross-origin requests.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Default-deny powerful APIs. interest-cohort opts out of FLoC/Topics.
add_header Permissions-Policy
"camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
# Content Security Policy. Shipped in Report-Only mode initially —
# promote to enforcing (strip the "-Report-Only" suffix) once the
# report stream has been clean for a week.
#
# External origins justified inline:
# cdn.jsdelivr.net KaTeX CSS + JS, Vega / Vega-Lite / Vega-Embed
# *.basemaps.cartocdn.com Leaflet basemap tiles (photography map only)
#
# Why 'unsafe-inline' on style:
# - photography.html emits <span style="background:$swatch$"> for
# palette swatches (data-driven, can't be hashed).
# - KaTeX adds inline styles when rendering math at runtime.
#
# Why 'unsafe-eval' on script:
# - vega-embed compiles Vega-Lite specs at runtime via new Function().
# Removing this would require pre-compiling specs at build time.
#
# To collect violation reports, set up a `report-uri` endpoint and add
# `report-uri /csp-report;` (and/or `report-to <group>;`) below.
add_header Content-Security-Policy-Report-Only
"default-src 'self'; \
script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net; \
style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; \
img-src 'self' data: https://*.basemaps.cartocdn.com; \
font-src 'self' data:; \
connect-src 'self'; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'; \
object-src 'none'; \
upgrade-insecure-requests" always;

View File

@ -70,9 +70,10 @@ location ^~ /models/ {
}
# Per-extension caching for assets that live alongside HTML. CSS and JS
# in this repo are not fingerprinted, so a 1-day cache with must-revalidate
# keeps them responsive to deploys without forcing a fetch on every page.
# in this repo are not fingerprinted, so a 1-hour cache with must-revalidate
# 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)$ {
add_header Cache-Control "public, max-age=86400, must-revalidate" always;
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
access_log off;
}

62
nginx/vhost.conf.example Normal file
View File

@ -0,0 +1,62 @@
# vhost.conf.example — reference vhost for levineuwirth.org.
#
# The live vhost on the VPS is the source of truth and lives at
# /etc/nginx/sites-available/levineuwirth.conf. `make deploy` does not
# touch the vhost — it only rsyncs _site/ to the document root. This
# file exists so the canonical structure (which snippets to include,
# in what order) is documented in the repo.
#
# To adopt: copy this file to /etc/nginx/sites-available/levineuwirth.conf
# on the VPS, fill in the certificate paths, and `nginx -t && systemctl
# reload nginx`. The three snippets it includes ship from this repo's
# nginx/ directory and should be installed under /etc/nginx/snippets/.
# ── http { } scope ──────────────────────────────────────────────────
# popup-proxy.conf consumes a `proxy_cache_path` defined in http { }.
# Place this directive in nginx.conf or a conf.d/ file:
#
# proxy_cache_path /var/cache/nginx/popup-proxy
# levels=1:2 keys_zone=popup_proxy:16m
# max_size=512m inactive=60d use_temp_path=off;
#
# popup-proxy.conf also defines a `limit_req_zone` for PubMed; place
# its companion zone definition in http { } as well:
#
# limit_req_zone $binary_remote_addr zone=pubmed:1m rate=3r/s;
# ── HTTPS server ────────────────────────────────────────────────────
server {
listen 443 ssl;
http2 on;
listen [::]:443 ssl;
server_name levineuwirth.org;
root /var/www/levineuwirth.org;
index index.html;
ssl_certificate /etc/letsencrypt/live/levineuwirth.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/levineuwirth.org/privkey.pem;
# Order matters: security-headers first so add_header directives
# propagate into the locations defined by the other snippets.
include snippets/security-headers.conf;
include snippets/static-assets.conf;
include snippets/popup-proxy.conf;
# Static-site fallback. Pretty URLs first (foo/index.html, foo.html),
# then 404.
location / {
try_files $uri $uri/index.html $uri.html =404;
}
# Custom 404. The build emits _site/404.html.
error_page 404 /404.html;
}
# ── HTTP → HTTPS redirect ───────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name levineuwirth.org;
return 301 https://$host$request_uri;
}