Files
session-history/ohm/0026/SESSION-0026.8-TRANSCRIPT-2026-05-28T13-46--2026-05-28T14-09.md
T
Ben Stull 9e6756f678 ohm: migrate orphaned subagent transcripts (0019.x, 0026.x) + renumber dup 0046.0 -> 0073
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>
2026-06-05 03:28:59 -07:00

6.5 KiB
Raw Blame History

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 219222). 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).