Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./September 10, 2026/14 min

Your AI Chat Leaks Data Through a Rendered Image

Filed underai-testing/playwright/llm/security/ai-agents
Your AI Chat Leaks Data Through a Rendered Image

Table of Contents
  • Why does this matter now?
  • Why don’t text assertions catch this?
  • The CI optimization that hides it
  • What is the render sink, exactly?
  • How do you make this test deterministic?
  • How do you assert the sink?
  • The DOM allowlist assertion
  • The network egress assertion
  • Cover the streaming path separately
  • What this test does not fix
  • The one thing to do today

On this page

  • Why does this matter now?
  • Why don’t text assertions catch this?
  • The CI optimization that hides it
  • What is the render sink, exactly?
  • How do you make this test deterministic?
  • How do you assert the sink?
  • The DOM allowlist assertion
  • The network egress assertion
  • Cover the streaming path separately
  • What this test does not fix
  • The one thing to do today

Your assistant answers the question, your Playwright test asserts the answer text, and the suite goes green. What the test never noticed is that the model also emitted a markdown image, your renderer turned it into an <img>, and the browser silently fetched a URL on a third-party host with the conversation stuffed into the query string. Nothing executed. Nothing threw. Your assertion was on textContent, and an image contributes exactly zero characters to textContent.

This is the whole bug class in one sentence: your tests assert what the model said, and the vulnerability lives in what your renderer did with it. Every SDET testing an AI feature has a mock-the-model story by now. Almost nobody has a test for the sink at the other end of the pipe.

Why does this matter now?

Because the model output path is a live, unvalidated injection sink, and the industry has already shipped the proof. OWASP ranks this as LLM05:2025 Improper Output Handling — the model returns text and the surrounding application trusts it as if it were sanitized, rendering it as HTML, building a URL from it, or handing it to a shell.

The reference incident is EchoLeak. Disclosed in June 2025 against Microsoft 365 Copilot, CVE-2025-32711 carries a CVSS score of 9.3 and required zero user interaction: a crafted email carried hidden instructions, Copilot pulled it into RAG context, and the response exfiltrated chat logs and SharePoint content to an attacker-controlled server. The mechanism was not a script tag. Per the vulnerability analysis, a major enabler was the automatic rendering of markdown links and image previews — and the exploit still worked around a Content Security Policy by routing through an allowlisted image proxy.

§ EchoLeak (CVE-2025-32711), the reference case for this bug class

9.3

CVSS score

Critical, disclosed June 2025

0

Clicks required

Zero-click, no user interaction

0

Scripts executed

Plain markdown image rendering

If a product team with Microsoft’s security budget shipped that, your chat feature — where the renderer was picked in an afternoon because it had the most GitHub stars — is not in better shape.

[ WARNING ]

Treat model output exactly as you treat user input from the public internet. The instant your app does retrieval, every document in the corpus is an untrusted author with a direct line to your renderer.

Why don’t text assertions catch this?

Because the dangerous node contributes nothing to the thing you asserted on. A Playwright assertion like expect(bubble).toContainText('Your balance is $4,210') reads the accumulated text of the subtree. An <img> element has no text content, so it is invisible to that assertion by construction — the answer renders correctly, the assertion passes, and a request goes out.

It gets worse if you followed the good advice to anchor AI test assertions to the accessibility tree. An image with alt="" is mapped to the presentation role and dropped from the accessibility tree entirely, so getByRole() queries can’t see it either. The most robust locator strategy in your toolkit is, for this specific bug, the most blind.

The CI optimization that hides it

Here is the detail that turns this from theory into a real incident. Most mature suites block images in CI for speed:

tests/fixtures/fast-page.ts
// Shaves ~30% off wall-clock on an image-heavy app. Also hides the bug.
await page.route('**/*.{png,jpg,jpeg,gif,webp,svg}', route => route.abort());

I’ve put this exact route in three different frameworks. It’s good engineering — and it means the exfiltration request is aborted at the Playwright layer before anything notices it was ever made. The suite is now structurally incapable of catching an image-based egress. This is the same failure mode I wrote about in green tests hiding real bugs: the optimization didn’t break the test, it broke the test’s ability to observe.

What is the render sink, exactly?

The render sink is every place model tokens stop being a string and start being behaviour. There are three, and they fail differently:

SinkWhat lands in the DOMWhat catches it
Markup<script>, <iframe>, event handlersSanitizer allowlist
URL schemejavascript:, data: in hrefScheme allowlist
Subresource<img src>, <link>, embedsNetwork egress assertion

The first two are ordinary XSS and your sanitizer probably handles them — DOMPurify ships with a secure default that strips both. The third is the one that gets shipped, because a rendered image is not a vulnerability in any XSS test suite’s model of the world. It’s a fetch. And a fetch is how data leaves.

Here’s the sink in its most common form:

src/components/AnswerBubble.tsx (the sink)
export function AnswerBubble({ markdown }: { markdown: string }) {
return (
<div
className="answer"
// marked → raw HTML → innerHTML. The model authored this string.
dangerouslySetInnerHTML={{ __html: marked.parse(markdown) }}
/>
);
}

Even with DOMPurify wrapped around marked.parse(), the default configuration keeps <img> and keeps arbitrary src hosts — because for every other use of a sanitizer, that’s correct behaviour. The gap isn’t a bug in the library. It’s a policy decision nobody made.

How do you make this test deterministic?

Pin the model out of the loop entirely. This is a renderer contract test, not a model eval — the input is a fixed string, and the only thing under test is what the DOM does with it. Route-mock the completion endpoint the same way you would for any two-lane mock-in-CI, eval-nightly setup:

tests/fixtures/mock-model.ts
export async function mockAnswer(page: Page, markdown: string) {
await page.route('**/api/chat/completions', async route => {
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
// One fixed answer. No sampling, no temperature, no flake.
body: `data: ${JSON.stringify({ delta: markdown })}\n\ndata: [DONE]\n\n`,
});
});
}

Now the fixture is a table of hostile-but-benign payloads. Keep them boring — a marker origin you control, nothing that actually encodes data:

tests/fixtures/render-payloads.ts
export const RENDER_PAYLOADS = [
{ name: 'external image', md: '![](https://sink.test/probe.png)' },
{ name: 'reference image', md: '![a][1]\n\n[1]: https://sink.test/probe.png' },
{ name: 'html img', md: '<img src="https://sink.test/probe.png">' },
{ name: 'js scheme link', md: '[click](javascript:void 0)' },
{ name: 'inline handler', md: '<span onmouseover="void 0">hi</span>' },
];

The reference-style variant matters more than it looks. Teams that patch the obvious ![](url) form routinely miss it, because their fix was a regex over the markdown string instead of a policy on the rendered tree.

How do you assert the sink?

Two assertions, layered. The DOM check tells you the sanitizer’s policy is what you think it is; the network check tells you the browser didn’t act on something the sanitizer let through.

Assert an allowlist over the rendered tree, not a denylist

Query every element inside the answer bubble and fail on any tag or attribute outside the set you deliberately permit. A denylist of “dangerous tags” ships a hole the day someone adds a new component; an allowlist fails closed.

Assert zero egress to non-allowlisted origins

Register a request listener before rendering, then assert that no request — of any resource type — left for an origin outside your CDN and API hosts. This is the assertion that would have caught EchoLeak’s class of bug.

Run it against the streamed path too

Partial markdown is a different renderer state. Half a link is still a link once the closing paren arrives, and some renderers commit the node early. Same payloads, chunked delivery.

Disable the image-blocking fixture for this spec only

Your fast-page route abort would swallow the very request you’re asserting on. Scope the speed optimization to the rest of the suite and let this spec observe real intent.

The DOM allowlist assertion

tests/security/render-sink.spec.ts
const ALLOWED_TAGS = new Set(['P','STRONG','EM','CODE','PRE','UL','OL','LI','A','BR']);
test('renderer emits only allowlisted tags', async ({ page }) => {
await mockAnswer(page, RENDER_PAYLOADS.map(p => p.md).join('\n\n'));
await page.goto('/chat');
await page.getByTestId('send').click();
const tags = await page.getByTestId('answer')
.locator('*')
.evaluateAll(nodes => nodes.map(n => n.tagName));
expect(tags.filter(t => !ALLOWED_TAGS.has(t))).toEqual([]);
});

Note what’s not in ALLOWED_TAGS: IMG. For an internal assistant answering questions about your own data, a model-authored image is never a legitimate feature — and the cheapest correct fix is to configure the sanitizer to drop the tag rather than to police its src. If your product genuinely needs model-rendered images (charts, generated diagrams), the tag goes on the allowlist and the origin check below becomes the load-bearing control.

The network egress assertion

This is the one that earns its keep, and it’s the reason to run this in Playwright rather than jsdom — only a real browser will actually attempt the fetch.

tests/security/render-sink.spec.ts
const ALLOWED_ORIGINS = ['http://localhost:3000', 'https://cdn.yourco.com'];
test('rendered answer makes no third-party requests', async ({ page }) => {
const egress: string[] = [];
page.on('request', req => {
const origin = new URL(req.url()).origin;
if (!ALLOWED_ORIGINS.includes(origin)) egress.push(`${req.resourceType()} ${req.url()}`);
});
await mockAnswer(page, '![](https://sink.test/probe.png)');
await page.goto('/chat');
await page.getByTestId('send').click();
await expect(page.getByTestId('answer')).toBeVisible();
expect(egress, 'model output triggered a third-party request').toEqual([]);
});

The request event fires on intent, before the response returns — so you can pair this with a blanket route(...).abort() on unknown origins and get a test that proves the leak was attempted without ever letting a byte reach sink.test. That’s the property I want in a security test running on a shared CI runner: it detects the egress, and it is itself incapable of performing one.

§ Delta · What the suite can observe about model output Egress detectable, not just deniable

Before

Answer text matched the expected string; image requests aborted by the speed fixture

→ ↓

After

Tag allowlist enforced on the rendered tree; every non-allowlisted origin request fails the build

Cover the streaming path separately

If your UI renders tokens as they arrive — which it does, because time-to-first-token is the metric users feel — the renderer runs against incomplete markdown dozens of times per answer. Chrome’s guidance on rendering streamed LLM responses is explicit that naive append-and-parse produces states the final-output test never sees. Deliver the same payloads in three-character chunks and re-run both assertions. On one team’s assistant, the final render was clean and an intermediate frame wasn’t — the sanitizer ran on the completed buffer, and the streaming path wrote through a different code path that skipped it.

What this test does not fix

It’s a detection gate, not a security architecture. Three things it deliberately leaves to other layers:

  • CSP is still required. EchoLeak bypassed a CSP through an allowlisted proxy, which is an argument for tightening img-src and connect-src, not for skipping them. The test tells you your policy is wrong; the header limits what happens when it is.
  • It doesn’t stop the injection. Preventing hostile instructions from entering context is a separate problem — the inbound half of it is what I covered in how your app under test can hijack a Playwright agent.
  • Server-side rendering has its own sink. If you generate email or PDF from model output, that pipeline needs the same allowlist and never touches this browser test.

The one thing to do today

Open your chat feature, find the line where model markdown becomes HTML, and check the sanitizer config for img. If <img> survives with an arbitrary src, write the network egress test above — it is about twenty lines and one fixture — and run it before you touch the sanitizer. Watch it go red. That red is the difference between a feature you believe is safe and one you can prove is, and it takes an afternoon, not a quarter.

Want more of these — the enterprise foot-guns in AI test pipelines that don’t show up in anyone’s docs? Get the weekly QA automation insights: no fluff, just battle-tested strategies from 10+ years in the trenches.

§ Frequently Asked FAQ
+ Doesn't DOMPurify already handle this?

Not by default, for this specific sink. DOMPurify reliably strips <script>, event handlers, and javascript: URLs — that’s classic XSS, and it’s solid. But its secure default still permits <img> with an arbitrary src, because that’s correct behaviour for nearly every other use of a sanitizer. Rendering a model-authored image from a third-party host is a policy decision your product has to make explicitly: either drop img from ALLOWED_TAGS, or enforce an origin allowlist on src — and assert whichever you chose.

+ Why use Playwright instead of a unit test with jsdom?

Because jsdom doesn’t fetch subresources. It will happily parse an <img> into the DOM and never attempt the network request, so the exfiltration attempt is unobservable. The whole value of this test is that a real browser actually tries the fetch, and Playwright’s page.on('request') lets you assert on that intent. Keep jsdom for the sanitizer’s pure string-to-tree behaviour; use Playwright for the egress assertion.

+ Our tests block images in CI for speed. Does that protect us?

No — it hides the problem. Aborting image requests in a page.route() handler stops the request inside your test environment only. Production has no such handler, so real users still trigger the fetch. Worse, the abort makes your suite structurally unable to detect the bug. Scope the image-blocking fixture to everything except your render-sink spec.

+ Should the network assertion be strict equality on an empty array?

Yes, and resist the urge to soften it. A tolerance like “no more than two unknown origins” immediately becomes the place teams park analytics beacons and third-party fonts, and the signal dies. Put every legitimate origin in ALLOWED_ORIGINS explicitly, review that list in code review, and keep the assertion at toEqual([]) so adding an origin is a deliberate act with a reviewer attached.

+ How do I test this if the product genuinely renders model-generated charts?

Keep img on the tag allowlist and move enforcement to the origin check. Model-generated visuals should be served from your own domain or a CDN you control — never from a host the model names. The test then asserts every image src resolves to an allowlisted origin, which still fails on any attacker-supplied URL while permitting your legitimate rendering path.

§ Further Reading 03 of 03
01AI Testing

Your Agent's Confirm Dialog Is Safety Theater

Your Playwright test checks the confirm dialog renders — not that the irreversible action was blocked. Test the human-in-the-loop approval gate properly.

Read →
02AI Testing

Your Agent Has More Permissions Than Your User

AI agents call tools with service credentials, so user authorization vanishes at the tool boundary. Test the confused deputy in Playwright before an auditor.

Read →
03AI Testing

Your AI Agent Is Looping, and No Test Caught It

A max-iterations cap hides livelock instead of catching it. Here's the Playwright test a senior SDET writes to detect cyclic tool calls before they hit prod.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.