|
| 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. |
0 commit comments