9e6756f678
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>
139 lines
6.5 KiB
Markdown
139 lines
6.5 KiB
Markdown
# 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).
|