Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

加固 MCP 服务器

MCP 服务器和任何 web 应用一样,需要做安全加固,才能让受信任的用户使用而不被滥用。MCP 规范使用 OAuth 2.1 完成 MCP 客户端与服务器之间的认证。

本指南介绍当 MCP 服务器作为第三方提供商(如 GitHub、Google)的 OAuth 代理时的安全最佳实践。

使用 workers-oauth-provider 实现 OAuth 保护

Cloudflare 的 workers-oauth-provider ↗ 处理 token 管理、客户端注册和访问 token 校验:

JavaScript


import { OAuthProvider } from "@cloudflare/workers-oauth-provider";

import { MyMCP } from "./mcp";


export default new OAuthProvider({

  authorizeEndpoint: "/authorize",

  tokenEndpoint: "/token",

  clientRegistrationEndpoint: "/register",

  apiRoute: "/mcp",

  apiHandler: MyMCP.serve("/mcp"),

  defaultHandler: AuthHandler,

});


TypeScript


import { OAuthProvider } from "@cloudflare/workers-oauth-provider";

import { MyMCP } from "./mcp";


export default new OAuthProvider({

  authorizeEndpoint: "/authorize",

  tokenEndpoint: "/token",

  clientRegistrationEndpoint: "/register",

  apiRoute: "/mcp",

  apiHandler: MyMCP.serve("/mcp"),

  defaultHandler: AuthHandler,

});


同意对话框的安全

当你的 MCP 服务器代理到第三方 OAuth 提供商时,在把用户转发到上游之前,必须实现你自己的同意对话框。这可以避免“困惑代理(confused deputy)“问题——攻击者可能利用缓存的同意凭证。

CSRF 防护

没有 CSRF 防护时,攻击者可以诱骗用户批准恶意 OAuth 客户端。请使用一个存放在安全 cookie 中的随机 token:

JavaScript


// Generate CSRF token when showing consent form

function generateCSRFProtection() {

  const token = crypto.randomUUID();

  const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;

  return { token, setCookie };

}


// Validate CSRF token on form submission

function validateCSRFToken(formData, request) {

  const tokenFromForm = formData.get("csrf_token");

  const cookieHeader = request.headers.get("Cookie") || "";

  const tokenFromCookie = cookieHeader

    .split(";")

    .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))

    ?.split("=")[1];


  if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {

    throw new Error("CSRF token mismatch");

  }


  // Clear cookie after use (one-time use)

  return {

    clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`,

  };

}


TypeScript


// Generate CSRF token when showing consent form

function generateCSRFProtection() {

  const token = crypto.randomUUID();

  const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`;

  return { token, setCookie };

}


// Validate CSRF token on form submission

function validateCSRFToken(formData: FormData, request: Request) {

  const tokenFromForm = formData.get("csrf_token");

  const cookieHeader = request.headers.get("Cookie") || "";

  const tokenFromCookie = cookieHeader

    .split(";")

    .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN="))

    ?.split("=")[1];


  if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) {

    throw new Error("CSRF token mismatch");

  }


  // Clear cookie after use (one-time use)

  return {

    clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`,

  };

}


把 token 作为隐藏字段放进同意表单中:


<input type="hidden" name="csrf_token" value="${csrfToken}" />


输入清理

未经清理的用户可控内容(客户端名、logo、URI)可能执行恶意脚本:

JavaScript


function sanitizeText(text) {

  return text

    .replace(/&/g, "&amp;")

    .replace(/</g, "&lt;")

    .replace(/>/g, "&gt;")

    .replace(/"/g, "&quot;")

    .replace(/'/g, "&#039;");

}


function sanitizeUrl(url) {

  if (!url) return "";

  try {

    const parsed = new URL(url);

    // Only allow http/https - reject javascript:, data:, file:

    if (!["http:", "https:"].includes(parsed.protocol)) {

      return "";

    }

    return url;

  } catch {

    return "";

  }

}


// Always sanitize before rendering

const clientName = sanitizeText(client.clientName);

const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));


TypeScript


function sanitizeText(text: string): string {

  return text

    .replace(/&/g, "&amp;")

    .replace(/</g, "&lt;")

    .replace(/>/g, "&gt;")

    .replace(/"/g, "&quot;")

    .replace(/'/g, "&#039;");

}


function sanitizeUrl(url: string): string {

  if (!url) return "";

  try {

    const parsed = new URL(url);

    // Only allow http/https - reject javascript:, data:, file:

    if (!["http:", "https:"].includes(parsed.protocol)) {

      return "";

    }

    return url;

  } catch {

    return "";

  }

}


// Always sanitize before rendering

const clientName = sanitizeText(client.clientName);

const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));


Content Security Policy

CSP 头可以指示浏览器拦截危险内容:

JavaScript


function buildSecurityHeaders(setCookie, nonce) {

  const cspDirectives = [

    "default-src 'none'",

    "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""),

    "style-src 'self' 'unsafe-inline'",

    "img-src 'self' https:",

    "font-src 'self'",

    "form-action 'self'",

    "frame-ancestors 'none'", // Prevent clickjacking

    "base-uri 'self'",

    "connect-src 'self'",

  ].join("; ");


  return {

    "Content-Security-Policy": cspDirectives,

    "X-Frame-Options": "DENY",

    "X-Content-Type-Options": "nosniff",

    "Content-Type": "text/html; charset=utf-8",

    "Set-Cookie": setCookie,

  };

}


TypeScript


function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit {

  const cspDirectives = [

    "default-src 'none'",

    "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""),

    "style-src 'self' 'unsafe-inline'",

    "img-src 'self' https:",

    "font-src 'self'",

    "form-action 'self'",

    "frame-ancestors 'none'", // Prevent clickjacking

    "base-uri 'self'",

    "connect-src 'self'",

  ].join("; ");


  return {

    "Content-Security-Policy": cspDirectives,

    "X-Frame-Options": "DENY",

    "X-Content-Type-Options": "nosniff",

    "Content-Type": "text/html; charset=utf-8",

    "Set-Cookie": setCookie,

  };

}


State 处理

在同意对话框与 OAuth 回调之间,你需要确保操作的是同一个用户。请使用一个存储在 KV 中、带短过期时间的 state token:

JavaScript


// Create state token before redirecting to upstream provider

async function createOAuthState(oauthReqInfo, kv) {

  const stateToken = crypto.randomUUID();

  await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {

    expirationTtl: 600, // 10 minutes

  });

  return { stateToken };

}


// Bind state to browser session with a hashed cookie

async function bindStateToSession(stateToken) {

  const encoder = new TextEncoder();

  const hashBuffer = await crypto.subtle.digest(

    "SHA-256",

    encoder.encode(stateToken),

  );

  const hashHex = Array.from(new Uint8Array(hashBuffer))

    .map((b) => b.toString(16).padStart(2, "0"))

    .join("");


  return {

    setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`,

  };

}


// Validate state in callback

async function validateOAuthState(request, kv) {

  const url = new URL(request.url);

  const stateFromQuery = url.searchParams.get("state");


  if (!stateFromQuery) {

    throw new Error("Missing state parameter");

  }


  // Check state exists in KV

  const storedData = await kv.get(`oauth:state:${stateFromQuery}`);

  if (!storedData) {

    throw new Error("Invalid or expired state");

  }


  // Validate state matches session cookie

  // ... (hash comparison logic)


  await kv.delete(`oauth:state:${stateFromQuery}`);

  return JSON.parse(storedData);

}


TypeScript


// Create state token before redirecting to upstream provider

async function createOAuthState(oauthReqInfo: AuthRequest, kv: KVNamespace) {

  const stateToken = crypto.randomUUID();

  await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), {

    expirationTtl: 600, // 10 minutes

  });

  return { stateToken };

}


// Bind state to browser session with a hashed cookie

async function bindStateToSession(stateToken: string) {

  const encoder = new TextEncoder();

  const hashBuffer = await crypto.subtle.digest(

    "SHA-256",

    encoder.encode(stateToken),

  );

  const hashHex = Array.from(new Uint8Array(hashBuffer))

    .map((b) => b.toString(16).padStart(2, "0"))

    .join("");


  return {

    setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`,

  };

}


// Validate state in callback

async function validateOAuthState(request: Request, kv: KVNamespace) {

  const url = new URL(request.url);

  const stateFromQuery = url.searchParams.get("state");


  if (!stateFromQuery) {

    throw new Error("Missing state parameter");

  }


  // Check state exists in KV

  const storedData = await kv.get(`oauth:state:${stateFromQuery}`);

  if (!storedData) {

    throw new Error("Invalid or expired state");

  }


  // Validate state matches session cookie

  // ... (hash comparison logic)


  await kv.delete(`oauth:state:${stateFromQuery}`);

  return JSON.parse(storedData);

}


为什么使用 __Host- 前缀?

__Host- 前缀可防止子域攻击,在 *.workers.dev 域名上尤其重要:

  • 必须设置 Secure 标志(仅 HTTPS)
  • 必须有 Path=/
  • 不能有 Domain 属性

如果不使用 __Host-,控制 evil.workers.dev 的攻击者可以为你的 mcp-server.workers.dev 域名设置 cookie。

多个 OAuth 流程

如果在同一个域上运行多个 OAuth 流程,请为 cookie 加上命名空间:


__Host-CSRF_TOKEN_GITHUB

__Host-CSRF_TOKEN_GOOGLE

__Host-APPROVED_CLIENTS_GITHUB

__Host-APPROVED_CLIENTS_GOOGLE


已批准客户端注册表

为每个用户维护一个已批准的客户端 ID 列表,避免反复弹出同意对话框:

JavaScript


async function addApprovedClient(request, clientId, cookieSecret) {

  const existingClients =

    (await getApprovedClientsFromCookie(request, cookieSecret)) || [];

  const updatedClients = [...new Set([...existingClients, clientId])];


  const payload = JSON.stringify(updatedClients);

  const signature = await signData(payload, cookieSecret); // HMAC-SHA256

  const cookieValue = `${signature}.${btoa(payload)}`;


  return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;

}


TypeScript


async function addApprovedClient(

  request: Request,

  clientId: string,

  cookieSecret: string,

) {

  const existingClients =

    (await getApprovedClientsFromCookie(request, cookieSecret)) || [];

  const updatedClients = [...new Set([...existingClients, clientId])];


  const payload = JSON.stringify(updatedClients);

  const signature = await signData(payload, cookieSecret); // HMAC-SHA256

  const cookieValue = `${signature}.${btoa(payload)}`;


  return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`;

}


读取该 cookie 时,先校验 HMAC 签名再信任其中的数据。如果客户端不在已批准列表中,就显示同意对话框。

安全清单

防护目的
CSRF token防止伪造的同意提交
输入清理防止同意对话框中的 XSS
CSP 头拦截被注入的脚本
State 绑定防止会话固定攻击
__Host- cookie防止子域攻击
HMAC 签名校验 cookie 完整性

后续步骤

MCP 授权 MCP 服务器的 OAuth 与认证。

构建一个远程 MCP 服务器 在 Cloudflare 上部署 MCP 服务器。

MCP 安全最佳实践 官方 MCP 规范的安全指南。