加固 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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);
}
Cookie 安全
为什么使用 __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 规范的安全指南。