跳转到内容
搜索文档

保护 MCP 服务器

最后更新 查看 MarkdownAgent 设置

MCP 服务器与任何 Web 应用一样需要安全保护,以便受信任用户使用且不被滥用。MCP 规范在 MCP 客户端与服务器之间使用 OAuth 2.1 进行认证。

本指南涵盖作为第三方 provider(如 GitHub 或 Google)OAuth 代理的 MCP 服务器的安全最佳实践。

使用 workers-oauth-provider 的 OAuth 保护

Cloudflare 的 workers-oauth-provider 处理 token 管理、客户端注册与 access token 验证:

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,
});
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 provider 时,必须在将用户转发到上游之前实现自己的同意对话框。这可防止「confused deputy」问题——攻击者可能利用缓存的同意。

CSRF 保护

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

// 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`,
	};
}
// 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)若未清理可能执行恶意脚本:

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));
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));

内容安全策略(CSP)

CSP 头指示浏览器阻止危险内容:

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,
	};
}
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:

// 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);
}
// 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

已批准客户端注册表

维护每个用户已批准 client ID 的注册表,避免重复显示同意对话框:

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`;
}
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 签名。若 client 不在已批准列表中,显示同意对话框。

安全清单

保护措施 用途
CSRF token 防止伪造的同意批准
输入清理 防止同意对话框中的 XSS
CSP 头 阻止注入脚本
State 绑定 防止 session fixation
__Host- cookie 防止子域攻击
HMAC 签名 验证 cookie 完整性

后续步骤

这篇文档对您有帮助吗?