88197060de
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
117 lines
6.1 KiB
Plaintext
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;
|
|
}
|
|
}
|