INTEGRITY Документация

Защита серверов MCP

MCP-серверы, как и любое веб-приложение, нужно защищать, чтобы ими могли безопасно пользоваться доверенные пользователи. Спецификация MCP использует OAuth 2.1 для аутентификации между MCP-клиентами и MCP-серверами.

Это руководство описывает передовые практики безопасности для MCP-серверов, которые работают как OAuth-прокси для сторонних провайдеров (например GitHub или Google).

Защита OAuth с помощью workers-oauth-provider

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

Добавьте токен как скрытое поле в форму согласия:

<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 callback нужно убедиться, что это один и тот же пользователь. Используйте токен состояния, сохранённый в KV с коротким сроком действия:

// 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 домены:

Без __Host-, злоумышленник, контролирующий evil.workers.dev мог бы устанавливать cookies для вашего mcp-server.workers.dev домен.

Несколько потоков OAuth

Если на одном домене используется несколько потоков OAuth, разделяйте cookies по пространствам имён:

__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 Блокировка внедрённых скриптов
Привязка состояния Предотвращение фиксации сессии
__Host- файлы cookie Предотвращение атак на поддомены
Подписи HMAC Проверьте целостность cookie

Следующие шаги

MCP-авторизация

OAuth и аутентификация для MCP-серверов.

Создайте удалённый MCP-сервер

Разверните MCP-серверы на Cloudflare.

Рекомендации по безопасности MCP

Официальное руководство по безопасности спецификации MCP.