From a023ede222d387179336e58cdd26e1802a73d2a6 Mon Sep 17 00:00:00 2001 From: sumit Date: Wed, 16 Sep 2026 22:12:44 +0530 Subject: [PATCH] fix(security): escape HTML in agent chat before markdown substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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('') returned the payload byte for byte, and in a browser Chromium parsed it into a real 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 and 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 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 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) --- CLAUDE.md | 20 +++ .../2026-09-16-agent-chat-stored-xss.md | 124 ++++++++++++++++++ src/components/Agent/AgentView.jsx | 7 +- src/components/Agent/boldify.js | 44 +++++++ src/components/Agent/boldify.test.js | 110 ++++++++++++++++ src/components/Brain/AgentArtifacts.jsx | 10 +- 6 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 docs/security/2026-09-16-agent-chat-stored-xss.md create mode 100644 src/components/Agent/boldify.js create mode 100644 src/components/Agent/boldify.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 52aac16..6300492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,6 +502,26 @@ report in chat, but it never computes it. 7. **Virtual Threads**: Enabled for concurrency (JDK 25). ### Frontend Rules + +0. **Anything reaching `dangerouslySetInnerHTML` must be escaped at the source.** + `AgentView`'s `boldify` applied its markdown substitutions to the raw string and escaped + nothing, so every character of an assistant reply was parsed as markup — verified by + executing it: `boldify('')` returned the payload byte for byte, and + in a browser Chromium built a real `` element and fired its handler. It is **stored** + XSS: `agent_conversation.transcript` is replayed into the renderer on load, and the agent + echoes database content, so a table named `` renders in an analyst's + browser. httpOnly cookies mean the token cannot be read, but `withCredentials` means the + payload acts *as* the reader. **Escape before substituting, never after** — escaping after + would escape the ``/`` tags the function itself emits and print literal tag + text. Note `') + + assert.ok(!out.includes('