Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

MCP サーバーの保護

最終更新 Markdown で表示Agent セットアップ

MCP サーバーは、他の Web アプリケーションと同様に、信頼できるユーザーが濫用なく使えるよう保護する必要があります。MCP 仕様は、MCP クライアントとサーバー間の認証に OAuth 2.1 を使います。

このガイドは、サードパーティプロバイダー(GitHub や Google など)への OAuth プロキシとして動く MCP サーバー向けのセキュリティベストプラクティスを扱います。

workers-oauth-provider による OAuth 保護

Cloudflare の workers-oauth-provider が、トークン管理、クライアント登録、アクセストークン検証を扱います。

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 プロバイダーへプロキシするときは、ユーザーを上流へ送る前に、独自の同意ダイアログを実装する必要があります。攻撃者がキャッシュ済み同意を悪用できる「confused deputy」(混乱した代理人)問題を防ぎます。

CSRF 保護

CSRF 保護がないと、攻撃者はユーザーをだまして悪意ある OAuth クライアントを承認させられます。セキュア Cookie に保存したランダムトークンを使います。

// 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`,
	};
}

同意フォームに、トークンを hidden フィールドとして含めます。

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

入力のサニタイズ

ユーザーが制御する内容(クライアント名、ロゴ、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));

Content Security Policy

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

状態の扱い

同意ダイアログと OAuth コールバックの間で、同じユーザーであることを保証する必要があります。短い有効期限付きで KV に保存した state トークンを使います。

// 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

承認済みクライアントレジストリ

ユーザーごとに承認済みクライアント 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 署名を検証します。クライアントが承認リストになければ、同意ダイアログを表示します。

セキュリティチェックリスト

保護 目的
CSRF トークン 偽造された同意承認を防ぎます
入力のサニタイズ 同意ダイアログでの XSS を防ぎます
CSP ヘッダー 注入スクリプトをブロックします
State のバインド セッション固定を防ぎます
__Host- Cookie サブドメイン攻撃を防ぎます
HMAC 署名 Cookie の整合性を検証します

次のステップ

MCP 認可

MCP サーバー向けの OAuth と認証です。

役に立ちましたか?