Files
F-r-ldraledigplannerare/frontend/nginx.conf.template
T
jacobh2 88197060de Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.

Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
  child_id as an unchecked query param, and get_overview called
  get_days_left_at_age_4 without workspace_id, so the ownership guard
  never ran. Any member could read another workspace's child projection.
  Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
  failure's raw exception text and the FK maintenance banner's scraped
  text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
  lowercasing existing rows (aborts naming colliding rows rather than
  failing opaquely on the UNIQUE constraint).

Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
  hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
  off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
  consumers. setBusy(true) was unconditional while the clear was guarded
  by isCurrent, so a superseded call stranded its flag forever — under
  StrictMode this hung every in-app navigation behind the PageSkeleton.
  isCurrent decides which response's data wins, never whether a call
  releases the flag it raised.

Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
  writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
  GET /workspace/:id from every cold load.

CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).

Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.

Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
2026-09-21 06:16:23 +02:00

117 lines
6.1 KiB
Plaintext

# Rendered by the nginx-unprivileged entrypoint: envsubst expands ${API_UPSTREAM}
# (and only names that exist in the environment — nginx's own $uri/$host/etc.
# are left alone) into /etc/nginx/conf.d/default.conf at container start.
server {
# 8080, not 80: unprivileged nginx runs as uid 101 and cannot bind <1024.
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The bundle is already gzipped over the wire by this; Brotli would need a
# module the stock image does not carry, so gzip it is.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml application/manifest+json;
server_tokens off;
# nginx does NOT merge add_header across levels: if a location block adds
# even one add_header of its own, every add_header set here at server
# level is discarded for responses from that location — not appended to.
# /assets/, /fonts/ and = /index.html below each set their own
# Cache-Control, so the four security headers are repeated verbatim
# inside each of those blocks. Add a header here, and it must be added
# there too (or it silently never reaches those responses).
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Derived from the actual production build (frontend/dist): no inline
# <script>/<style>, all JS/CSS/fonts same-origin, the only data: URIs are
# an <img> (QR code) and a CSS background-image (chevron icon) — hence
# img-src needs data: but nothing else does. API is same-origin /api/.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Container-level liveness. Served from memory, so a broken bundle or a
# read-only mount cannot make this lie.
location = /healthz {
access_log off;
# default_type, not `add_header Content-Type`: add_header appends, so
# the response would carry two Content-Type headers.
default_type text/plain;
return 200 "ok\n";
}
# Same-origin API. Because the browser only ever talks to this origin, the
# backend's CORS allow-list is not exercised at all in this deployment.
location /api/ {
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# The FK-scraper poll endpoints are long-ish; don't cut them at 60s.
proxy_read_timeout 120s;
proxy_buffering off;
}
# Vite emits content-hashed filenames under /assets, so they are immutable.
location /assets/ {
access_log off;
# `expires 1y` would emit its own Cache-Control alongside this one;
# one explicit header is clearer than two contradicting-looking ones.
add_header Cache-Control "public, max-age=31536000, immutable" always;
# Repeated: see the server-level comment above — an add_header here
# discards the server block's add_headers for this location.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
try_files $uri =404;
}
# Self-hosted webfonts are versioned in their filename (…-v7.woff2), so
# a glyph update ships as a new file and the old one can be cached forever.
location /fonts/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
# Repeated: see the server-level comment above.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
try_files $uri =404;
}
# index.html is the only mutable entry point — never cache it, or a deploy
# leaves clients pinned to a stale asset graph.
location = /index.html {
add_header Cache-Control "no-cache" always;
# Repeated: see the server-level comment above — this is the response
# the CSP/X-Frame-Options/etc. exist to protect, so it must not lose
# them just because this block also sets Cache-Control.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
}
# SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a
# shared link on a client-only route (/calendar, /settings, ...) arrives
# here as a real HTTP request for a path with no file behind it. Without
# this line that is a 404 and client-side routing breaks on reload.
location / {
try_files $uri $uri/ /index.html;
}
}