# 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 `<style>` at render time. Tightening to nonces/hashes is a
  build-side follow-up. **`script-src` deliberately omits
  `'unsafe-inline'`** — no inline `<script>` in the app (index.html has
  only `<script type="module" src="/src/main.jsx">`), so inline-script
  XSS stays blocked.

- `img-src 'self' data: https:` and `font-src 'self' data:` keep
  markdown/RFC remote images and data: URIs (and svg/mermaid data:
  output) working.

## MemoryDenyWriteExecute judgment call

I included `MemoryDenyWriteExecute=true` **active, not commented out**.
The stack is stock CPython + FastAPI/uvicorn + sqlite3 + bcrypt + httpx
— no JIT, no PyPy, nothing that mmaps W+X pages. MDWX is safe for that
profile. The risk is theoretical (a C-extension doing W+X mmap), so I
judged the security win worth keeping it on. I added an inline comment
flagging it as the single line to comment out if uvicorn fails to come
up on restart. Driver action: **watch the first `systemctl restart
rfc-app` journal** — if uvicorn crashes on boot, comment out the one
`MemoryDenyWriteExecute=true` line and `daemon-reload` + restart;
everything else in the block is restart-safe for this stack.

## Verify

Re-read both files after editing.
- nginx: brace-balanced (1 `server {` + 4 `location {` = 5 opens, 5
  closes). Could not run `nginx -t` (no live nginx) — visual sanity
  check only; CSP is a single quoted `add_header` value, well-formed.
- systemd: valid `key=value` ini throughout; the two empty-value
  capability clears (`CapabilityBoundingSet=`, `AmbientCapabilities=`)
  are intentional and valid systemd syntax.

No secret bytes touched or printed. No commit/push/VM action taken.

## Cut state

Both edits complete and verified in-repo on
`feature/v0.25.0-security-hardening`. Handed back to driver for commit
+ VM apply. Open driver watch-item: MDWX on first restart (above).
