Files
jcoffey-dev e8b6a8bc2e Answer 404 for unrouted paths, and keep the route lists honest
web/nginx.conf ended its try_files chain in an unconditional /200.html, so
every path the site does not have -- /wp-login.php, /.env, a typo'd inbound
link -- came back as the SPA shell with a success status. It now answers 404,
which needs nginx to know which routes exist: most it infers from the build
output, but dynamic routes and ones that never opted into prerendering have no
file on disk and are listed by hand.

Those hand-maintained lists drift, and the drift is invisible until it ships:
vite dev and npm run preview route from the client manifest and never read
nginx.conf, so a new dynamic route works everywhere a developer would look and
404s in production. hack/check-web-routes.sh compares the lists against
web/src/routes, and a workflow runs it. Its own workflow rather than another
job on license-compliance.yml, which already carries one unrelated check.

Also turns absolute_redirect off. With nginx's default the trailing-slash
canonicaliser reconstructs the origin from its own listen port, so a request
for https://demo.cairnobs.org/settings/ was answered with
Location: http://127.0.0.1:3000/settings -- the container's internal address,
unreachable from the client, and downgraded to http on the way. Verified by
curl against the built image; it was latent here before the canonicaliser
existed too, through the directory redirect on /dev.
2026-08-28 15:55:28 -07:00

145 lines
8.0 KiB
Nginx Configuration File

server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
# Every redirect this file can emit (the trailing-slash canonicaliser
# below, plus nginx's own built-in directory redirects) must be a bare
# path, not an absolute URL. With the default `absolute_redirect on`,
# nginx reconstructs the origin from its own `listen` port and answers
# a request for https://demo.cairnobs.org/settings/ with
# `Location: http://127.0.0.1:3000/settings` -- the container's
# internal address, unreachable from the client, and a downgrade to
# http on top of it. Verified by curl against the built image, and
# latent in this file before the redirect below existed too, via the
# directory redirect on /dev.
absolute_redirect off;
# Security-audit remediation (M-3): baseline browser security headers,
# absent entirely before this. HSTS/nosniff/frame-options/referrer-
# policy/permissions-policy carry no functional risk to this app and
# are unconditionally safe to add.
#
# CSP is the one directive that needed real care rather than a
# copy-pasted strict default: adapter-static's own build output
# (web/build/index.html) genuinely contains two inline <script>
# blocks -- SvelteKit's own hydration bootstrap and the dark-mode-
# before-paint snippet -- so a naive `script-src 'self'` would break
# every page load, not just XSS. Hash-pinning those two scripts
# instead was considered and rejected: nginx.conf is a static file
# this Docker image ships unmodified, with nothing in the build
# pipeline that regenerates it per build, and SvelteKit's bootstrap
# script embeds a build-specific identifier -- a hardcoded hash here
# would silently start blocking the app's own hydration script on the
# next unrelated rebuild, a far worse outcome than the gap this is
# closing. `script-src`/`style-src` therefore keep `'unsafe-inline'`
# (inline style attributes are also genuinely used, e.g. the Hosts
# page's dynamic bar-width styling) -- this CSP is not a script-
# injection defense by itself (the codebase already has no known XSS
# vector to defend against -- see the security audit's "verified
# clean" section), it's the other directives doing real work:
# `object-src none` (no plugin-embed vector), `frame-ancestors none`
# (clickjacking, redundant with X-Frame-Options but cheap insurance),
# `base-uri self` (blocks base-tag hijacking), `form-action self`.
# `connect-src *` stays permissive rather than enumerating this
# deployment's actual api/alerting/enterprise-auth hosts, since those
# are configured per-deployment via VITE_*_BASE_URL build args (see
# web/Dockerfile) and a static nginx.conf has no way to know them.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src *; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ---- 404 enforcement -------------------------------------------------
#
# A request for a path this site doesn't have must answer 404. It used
# to answer 200: `location /`'s try_files chain ended in an
# unconditional /200.html, so every unrouted path -- /wp-login.php,
# /.env, a typo'd inbound link -- got the SPA shell back with a success
# status. In a browser that still *looked* right (the client router
# renders its not-found page either way), which is exactly why it went
# unnoticed, but nothing reading the status line could tell a real page
# from a miss: crawlers indexed junk URLs, uptime/link checkers saw
# every 404 as healthy, and every probe for a vulnerable path came back
# looking like a hit.
#
# The fix has to be selective rather than a blanket `=404`, because two
# kinds of genuinely-valid route have no file on disk to match:
#
# 1. Dynamic routes (src/routes/dashboards/[id] and friends) -- the
# param isn't known at build time, so there is nothing to
# prerender and the SPA shell IS the correct 200 response.
# 2. /data-sources -- an ordinary static route that simply never
# opted into prerendering (it has no +page.ts, so no `export const
# prerender = true`), leaving adapter-static to serve it from the
# fallback like a dynamic one.
#
# Everything else is prerendered to a flat <route>.html that matches on
# disk, so adding a normal prerendered route needs no change here. The
# two allowlists below are the whole drift surface, and
# hack/check-web-routes.sh fails CI if a new dynamic or non-prerendered
# route isn't reflected in them.
# 404s keep the SPA shell as their body, so what a human sees is
# unchanged from before -- SvelteKit's own not-found page, rendered
# client-side, styled like the rest of the app. Only the status line is
# corrected. `error_page` with no `=code` override preserves the 404;
# writing `error_page 404 =200 /200.html` would reintroduce the exact
# bug this block exists to fix.
error_page 404 /200.html;
# Canonicalize trailing slashes instead of 404ing them. SvelteKit's
# default is `trailingSlash: 'never'`, so /settings is canonical and
# /settings/ is a stale-but-real inbound link shape. It used to resolve
# by falling through to the SPA shell, which the strict try_files below
# no longer does -- without this redirect, tightening the fallback
# would silently turn every trailing-slash link into a 404. Anchored so
# "/" itself (which needs at least three characters to match) is
# untouched. 308 rather than 301 to preserve the method.
location ~ ^(/.+)/$ {
return 308 $1;
}
# Dynamic routes -- /agents/<host>, /alerts/<id>, /dashboards/<id>,
# /hosts/<host>. Exactly one trailing segment, so /dashboards/a/b is
# not a route and still 404s. $uri.html stays ahead of the fallback in
# the chain because /alerts/new is a real prerendered page that happens
# to match this same shape and must keep serving its own file.
location ~ ^/(?:agents|alerts|dashboards|hosts)/[^/]+$ {
try_files $uri $uri.html /200.html;
}
# Non-prerendered static route (case 2. above). Exact match, so it
# can't shadow anything below it.
location = /data-sources {
try_files /200.html =404;
}
# "/" is the one directory URL that is a real route -- served
# explicitly so `$uri/` can stay out of the chain below.
location = / {
try_files /index.html =404;
}
location / {
# adapter-static writes prerendered routes as flat <route>.html
# files (e.g. /dashboards -> dashboards.html, confirmed by
# actually inspecting the build output), not <route>/index.html --
# $uri.html has to be in this chain or a request for exactly
# "/dashboards" falls straight through and skips the prerendered
# page it should be serving. The chain now ends in =404 rather
# than /200.html; the locations above carry the cases that
# legitimately still need the fallback shell.
#
# `$uri/` is deliberately NOT in this chain. build/ has directories
# that are not routes (dev/, alerts/, _app/, fonts/, icons/), none
# of them containing an index.html, and matching them here made
# nginx 301 /dev -> /dev/, which the trailing-slash canonicaliser
# then bounced straight back to /dev: an infinite redirect loop on
# a path that should simply 404. Caught by curling the built image.
try_files $uri $uri.html =404;
}
}