VYPR
Medium severityNVD Advisory· Published Feb 13, 2026· Updated Apr 15, 2026

CVE-2026-1721

CVE-2026-1721

Description

Summary

A Reflected Cross-Site Scripting (XSS) vulnerability was discovered in the AI Playground's OAuth callback handler. The error_description query parameter was directly interpolated into an HTML script tag without proper escaping, allowing attackers to execute arbitrary JavaScript in the context of the victim's session.

Root cause

The OAuth callback handler in site/ai-playground/src/server.ts directly interpolated the authError value, sourced from the error_description query parameter, into an inline <script> tag.

Impact

An attacker could craft a malicious link that, when clicked by a victim, would:

  • Steal user chat message history - Access all LLM interactions stored in the user's session.
  • Access connected MCP Servers - Interact with any MCP servers connected to the victim's session (public or authenticated/private), potentially allowing the attacker to perform actions on the victim's behalf

Mitigation:

  • PR:  https://github.com/cloudflare/agents/pull/841 https://github.com/cloudflare/agents/pull/841
  • Agents-sdk users should upgrade to agents@0.3.10
  • Developers using configureOAuthCallback with custom error handling in their own applications should ensure all user-controlled input is escaped before interpolation.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
agentsnpm
< 0.3.100.3.10

Affected products

1

Patches

1
3f490d045844

fix: escape html in external oauth error message (#841)

12 files changed · +224 143
  • .changeset/rare-windows-bathe.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +"agents": patch
    +---
    +
    +Escape authError to prevent XSS attacks and store it in the connection state to avoid needing script tags to display error.
    
  • examples/mcp-client/src/client.tsx+1 0 modified
    @@ -69,6 +69,7 @@ function App() {
             placeholder: {
               auth_url: null,
               capabilities: null,
    +          error: null,
               instructions: null,
               name: serverName,
               server_url: serverUrl,
    
  • examples/mcp-client/src/server.ts+5 17 modified
    @@ -1,26 +1,14 @@
     import { Agent, routeAgentRequest } from "agents";
    -import type { MCPClientOAuthResult } from "agents/mcp";
     
     export class MyAgent extends Agent {
       onStart() {
         // Optionally configure OAuth callback. Here we use popup-closing behavior since we're opening a window on the client
         this.mcp.configureOAuthCallback({
    -      customHandler: (result: MCPClientOAuthResult) => {
    -        if (result.authSuccess) {
    -          return new Response("<script>window.close();</script>", {
    -            headers: { "content-type": "text/html" },
    -            status: 200
    -          });
    -        } else {
    -          const safeError = JSON.stringify(result.authError || "Unknown error");
    -          return new Response(
    -            `<script>alert('Authentication failed: ' + ${safeError}); window.close();</script>`,
    -            {
    -              headers: { "content-type": "text/html" },
    -              status: 200
    -            }
    -          );
    -        }
    +      customHandler: () => {
    +        return new Response("<script>window.close();</script>", {
    +          headers: { "content-type": "text/html" },
    +          status: 200
    +        });
           }
         });
       }
    
  • package-lock.json+9 0 modified
    @@ -11006,6 +11006,13 @@
           "dev": true,
           "license": "MIT"
         },
    +    "node_modules/@types/escape-html": {
    +      "version": "1.0.4",
    +      "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
    +      "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==",
    +      "dev": true,
    +      "license": "MIT"
    +    },
         "node_modules/@types/estree": {
           "version": "1.0.8",
           "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
    @@ -27460,6 +27467,7 @@
             "@cfworker/json-schema": "^4.1.1",
             "@modelcontextprotocol/sdk": "1.25.2",
             "cron-schedule": "^6.0.0",
    +        "escape-html": "^1.0.3",
             "json-schema": "^0.4.0",
             "json-schema-to-typescript": "^15.0.4",
             "mimetext": "^3.0.28",
    @@ -27475,6 +27483,7 @@
             "@ai-sdk/openai": "^3.0.23",
             "@ai-sdk/react": "^3.0.66",
             "@cloudflare/workers-oauth-provider": "^0.2.2",
    +        "@types/escape-html": "^1.0.4",
             "@types/react": "^19.2.10",
             "@types/yargs": "^17.0.35",
             "ai": "^6.0.64",
    
  • packages/agents/package.json+2 0 modified
    @@ -27,6 +27,7 @@
         "@cfworker/json-schema": "^4.1.1",
         "@modelcontextprotocol/sdk": "1.25.2",
         "cron-schedule": "^6.0.0",
    +    "escape-html": "^1.0.3",
         "json-schema": "^0.4.0",
         "json-schema-to-typescript": "^15.0.4",
         "mimetext": "^3.0.28",
    @@ -39,6 +40,7 @@
         "@ai-sdk/openai": "^3.0.23",
         "@ai-sdk/react": "^3.0.66",
         "@cloudflare/workers-oauth-provider": "^0.2.2",
    +    "@types/escape-html": "^1.0.4",
         "@types/react": "^19.2.10",
         "@types/yargs": "^17.0.35",
         "ai": "^6.0.64",
    
  • packages/agents/src/index.ts+2 0 modified
    @@ -259,6 +259,7 @@ export type MCPServer = {
       // Scope outside of that can't be relied upon because when the DO sleeps, there's no way
       // to communicate a change to a non-ready state.
       state: MCPConnectionState;
    +  error: string | null;
       instructions: string | null;
       capabilities: ServerCapabilities | null;
     };
    @@ -3182,6 +3183,7 @@ export class Agent<
             mcpState.servers[server.id] = {
               auth_url: server.auth_url,
               capabilities: serverConn?.serverCapabilities ?? null,
    +          error: serverConn?.connectionError ?? null,
               instructions: serverConn?.instructions ?? null,
               name: server.name,
               server_url: server.server_url,
    
  • packages/agents/src/mcp/client-connection.ts+1 0 modified
    @@ -95,6 +95,7 @@ export type MCPDiscoveryResult = {
     export class MCPClientConnection {
       client: Client;
       connectionState: MCPConnectionState = MCPConnectionState.CONNECTING;
    +  connectionError: string | null = null;
       lastConnectedTransport: BaseTransportType | undefined;
       instructions?: string;
       tools: Tool[] = [];
    
  • packages/agents/src/mcp/client.ts+63 71 modified
    @@ -1,4 +1,5 @@
     import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
    +import escapeHtml from "escape-html";
     import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
     import type {
       CallToolRequest,
    @@ -44,6 +45,13 @@ export type MCPServerOptions = {
       };
     };
     
    +/**
    + * Result of an OAuth callback request
    + */
    +export type MCPOAuthCallbackResult =
    +  | { serverId: string; authSuccess: true; authError?: undefined }
    +  | { serverId: string; authSuccess: false; authError: string };
    +
     /**
      * Options for registering an MCP server
      */
    @@ -187,6 +195,19 @@ export class MCPClientManager {
         );
       }
     
    +  private failConnection(
    +    serverId: string,
    +    error: string
    +  ): MCPOAuthCallbackResult {
    +    this.clearServerAuthUrl(serverId);
    +    if (this.mcpConnections[serverId]) {
    +      this.mcpConnections[serverId].connectionState = MCPConnectionState.FAILED;
    +      this.mcpConnections[serverId].connectionError = error;
    +    }
    +    this._onServerStateChanged.fire();
    +    return { serverId, authSuccess: false, authError: error };
    +  }
    +
       jsonSchema: typeof import("ai").jsonSchema | undefined;
     
       /**
    @@ -663,19 +684,19 @@ export class MCPClientManager {
         return servers.some((server) => server.id === serverId);
       }
     
    -  async handleCallbackRequest(req: Request) {
    +  async handleCallbackRequest(req: Request): Promise<MCPOAuthCallbackResult> {
         const url = new URL(req.url);
         const code = url.searchParams.get("code");
         const state = url.searchParams.get("state");
         const error = url.searchParams.get("error");
         const errorDescription = url.searchParams.get("error_description");
     
    +    // Early validation - these throw because we can't identify the connection
         if (!state) {
           throw new Error("Unauthorized: no state provided");
         }
     
         const serverId = this.extractServerIdFromState(state);
    -
         if (!serverId) {
           throw new Error(
             "No serverId found in state parameter. Expected format: {nonce}.{serverId}"
    @@ -684,7 +705,6 @@ export class MCPClientManager {
     
         const servers = this.getServersFromStorage();
         const serverExists = servers.some((server) => server.id === serverId);
    -
         if (!serverExists) {
           throw new Error(
             `No server found with id "${serverId}". Was the request matched with \`isCallbackRequest()\`?`
    @@ -695,89 +715,61 @@ export class MCPClientManager {
           throw new Error(`Could not find serverId: ${serverId}`);
         }
     
    +    // We have a valid connection - all errors from here should fail the connection
         const conn = this.mcpConnections[serverId];
    -    if (!conn.options.transport.authProvider) {
    -      throw new Error(
    -        "Trying to finalize authentication for a server connection without an authProvider"
    -      );
    -    }
     
    -    const authProvider = conn.options.transport.authProvider;
    -    authProvider.serverId = serverId;
    +    try {
    +      if (!conn.options.transport.authProvider) {
    +        throw new Error(
    +          "Trying to finalize authentication for a server connection without an authProvider"
    +        );
    +      }
     
    -    // Two-phase state validation: check first (non-destructive), consume later
    -    // This prevents DoS attacks where attacker consumes valid state before legitimate callback
    -    const stateValidation = await authProvider.checkState(state);
    -    if (!stateValidation.valid) {
    -      this.clearServerAuthUrl(serverId);
    -      if (this.mcpConnections[serverId]) {
    -        this.mcpConnections[serverId].connectionState =
    -          MCPConnectionState.FAILED;
    +      const authProvider = conn.options.transport.authProvider;
    +      authProvider.serverId = serverId;
    +
    +      // Two-phase state validation: check first (non-destructive), consume later
    +      // This prevents DoS attacks where attacker consumes valid state before legitimate callback
    +      const stateValidation = await authProvider.checkState(state);
    +      if (!stateValidation.valid) {
    +        throw new Error(stateValidation.error || "Invalid state");
           }
    -      this._onServerStateChanged.fire();
    -      return {
    -        serverId,
    -        authSuccess: false,
    -        authError: stateValidation.error || "Invalid state"
    -      };
    -    }
     
    -    if (error) {
    -      return {
    -        serverId,
    -        authSuccess: false,
    -        authError: errorDescription || error
    -      };
    -    }
    +      if (error) {
    +        // Escape external OAuth error params to prevent XSS
    +        throw new Error(escapeHtml(errorDescription || error));
    +      }
     
    -    if (!code) {
    -      throw new Error("Unauthorized: no code provided");
    -    }
    +      if (!code) {
    +        throw new Error("Unauthorized: no code provided");
    +      }
     
    -    if (
    -      this.mcpConnections[serverId].connectionState ===
    -        MCPConnectionState.READY ||
    -      this.mcpConnections[serverId].connectionState ===
    -        MCPConnectionState.CONNECTED
    -    ) {
    -      this.clearServerAuthUrl(serverId);
    -      return {
    -        serverId,
    -        authSuccess: true
    -      };
    -    }
    +      // Already authenticated - just return success
    +      if (
    +        conn.connectionState === MCPConnectionState.READY ||
    +        conn.connectionState === MCPConnectionState.CONNECTED
    +      ) {
    +        this.clearServerAuthUrl(serverId);
    +        return { serverId, authSuccess: true };
    +      }
     
    -    if (
    -      this.mcpConnections[serverId].connectionState !==
    -      MCPConnectionState.AUTHENTICATING
    -    ) {
    -      throw new Error(
    -        `Failed to authenticate: the client is in "${this.mcpConnections[serverId].connectionState}" state, expected "authenticating"`
    -      );
    -    }
    +      if (conn.connectionState !== MCPConnectionState.AUTHENTICATING) {
    +        throw new Error(
    +          `Failed to authenticate: the client is in "${conn.connectionState}" state, expected "authenticating"`
    +        );
    +      }
     
    -    try {
           await authProvider.consumeState(state);
           await conn.completeAuthorization(code);
           await authProvider.deleteCodeVerifier();
           this.clearServerAuthUrl(serverId);
    +      conn.connectionError = null;
           this._onServerStateChanged.fire();
     
    -      return {
    -        serverId,
    -        authSuccess: true
    -      };
    -    } catch (authError) {
    -      const errorMessage =
    -        authError instanceof Error ? authError.message : String(authError);
    -
    -      this._onServerStateChanged.fire();
    -
    -      return {
    -        serverId,
    -        authSuccess: false,
    -        authError: errorMessage
    -      };
    +      return { serverId, authSuccess: true };
    +    } catch (err) {
    +      const message = err instanceof Error ? err.message : String(err);
    +      return this.failConnection(serverId, message);
         }
       }
     
    
  • packages/agents/src/tests/mcp/client-manager.test.ts+101 8 modified
    @@ -399,7 +399,7 @@ describe("MCPClientManager OAuth Integration", () => {
           expect(result.authError).toBe("User denied access");
         });
     
    -    it("should throw error for callback without code or error", async () => {
    +    it("should fail connection for callback without code or error", async () => {
           const serverId = "server1";
           const callbackUrl = "http://localhost:3000/callback";
           const stateStorage = createMockStateStorage();
    @@ -429,9 +429,9 @@ describe("MCPClientManager OAuth Integration", () => {
           const state = stateStorage.createState(serverId);
           const callbackRequest = new Request(`${callbackUrl}?state=${state}`);
     
    -      await expect(
    -        manager.handleCallbackRequest(callbackRequest)
    -      ).rejects.toThrow("Unauthorized: no code provided");
    +      const result = await manager.handleCallbackRequest(callbackRequest);
    +      expect(result.authSuccess).toBe(false);
    +      expect(result.authError).toBe("Unauthorized: no code provided");
         });
     
         it("should throw error for callback without state", async () => {
    @@ -496,7 +496,7 @@ describe("MCPClientManager OAuth Integration", () => {
           expect(result.serverId).toBe(serverId);
         });
     
    -    it("should error when callback received for connection in failed state", async () => {
    +    it("should fail connection when callback received for connection in failed state", async () => {
           const serverId = "test-server";
           const callbackUrl = "http://localhost:3000/callback";
           const stateStorage = createMockStateStorage();
    @@ -532,9 +532,9 @@ describe("MCPClientManager OAuth Integration", () => {
             `${callbackUrl}?code=test&state=${state}`
           );
     
    -      await expect(
    -        manager.handleCallbackRequest(callbackRequest)
    -      ).rejects.toThrow(
    +      const result = await manager.handleCallbackRequest(callbackRequest);
    +      expect(result.authSuccess).toBe(false);
    +      expect(result.authError).toBe(
             'Failed to authenticate: the client is in "failed" state, expected "authenticating"'
           );
         });
    @@ -813,6 +813,9 @@ describe("MCPClientManager OAuth Integration", () => {
           expect(result.authSuccess).toBe(false);
           expect(result.serverId).toBe(serverId);
           expect(result.authError).toBe("User denied access");
    +      // Verify error is stored on connection for UI display
    +      expect(connection.connectionState).toBe("failed");
    +      expect(connection.connectionError).toBe("User denied access");
         });
     
         it("should handle OAuth error without description", async () => {
    @@ -853,6 +856,96 @@ describe("MCPClientManager OAuth Integration", () => {
           expect(result.authError).toBe("server_error");
         });
     
    +    it("should escape XSS payloads in error_description", async () => {
    +      const serverId = "test-server";
    +      const callbackUrl = "http://localhost:3000/callback";
    +      const stateStorage = createMockStateStorage();
    +
    +      saveServerToMock({
    +        id: serverId,
    +        name: "Test Server",
    +        server_url: "http://test.com",
    +        callback_url: callbackUrl,
    +        client_id: "test-client-id",
    +        auth_url: null,
    +        server_options: null
    +      });
    +
    +      const mockAuthProvider = createMockAuthProvider(stateStorage);
    +      const connection = new MCPClientConnection(
    +        new URL("http://test.com"),
    +        { name: "test-client", version: "1.0.0" },
    +        {
    +          transport: { type: "auto", authProvider: mockAuthProvider },
    +          client: {}
    +        }
    +      );
    +      connection.connectionState = "authenticating";
    +      manager.mcpConnections[serverId] = connection;
    +
    +      const state = stateStorage.createState(serverId);
    +      const xssPayload = "</script><img src=x onerror=alert(1)>";
    +      const callbackRequest = new Request(
    +        `${callbackUrl}?error=access_denied&error_description=${encodeURIComponent(xssPayload)}&state=${state}`
    +      );
    +      const result = await manager.handleCallbackRequest(callbackRequest);
    +
    +      expect(result.authSuccess).toBe(false);
    +      // Verify XSS payload is escaped
    +      expect(result.authError).toBe(
    +        "&lt;/script&gt;&lt;img src=x onerror=alert(1)&gt;"
    +      );
    +      expect(connection.connectionError).toBe(
    +        "&lt;/script&gt;&lt;img src=x onerror=alert(1)&gt;"
    +      );
    +      // Should not contain raw script tag
    +      expect(result.authError).not.toContain("<script>");
    +      expect(result.authError).not.toContain("</script>");
    +    });
    +
    +    it("should escape XSS payloads in error parameter when description is absent", async () => {
    +      const serverId = "test-server";
    +      const callbackUrl = "http://localhost:3000/callback";
    +      const stateStorage = createMockStateStorage();
    +
    +      saveServerToMock({
    +        id: serverId,
    +        name: "Test Server",
    +        server_url: "http://test.com",
    +        callback_url: callbackUrl,
    +        client_id: "test-client-id",
    +        auth_url: null,
    +        server_options: null
    +      });
    +
    +      const mockAuthProvider = createMockAuthProvider(stateStorage);
    +      const connection = new MCPClientConnection(
    +        new URL("http://test.com"),
    +        { name: "test-client", version: "1.0.0" },
    +        {
    +          transport: { type: "auto", authProvider: mockAuthProvider },
    +          client: {}
    +        }
    +      );
    +      connection.connectionState = "authenticating";
    +      manager.mcpConnections[serverId] = connection;
    +
    +      const state = stateStorage.createState(serverId);
    +      const xssPayload = "<script>alert('xss')</script>";
    +      const callbackRequest = new Request(
    +        `${callbackUrl}?error=${encodeURIComponent(xssPayload)}&state=${state}`
    +      );
    +      const result = await manager.handleCallbackRequest(callbackRequest);
    +
    +      expect(result.authSuccess).toBe(false);
    +      expect(result.authError).toBe(
    +        "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
    +      );
    +      expect(connection.connectionError).toBe(
    +        "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
    +      );
    +    });
    +
         it("should handle token exchange failure", async () => {
           const serverId = "test-server";
           const callbackUrl = "http://localhost:3000/callback";
    
  • site/ai-playground/src/app.tsx+2 1 modified
    @@ -110,7 +110,8 @@ const App = () => {
               id,
               name: server.name,
               url: server.server_url,
    -          state: server.state
    +          state: server.state,
    +          error: server.error
             })
           );
     
    
  • site/ai-playground/src/components/McpServers.tsx+28 29 modified
    @@ -12,6 +12,7 @@ export type McpServerInfo = {
       name?: string;
       url?: string;
       state: string;
    +  error?: string | null;
     };
     
     export type McpServersComponentState = {
    @@ -61,14 +62,6 @@ export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
         (s) => s.state === "authenticating"
       );
     
    -  // Clear error when a server becomes ready
    -  useEffect(() => {
    -    const hasReadyServer = mcpState.servers.some((s) => s.state === "ready");
    -    if (hasReadyServer) {
    -      setError("");
    -    }
    -  }, [mcpState.servers]);
    -
       const logRef = useRef<HTMLDivElement>(null);
       const [showAuth, setShowAuth] = useState<boolean>(false);
       const [headerKey, setHeaderKey] = useState<string>(() => {
    @@ -364,11 +357,6 @@ export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
           </p>
     
           <div className="my-4">
    -        {/* Error display */}
    -        {error && (
    -          <div className="mb-2 text-xs text-red-600 truncate">{error}</div>
    -        )}
    -
             {/* Add new server form - URL input + key icon + Add button */}
             <div className="relative mb-4">
               <div className="flex space-x-2">
    @@ -515,25 +503,36 @@ export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
                 {mcpState.servers.map((server) => (
                   <div
                     key={server.id}
    -                className="flex items-center justify-between p-2 border border-gray-200 rounded-md bg-gray-50"
    +                className={`p-2 border rounded-md ${
    +                  server.state === "failed"
    +                    ? "border-red-200 bg-red-50"
    +                    : "border-gray-200 bg-gray-50"
    +                }`}
                   >
    -                <div className="flex items-center space-x-2 min-w-0 flex-1">
    -                  {getStatusBadge(server.state)}
    -                  <span
    -                    className="text-sm text-gray-700 truncate"
    -                    title={server.url}
    +                <div className="flex items-center justify-between">
    +                  <div className="flex items-center space-x-2 min-w-0 flex-1">
    +                    {getStatusBadge(server.state)}
    +                    <span
    +                      className="text-sm text-gray-700 truncate"
    +                      title={server.url}
    +                    >
    +                      {server.url}
    +                    </span>
    +                  </div>
    +                  <button
    +                    type="button"
    +                    className="ml-2 px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 rounded-md transition-colors shrink-0"
    +                    onClick={() => handleDisconnect(server.id)}
    +                    disabled={disconnectingServerId === server.id}
                       >
    -                    {server.url}
    -                  </span>
    +                    {disconnectingServerId === server.id ? "..." : "×"}
    +                  </button>
                     </div>
    -                <button
    -                  type="button"
    -                  className="ml-2 px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 rounded-md transition-colors shrink-0"
    -                  onClick={() => handleDisconnect(server.id)}
    -                  disabled={disconnectingServerId === server.id}
    -                >
    -                  {disconnectingServerId === server.id ? "..." : "×"}
    -                </button>
    +                {server.state === "failed" && server.error && (
    +                  <div className="mt-2 text-xs text-red-600 break-words">
    +                    {server.error}
    +                  </div>
    +                )}
                   </div>
                 ))}
               </div>
    
  • site/ai-playground/src/server.ts+5 17 modified
    @@ -1,7 +1,6 @@
     import { createWorkersAI } from "workers-ai-provider";
     import { callable, routeAgentRequest } from "agents";
     import { AIChatAgent } from "@cloudflare/ai-chat";
    -import type { MCPClientOAuthResult } from "agents/mcp";
     import {
       convertToModelMessages,
       createUIMessageStream,
    @@ -66,23 +65,12 @@ export class Playground extends AIChatAgent<Env, PlaygroundState> {
       };
     
       onStart() {
    -    // Configure OAuth callback to close popup window after authentication
         this.mcp.configureOAuthCallback({
    -      customHandler: (result: MCPClientOAuthResult) => {
    -        if (result.authSuccess) {
    -          return new Response("<script>window.close();</script>", {
    -            headers: { "content-type": "text/html" },
    -            status: 200
    -          });
    -        }
    -        const safeError = JSON.stringify(result.authError || "Unknown error");
    -        return new Response(
    -          `<script>alert('Authentication failed: ' + ${safeError}); window.close();</script>`,
    -          {
    -            headers: { "content-type": "text/html" },
    -            status: 200
    -          }
    -        );
    +      customHandler: () => {
    +        return new Response("<script>window.close();</script>", {
    +          headers: { "content-type": "text/html" },
    +          status: 200
    +        });
           }
         });
       }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

50