Files
Project-SDE-WP-Suite/nginx-wp-suite.conf
n.siegfried a9b22f2add 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>
2026-08-03 22:37:18 -07:00

78 lines
4.1 KiB
Plaintext

# ─────────────────────────────────────────────────────────────────────────────
# Work Package Suite — NGINX site config
#
# Serves the static site and reverse-proxies /api/ to the Python API
# (FastAPI on 127.0.0.1:8000), which stores SOPs, Work Packages, and comments
# in PostgreSQL. Same-origin, so there is no CORS.
#
# Install:
# 1. Copy the project files to the web root (e.g. /var/www/wp-suite).
# 2. Run the API as a service (see server/README.md) listening on :8000.
# 3. Put this file at /etc/nginx/conf.d/wp-suite.conf
# (or /etc/nginx/sites-available/ + symlink into sites-enabled/).
# 4. Replace server_name and the ssl_certificate paths.
# 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;
server_name wp-suite.company.local; # <-- your internal hostname
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name wp-suite.company.local; # <-- your internal hostname
# Internal certificate from your company CA
ssl_certificate /etc/nginx/ssl/wp-suite.crt; # <-- cert path
ssl_certificate_key /etc/nginx/ssl/wp-suite.key; # <-- key path
ssl_protocols TLSv1.2 TLSv1.3;
# Static site
root /var/www/wp-suite; # <-- web root
index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
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;
}
# ── API proxy → Python (FastAPI) ─────────────────────────────────────────
# All /api/ calls (SOPs, Work Packages, comments) go to the local API
# service. Keep the /api/ prefix — the API routes are defined under /api.
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m; # WP documents can be larger
}
}