fix(security): escape HTML in agent chat before markdown substitution - #114
notSumit25 wants to merge 1 commit into
Conversation
AgentView renders assistant replies through dangerouslySetInnerHTML, and the
boldify function feeding it escaped nothing — it applied its markdown
substitutions to the raw string, so every character of a reply was parsed as
markup. Confirmed by executing the shipped function rather than reading it:
boldify('<img src=x onerror=alert(document.cookie)>') returned the payload byte
for byte, and in a browser Chromium parsed it into a real <img> element and
fired its onerror handler.
This is stored, not reflected. agent_conversation.transcript is a JSONB column
replayed into the renderer on load, so a payload fires on every visit. The
input is not trusted either: the agent echoes database content, and a real
stored transcript here reads "It rewrites the whole `t` table" where `t` is a
table name read from the database — the same channel an injected identifier
arrives on.
Severity is capped but not removed. Auth is an httpOnly cookie so the token
cannot be read, but client.js sends withCredentials, so injected script acts as
the reader against the API.
Escapes before substituting. The order is load-bearing: escaping after would
also escape the <strong> and <code> tags this function emits and print literal
tag text, so both halves are pinned by tests. Swapping in the safe renderer
that already exists (AgentChat/AgentMarkdown.jsx) is the better long-term shape
but carries its own CSS module, table component and link handling — a visual
redesign inside a security fix, so it is left for its own PR.
Brain/AgentArtifacts.jsx had the identical bug with a styled <code> tag,
currently unreachable behind AGENTS_ENABLED=false but live the day that flips.
Both renderers now share one escaping implementation and pass only presentation
in, so they cannot drift apart the way two copies would.
Verified: 6 of 12 tests fail against the real shipped function and pass after;
removing escapeHtml fails 7. In a browser the old path executed the handler and
left one real <img> in the DOM, the fixed path executed nothing, left zero, and
renders the payload as visible text. Build clean, 22 frontend tests pass, and
lint is unchanged against main's baseline (41 errors both sides, 0 in the four
files touched).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hands-on QA against the live stack — 11/11 scenarios PASS, with one correction to my own claimQA'd against the real running stack (live Postgres, live backend, patched build served on :3099 vs. the unpatched
|
| Check | UNPATCHED (main) |
PATCHED (#114) |
|---|---|---|
<img onerror> handler fired |
true ✗ |
false ✓ |
Nested-in-bold onerror fired |
true ✗ |
false ✓ |
Real <img> elements in DOM |
2 | 0 |
<script> fired |
false |
false |
<script> reporting false on both builds confirms the spec behaviour the PR documents — innerHTML-inserted <script> never executes, which is exactly why the tests assert on onerror instead.
All 11 scenarios
| ID | Scenario | Result |
|---|---|---|
| S1 | <img onerror> escaped, handler blocked |
PASS |
| S2 | Baseline executes (proves S1 not vacuous) | PASS |
| S3 | Real formatting intact — 3 <strong>, 1 <code> |
PASS |
| S4 | Quote handling (claim corrected — not injection on either build) | PASS |
| S5 | Payload nested inside bold | PASS |
| S6 | <script> escaped |
PASS |
| S7 | Bullet <span> sink (AgentView.jsx:901) — 0 raw tags |
PASS |
| S8 | AgentArtifacts styled <code> — full style preserved, 0 <img> |
PASS |
| S9 | Full page reload — still escaped | PASS |
| S10 | null/undefined/empty/whitespace — no crash |
PASS |
| S11 | Idempotent, no double-escaping (&lt; absent) |
PASS |
S8 confirms the shared-escape design works: AgentArtifacts keeps its full inline style (background:#f3f4f6;padding:1px 5px;…) while the payload is escaped — presentation differs, escaping doesn't.
Environment
- Health confirmed via logs (
Started DbaAgentApplication) and real requests (200 health / 401 unauthenticated), notdocker ps. - Verified
'&','<':'<'is present in the patched bundle and absent from the runningmainbuild — the difference under test is real. - DB restored to baseline: 16 rows before, 16 after, 0 scratch rows remaining.
Verdict: READY — zero blocking issues. The PR body should be amended to drop the attribute-injection example.
The problem
AgentViewrenders assistant replies throughdangerouslySetInnerHTML, and the function feeding it escaped nothing:Confirmed by executing the shipped function, not reading it:
Why it mattered
Stored, not reflected.
agent_conversation.transcriptis a JSONB column replayed into this renderer on load, so a payload fires on every visit.The input isn't trusted. The agent echoes database content. A real stored transcript in this install reads:
where
`t`is a table name read from the database — the same channel an injected identifier arrives on.Severity capped, not removed. httpOnly cookies mean the token can't be read — but
client.jssendswithCredentials, so injected script acts as the reader against the API (queries, results, admin endpoints if the reader is an admin).Browser proof
onerrorexecutedtrue✗false✓<img>elements in DOMimgTagsBefore: 1is the smoking gun — Chromium parsed the payload into a real DOM element and fired its handler. After the fix the payload appears as readable text, which is the correct behaviour: a table name containing angle brackets should be legible, not executed.The fix
Order is load-bearing. Escaping after would also escape the
<strong>/<code>tags this function emits, printing literal tag text. That's the obvious thing to "simplify" later, so both halves are pinned by tests.Why not swap in the safe renderer that already exists?
AgentChat/AgentMarkdown.jsx(ReactMarkdown+remarkGfm, norehype-raw) is the right long-term answer, but it's a complete renderer with its own CSS module,DownloadableTableand link handling, whileAgentViewhas bespoke inline styles. Swapping it in makes this a visual redesign inside a security fix. Four lines of escaping changes no pixels.Why not escape everything? The real transcript above shows how heavily the agent uses this formatting. Rendering
**DANGER**as literal asterisks would be rejected by its users.The sibling sink
Brain/AgentArtifacts.jsx:136had the identical bug with a styled<code>tag. Currently unreachable (AGENTS_ENABLED = false) — but dead-code-adjacent, not dead. Both renderers now share one escaping implementation with only presentation passed in, so they can't drift the way two copies would.Verification
boldify(RED)escapeHtmlremoved (mutation)<img><img>, payload visible as textnpm run buildnpm run lintResidual work (deliberately not here)
Content-Security-Policyheader.docker/nginx/default.confsetsX-Frame-Options,X-Content-Type-OptionsandReferrer-Policybut no CSP — so nothing stands behind an escaping bug if another is introduced. Ascript-srcwithoutunsafe-inlineis the defence in depth this sink deserves; separate deployment change, own blast radius.AgentViewtoAgentMarkdownas a deliberate UI change.Write-up:
docs/security/2026-09-16-agent-chat-stored-xss.md🤖 Generated with Claude Code