Backfills transcripts that existed only locally in ohm-infra: - 0019.1/.2/.3 - UX-polish wave subagent transcripts (driver 0019.0 never finalized) - 0026.1-.9 - security-audit-0026 subagent transcripts (driver abandoned/closed-out by 0068; audit drove published 0030 remediation) - 0073.0 - PPE/progressive-delivery + engineering-handbook session, originally drafted as a duplicate 0046.0; reassigned next free number (0072 taken by a concurrent session) sessions.json: add 0019/0073 titles, update 0026 title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.5 KiB
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 loadshttps://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 atsampleRate: 1(100% of consenting sessions recorded — see lib/analytics.js lines 219–222). Amplitude Session Replay spins up a Web Worker from ablob:URL for DOM capture/compression. With a strictdefault-src 'self'and noworker-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 addedblob: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 keptscript-src ... https://*.amplitude.comdefensively (harmless) but the truly necessary amplitude grant isconnect-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 inlinestyle={...}attributes throughout and mermaid injects<style>at render time. Tightening to nonces/hashes is a build-side follow-up.script-srcdeliberately 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:andfont-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 {+ 4location {= 5 opens, 5 closes). Could not runnginx -t(no live nginx) — visual sanity check only; CSP is a single quotedadd_headervalue, well-formed. - systemd: valid
key=valueini 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).