bd3ef269d4
Remediates the rfc-app application + deploy-config findings from the Session 0026 security audit. Cut as the "v0.25.0-security-hardening" branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since v0.26.0 (#28) shipped while this was in flight. - C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview, ProposalView x2, Editor); rel=noopener hook on target=_blank links. - H1: per-account OTC-verify lockout (migration 023, auto-applied) + per-IP throttle via new ratelimit.py; wired on otc verify/request + passcode check/verify. - M1: device_trust.lookup() single indexed-row read — cookie value is now "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan. (Behavior change: existing device-trust cookies re-prompt once.) - M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx. - M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out). - M5: bounce webhook fails CLOSED (503) when secret unset, instead of open; RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in. - L2/L3: per-IP cooldown + check-endpoint throttle. - L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out. VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade steps (incl. the out-of-band nginx + systemd apply, which the flotilla deploy gesture does not perform). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
5.8 KiB
Plaintext
119 lines
5.8 KiB
Plaintext
# nginx vhost for the RFC app — single-process FastAPI behind nginx,
|
|
# frontend served as static files from the Vite build output.
|
|
#
|
|
# Install:
|
|
# sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
|
|
# /etc/nginx/sites-available/ohm.wiggleverse.org
|
|
# sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
|
|
# /etc/nginx/sites-enabled/
|
|
# sudo nginx -t && sudo systemctl reload nginx
|
|
#
|
|
# Then add the Let's Encrypt cert:
|
|
# sudo certbot --nginx -d ohm.wiggleverse.org
|
|
# Certbot will rewrite this file to add the 443 listener and certificate
|
|
# directives; the rest of the config below stays as written.
|
|
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name ohm.wiggleverse.org;
|
|
|
|
# v0.25.0 security hardening (audit 0026 M2/L8)
|
|
#
|
|
# NOTE: certbot promotes THIS server block to the HTTPS listener
|
|
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
|
# block (see the install comment above). These response headers
|
|
# therefore ride into the HTTPS server block on the VM. They use
|
|
# `add_header ... always` so they also apply to nginx-generated
|
|
# error responses (4xx/5xx), not just 200s.
|
|
#
|
|
# `server_tokens off` (L8) — suppress the nginx version in the
|
|
# Server header and on error pages so we don't advertise the
|
|
# build to scanners.
|
|
server_tokens off;
|
|
|
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
add_header X-Frame-Options "DENY" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
|
|
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
|
|
# - default-src 'self': everything not called out below is same-origin.
|
|
# - script-src 'self' + challenges.cloudflare.com: the only external
|
|
# <script> tag the app injects is the CloudFlare Turnstile widget
|
|
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
|
|
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
|
|
# from 'self'), so they need no extra script origin — *.amplitude.com
|
|
# is listed defensively in case a future SDK build script-injects.
|
|
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
|
|
# is used, so we keep XSS-via-inline-script blocked.
|
|
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
|
|
# JSX uses inline `style={...}` attributes throughout and mermaid
|
|
# injects <style> blocks at render time. Removing it would break
|
|
# layout; tightening this is a future build-side change (nonce/hash).
|
|
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
|
|
# images and data: URIs; svg/mermaid output uses data: too.
|
|
# - font-src 'self' data: — bundled fonts plus data: webfonts.
|
|
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
|
|
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
|
|
# Analytics + Session Replay (shipped at sampleRate 1) POST to
|
|
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
|
|
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
|
|
# Web Worker from a blob: URL for capture/compression; without
|
|
# blob: here session replay breaks for every consenting user.
|
|
# - frame-src challenges.cloudflare.com — the Turnstile challenge
|
|
# renders in an iframe from that origin.
|
|
# - frame-ancestors 'none' — clickjacking defense, pairs with
|
|
# X-Frame-Options DENY for older agents.
|
|
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
|
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
|
|
|
# Static SPA assets live in the Vite build output. The systemd unit
|
|
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
|
# read this path. Either group-add www-data into rfc-app's group, or
|
|
# chmod o+r on the dist/ tree.
|
|
root /opt/rfc-app/frontend/dist;
|
|
index index.html;
|
|
|
|
# API routes are proxied to the FastAPI process. SSE chat streams
|
|
# need proxy_buffering off so chunks reach the browser immediately;
|
|
# the long read_timeout matches a slow LLM turn.
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:8000;
|
|
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_buffering off;
|
|
proxy_cache off;
|
|
proxy_read_timeout 1h;
|
|
}
|
|
|
|
location /auth/ {
|
|
proxy_pass http://127.0.0.1:8000;
|
|
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;
|
|
}
|
|
|
|
# SPA fallback — any non-asset path falls back to index.html so
|
|
# React Router can take over.
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
# Cache the hashed JS/CSS bundles aggressively; Vite includes a
|
|
# content-hash in the filename so updates bust the cache for free.
|
|
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
|
|
try_files $uri =404;
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
}
|
|
|
|
# Reasonable upload cap. Adjust if RFC bodies grow large.
|
|
client_max_body_size 4M;
|
|
}
|