Rendering without handing over the page
Markdown, HTML, links and images each turn model output into a different vector, and the quietest one is an image URL the browser fetches before anyone clicks anything.
The recommendation chatbot renders assistant messages as markdown, because plain text looked bad and markdown was one npm install away. That decision was made in an afternoon, on aesthetic grounds, by somebody who was not thinking about security — which is not a confession, it is the normal way this decision gets made everywhere.
Markdown rendering is a spectrum of authority, not a single choice. Every renderer has a configuration deciding how much of the page the content gets, and most people never open it. This lesson is about what each setting hands over — and the order of danger is the reverse of the order of how dangerous they look.
The sink you already know, and the myth attached to it
innerHTML is the sink every front-end engineer has been warned about. MDN’s warning on it is worth reading again with agent output in mind, because it names the category:
“This property parses its input as HTML, writing the result into the DOM. APIs like this are known as injection sinks, and are potentially a vector for cross-site scripting (XSS) attacks…” (MDN, Element.innerHTML)The same page kills the myth attached to it — the belief that innerHTML is safe-ish because injected <script> tags do not execute. MDN’s wording is that preventing script execution “is susceptible to many other ways that attackers can craft HTML to run malicious JavaScript.”
React’s dangerouslySetInnerHTML is the same sink with a name designed to make you think twice, and the interesting failure is not that somebody used it carelessly. It is that a markdown renderer with raw-HTML passthrough enabled reaches the same sink without anyone typing the word “HTML” anywhere in the codebase.
Markdown is an injection sink wearing a friendly name
OWASP moved improper output handling down to LLM10 in the 2026 edition — the furthest fall on the list, from fifth in 2025. Do not read that as the risk shrinking; read it as the list reweighting toward agentic risks like excessive agency. The entry itself got more front-end specific, not less:
“JavaScript or Markdown is generated by the LLM and returned to a user. The code is then interpreted by the browser, resulting in XSS.” (OWASP, LLM10:2026)
The four settings worth knowing in whatever renderer you are using, in rough order of authority handed over:
- Raw HTML passthrough. The renderer emits any HTML found in the source. This is
innerHTMLwith extra steps. - Images. The renderer emits
<img>with a model-suppliedsrc. The browser fetches it with no user action. This is the one that gets exploited, and it is covered below on its own. - Links. An anchor with a model-supplied
href, includingjavascript:anddata:schemes unless the renderer strips them. Requires a click, which people misread as requiring an attack on the user. It does not. It requires a plausible label, and the model writes the label. - Everything else — tables, code blocks, headings, emphasis. The reason you wanted markdown, and not where the problem is.
The image that leaks, and the five times it shipped
Here is the mechanism, and it is worth being able to draw on a whiteboard because it is the thing nobody outside this niche anticipates. A markdown image is . The renderer turns it into an <img src="url">. The browser fetches that URL immediately, unprompted, to display the picture. The URL is a string the model produced. If the model can be induced to put conversation content into the query string, the fetch is the exfiltration — no click, no script, no XSS, and nothing visibly wrong on screen. A 1×1 transparent pixel renders as nothing at all.
Johann Rehberger demonstrated it against ChatGPT plugins in May 2023, with a payload shaped exactly like the one above:
The detail worth carrying from that writeup is not the payload. It is the response: after disclosure in April 2023, OpenAI indicated that image markdown injection was a feature and no changes were planned. This class was known, reported, and treated as working-as-intended by a major vendor. Note who published that account — a security researcher writing up his own disclosure — and that the vendor’s side of it is reported rather than quoted.
It then shipped repeatedly, in products built by teams with real security functions. Simon Willison has tracked the class under an exfiltration-attacks tag and defines it as tricking a bot into rendering a Markdown image that leaks data encoded in the URL. Three of the incidents there teach something distinct:
- Google Bard, November 2023. A CSP restricting images to Google domains was in place. It was bypassed by routing through Google’s own Apps Script. A CSP allow-list is only as tight as the most permissive thing on it.
- Slack AI, August 2024. Not an image — a link. Willison’s description: an attack can trick Slack into showing a Markdown link which, when clicked, passes private data to the attacker’s server in the query string. The label was “click here to reauthenticate.” Blocking images does not close this.
- CamoLeak, October 2025. The one that should change how you read mitigations. GitHub proxies external images through Camo, which is documented as a privacy control — it anonymises URLs so third parties cannot track readers. Omer Mayraz pre-generated a Camo URL per character, had Copilot emit private code base16-encoded as a sequence of those allow-listed pixel loads, and read the secret off the request order. The proxy did not fail. It worked exactly as designed, and it was the channel. GitHub’s fix, in August 2025, was to disable image rendering in Copilot Chat entirely. Legit Security sells application security tooling, so take the mechanism, which is verifiable, and attribute the severity framing.
Where people get burned
Two mitigation stories in that list — Bard’s CSP and GitHub’s Camo — were bypassed by using something already on the allow-list. That is the pattern to expect, and it is why the honest framing for everything in the next section is blast radius reduction, not prevention. Both products ended up at the same place: stop auto-rendering images. The control that held was the one that removed the capability, not the one that filtered it.
What actually reduces the blast radius
OWASP’s 2026 recommendation for client renderers is unusually direct, and it is the sentence to put in front of anyone who thinks this is a backend concern:
“In client renderers (chat UIs, IDEs, email clients, mobile apps), prevent model output from triggering automatic outbound requests to attacker-controlled endpoints. Disable auto-rendering of Markdown images, link previews, iframes, and similar elements by default.” (OWASP, LLM10:2026)
Content Security Policy is the browser mechanism that makes some of that enforceable rather than aspirational. Two directives matter here. img-src “specifies valid sources of images and favicons.” connect-src “restricts the URLs which can be loaded using script interfaces” — MDN lists fetch(), fetchLater(), XMLHttpRequest, WebSocket, EventSource, Navigator.sendBeacon() and the ping attribute.
Content-Security-Policy: default-src 'self';
img-src 'self' data:;
connect-src 'self' https://api.your-product.example;
form-action 'self';
frame-ancestors 'none'Restricting img-src to your own origin turns “the renderer might emit a remote image” into “the browser will refuse to fetch one.” MDN documents the host-source mechanics that make this work but never calls it a proxy pattern — so treat “route images through your own origin” as an architectural recommendation built on documented mechanics, not as advice MDN gives. Bard and CamoLeak are the reason to also ask what else is on the list.
Sanitisation is the other half, and the platform story is genuinely awkward. The HTML Sanitizer API is on the standards track, but MDN’s banner reads “Limited availability”, and compatibility data checked on 3 September 2026 gives Element.setHTML() as Chrome and Edge 146, Firefox 148, Opera 130, and no support in Safari, Safari iOS or Samsung Internet — while setHTMLUnsafe(), the variant that does not sanitise, is Baseline everywhere. The safe API is the unavailable one. Anyone telling you to just use the Sanitizer API has not checked Safari.
Which leaves DOMPurify load-bearing in practice. Read its release history rather than just adding it to package.json: version 3.4.14, 19 August 2026, fixed “possible bypasses when risky tags are allow-listed.” Same lesson as Bard and Camo, one layer down — the bypass lives in the allow-list you widened.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Why is the link variant harder to argue away than the image variant, even though it needs a click?
Check your answer
Because the click is not a defence you control and not a hurdle the attacker has to clear alone. The model writes the label. In the Slack AI case the label was an instruction to reauthenticate, aimed at a person who was already in a work tool, already trusts it, and has been trained by every SaaS product they use to click exactly that. The click is a formality the attacker gets to design.
So “we disabled images” is an incomplete answer, and it is the answer a team will give you, because images got the headlines. The follow-up is whether model-supplied href values are constrained — to what list, and maintained by whom.
Hands on
Write the rendering policy for one surface, and the CSP that enforces it
Done when: A committed file in the surface’s repo stating, per element type — raw HTML, images, links, iframes, link previews — whether it is permitted and why; plus a draft CSP with img-src and connect-src filled in; plus the matching FLAG-LOG row. The policy must name at least one thing it does NOT constrain.
- Open the renderer config for the chatbot’s assistant message component, or whichever surface renders model output. Find the actual settings — raw HTML passthrough, permitted schemes for
href, whether<img>is emitted. Write down what they are today, not what you assumed. - Load a message whose content is
through the real component and watch the network panel. Either a request goes out or it does not. This takes two minutes and settles the question that would otherwise be a paragraph of speculation in your flag. - Write the policy file. One row per element type: permitted or not, and one sentence of reasoning that a stranger could evaluate. Include a row for link
hrefschemes; it is the one people omit. - Draft the CSP header for that surface with
img-srcandconnect-srcfilled in against real origins. Do not deploy it. The draft is the artifact; deploying is a conversation with whoever owns the deployment. - Add a final section headed What this does not constrain. Prompt injection itself belongs there. So does anything that reaches the page by a route other than this renderer.
- Fill the What I saw and Why it matters here cells of the matching
FLAG-LOG.mdrow from the observed network behaviour, not from the config. Bring both into the chat and I’ll push on any claim that came from reading rather than watching.
What this does not cover
Everything above is about a surface that displays. The harder boundary is a surface that can start something — where model-supplied content initiates a fetch, a navigation, a form submission or a tool call rather than merely appearing on screen. The action-trigger lesson takes that up, and it is where the third leg of the lethal trifecta stops being about images and starts being about capability.
It also does not cover the other direction of leak. Nothing here is being attacked when a session-replay script quietly records a review gate showing somebody’s passport scan. That is the module on data in the UI, and it is a different failure with a different owner.
Read this next — primary source
LLM10:2026 Improper Output HandlingOWASP GenAI Security Project, OWASP GenAI LLM Top 10 2026 — free, published August 2026. Text available as Markdown in the project repository; there is no 2026 web page for this entry yet.
This lesson takes the two client-renderer sentences, new in the 2026 edition and the most front-end-specific text OWASP has published on this. The full entry adds five other downstream sinks — SQL, shell, admin tooling, generated code reaching production, email clients — which matter to you for a different reason than the browser ones: they are the sinks a portfolio company already has someone responsible for, and knowing they sit on the same list is what lets you hand a flag to that person instead of carrying it.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.