# SESSION-0026.8 — v0.25.0 security hardening config edits Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md Subsession 0026.8 of OHM session 0026 (REMEDIATION mode). Scope was narrow and bounded: make exactly two in-repo config edits in the `rfc-app` repo (branch `feature/v0.25.0-security-hardening`) to close audit findings M2, L8 (nginx) and L4 (systemd). No git commit, no push, no VM access, no other files touched. The driver commits and applies to the VM later. ## What I changed and why ### TASK 1 — nginx security headers (M2 + L8) File: `deploy/nginx/ohm.wiggleverse.org.conf` Added to the server block (which is the certbot-promoted HTTPS block on the VM — see below): `server_tokens off;` (L8) plus five `add_header ... always;` response headers (HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin, and a Content-Security-Policy) (M2). All carry `always` so they apply to nginx error responses too. Important structural note I verified before editing: the in-repo `.conf` is the **pre-certbot template** and contains only the port-80 `server` block. Per the file's own install comment, RUNBOOK.md, and DEPLOY-NEW-SESSION-PROMPT.md, `certbot --nginx` rewrites this file on the VM, promoting this single server block to `listen 443 ssl` and adding a separate port-80 → 443 redirect block. So headers placed in this block ride into the HTTPS server block on the VM exactly as the task intended. I documented that relationship in an inline comment so a future reader isn't confused about why "the HTTPS headers" live in what looks like the port-80 block in-repo. Did NOT touch the existing TLS/proxy/`Cache-Control`/`client_max_body_size` directives. ### TASK 2 — systemd hardening (L4) File: `deploy/systemd/rfc-app.service` Kept the existing NoNewPrivileges / ProtectSystem=strict / ProtectHome / PrivateTmp / scoped ReadWritePaths. Added the full defense-in-depth block under a `# v0.25.0 security hardening (audit 0026 L4)` comment: empty `CapabilityBoundingSet=` + `AmbientCapabilities=` (service binds 127.0.0.1:8000, needs no caps), PrivateDevices, ProtectKernel{Tunables, Modules,Logs}, ProtectControlGroups, RestrictAddressFamilies (AF_INET AF_INET6 AF_UNIX), RestrictNamespaces, LockPersonality, MemoryDenyWriteExecute, RestrictRealtime, RestrictSUIDSGID, SystemCallFilter=@system-service, SystemCallErrorNumber=EPERM, SystemCallArchitectures=native, UMask=0077. ## The CSP I settled on + reasoning ``` 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' ``` Diffs from the suggested starting policy, all evidence-driven from reading the frontend: - **Added `connect-src https://challenges.cloudflare.com`.** Turnstile (TurnstileWidget.jsx loads `https://challenges.cloudflare.com/turnstile/v0/api.js`) makes XHR/fetch calls back to that origin during the challenge, not just an iframe. Without it the challenge can fail. - **Added `worker-src 'self' blob:`.** This is the load-bearing change. Amplitude analytics is `@amplitude/unified`, which ships **Session Replay**, and analytics.js initializes it at **`sampleRate: 1`** (100% of consenting sessions recorded — see lib/analytics.js lines 219–222). Amplitude Session Replay spins up a Web Worker from a `blob:` URL for DOM capture/compression. With a strict `default-src 'self'` and no `worker-src`, `blob:` workers are blocked and session replay breaks for every consenting user. I could not introspect node_modules to prove the exact blob usage (deps not installed in this checkout), so I relied on documented Amplitude Session Replay behavior and added `blob:` defensively — it's low-risk and prevents a real regression. - **script-src / connect-src amplitude:** Amplitude AND mermaid are both **bundled** (`import('@amplitude/unified')`, `import('mermaid')` — dynamic imports from node_modules, served from `'self'`), so script-src for them isn't strictly required. I kept `script-src ... https://*.amplitude.com` defensively (harmless) but the truly necessary amplitude grant is `connect-src https://*.amplitude.com` (event ingestion + session replay upload, all under `*.amplitude.com`). mermaid needs no external origin at all — it renders inline SVG. - **`style-src 'unsafe-inline'` retained and documented as required:** the JSX uses inline `style={...}` attributes throughout and mermaid injects `