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.
This commit is contained in:
+27
-3
@@ -124,6 +124,30 @@ The repo convention prefers distroless/scratch base images. Serving a
|
||||
static SPA still needs *some* HTTP server, though, and `nginx:alpine` is
|
||||
the boring, standard choice for that job — writing a custom static-file
|
||||
binary just to stay distroless would be more engineering than a Phase 0
|
||||
placeholder page justifies. `nginx.conf` here is minimal: serve `build/`,
|
||||
fall back to `index.html` for client-side routing (only one route exists
|
||||
today, but this is what you want the moment a second one is added).
|
||||
placeholder page justifies. `nginx.conf` serves `build/`, and resolves a
|
||||
request in this order: the file itself, then the flat `<route>.html`
|
||||
adapter-static prerenders each route to, then — for the handful of routes
|
||||
that have no file on disk — the `200.html` SPA shell.
|
||||
|
||||
### 404s
|
||||
|
||||
Anything that matches none of the above answers **404**, not 200. That
|
||||
took explicit work, because the natural static-SPA config falls back to
|
||||
the shell unconditionally and hands every junk URL a success status;
|
||||
crawlers, uptime checks and vulnerability scanners then can't tell a real
|
||||
page from a miss. The 404 still *renders* the shell, so a human sees the
|
||||
app's own not-found page exactly as before — only the status line
|
||||
changed.
|
||||
|
||||
Two kinds of route legitimately have no file to match and so are named
|
||||
explicitly in `nginx.conf`: dynamic routes (`dashboards/[id]` and
|
||||
friends), whose params don't exist at build time, and routes that never
|
||||
opted into prerendering (`/data-sources`, which has no `+page.ts`). Those
|
||||
two allowlists are the only thing here that can drift out of sync with
|
||||
`src/routes` — and drift would break *only production*, since `vite dev`
|
||||
and `npm run preview` route from the client manifest and never read
|
||||
`nginx.conf`. `hack/check-web-routes.sh` fails CI when they disagree; run
|
||||
it after adding a dynamic or non-prerendered route.
|
||||
|
||||
Trailing slashes redirect (308) to the canonical no-slash form rather
|
||||
than 404ing, matching SvelteKit's default `trailingSlash: 'never'`.
|
||||
|
||||
+95
-6
@@ -3,6 +3,18 @@ server {
|
||||
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
|
||||
@@ -40,16 +52,93 @@ server {
|
||||
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 to the SPA fallback and
|
||||
# skips the prerendered page it should be serving. 200.html (not
|
||||
# index.html) is the fallback shell for genuinely dynamic routes
|
||||
# (dashboards/[id] etc.) -- named differently so it doesn't
|
||||
# collide with "/" -> index.html, which really is prerendered.
|
||||
try_files $uri $uri.html $uri/ /200.html;
|
||||
# "/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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user