# SESSION-0026.2 — rfc-app Injection & Input-Handling Security Audit
Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Subsession: 0026.2 (read-only audit leg of security-audit driver session 0026)
Date: 2026-05-28 (PST)
Target: rfc-app at /Users/benstull/git/rfc-app, tag v0.24.0 (HEAD 28015ed)
Scope: SQL injection, XSS, SSRF, path traversal, email/markdown sanitization.
Mode: READ-ONLY. No edits, no deploys, no git mutations. No secret bytes recorded.
## Pre-state
Driver assigned the injection & input-handling surface. Repo confirmed at
tag v0.24.0. Backend is FastAPI + SQLite (backend/app/*.py); frontend is
React 19 + Vite (frontend/src). Markdown via `marked` v18; no sanitizer
library in frontend/package.json dependencies.
## Turn-by-turn arc
1. Enumerated backend modules and grepped all 321 `execute*` call sites for
f-string / `.format` / `%` / concat SQL. Pulled the ~16 dynamic-SQL hits.
2. SQL injection — examined every dynamic-SQL site:
- api_notifications.py:274 (`UPDATE users SET {', '.join(sets)}`) — `sets`
contains only hardcoded column-name literals; values parameterized via
`args`. Safe.
- api_notifications.py:479/483 (`UPDATE users SET {column} = ...`) —
`column` from static `_CATEGORY_COLUMN` allowlist. Safe.
- api_admin.py:413, notify.py:535, digest.py:201, email.py:610,
notify.py:942/964/1000 — all IN-clauses built via `",".join("?" * len)`,
the correct parameterized-IN idiom; values in the tuple. Safe.
- notify.py:638/643 — `{now}` is the literal `datetime('now')`, not input.
- api_admin.py:629/711/754 — `WHERE` built from `clauses` lists holding
only fixed `col = ?` strings; user values parameterized; `limit` is
FastAPI `int` with ge/le bounds. Safe.
- db.py — connection/migration layer; migrations read trusted on-disk .sql.
- Swept for dynamic ORDER BY / LIMIT / table-name injection: none found.
Verdict: parameterization is consistent and correct across the backend.
3. XSS — found the markdown pipeline:
- frontend/package.json has NO DOMPurify / rehype-sanitize / any sanitizer.
- MarkdownPreview.jsx:101-102 — `previewMarked.parse(content)` →
`hostRef.current.innerHTML = html`. The custom `Marked` instance only
overrides the ```mermaid code-fence renderer; everything else is marked
default (raw HTML passthrough). `escapeHtml` is applied ONLY to the
mermaid placeholder text and error text, NOT the document body.
- ProposalView.jsx:164 & 172 — `dangerouslySetInnerHTML` with the GLOBAL
unconfigured `marked.parse(data.entry?.body)` / `proposed_use_case`.
- Content sources: RFCView passes RFC body (previewContent/editorContent)
into MarkdownPreview; DocsSessionTranscript / DocsSpec / DocsUserGuide /
Philosophy / DocsSessionsAbout/Index also feed body through it. RFC body
and proposal body/use-case are free-text authored by any invited user.
- Backend: no sanitization on write (api.py — only Field max_length caps).
- Self-refutation step: ran `marked.parse` on
`