INTEGRITY Dokumentace

Zabezpečení serverů MCP

Servery MCP je, stejně jako každou webovou aplikaci, nutné zabezpečit, aby je mohli bez zneužití používat důvěryhodní uživatelé. Specifikace MCP používá pro ověřování mezi klienty a servery MCP protokol OAuth 2.1.

Tento návod popisuje osvědčené bezpečnostní postupy pro servery MCP, které fungují jako proxy OAuth pro poskytovatele třetích stran (například GitHub nebo Google).

Ochrana OAuth pomocí workers-oauth-provider

Cloudflare workers-oauth-provider ↗ zajišťuje správu tokenů, registraci klientů a validaci přístupových 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,
});

Když váš server MCP funguje jako proxy pro poskytovatele OAuth třetí strany, musíte před přesměrováním uživatelů dál implementovat vlastní souhlasné dialogové okno. Zabráníte tak problému takzvaného „zmateného zástupce“, kdy by útočníci mohli zneužít uložený souhlas.

Ochrana proti CSRF

Bez ochrany CSRF mohou útočníci uživatele napálit, aby schválili škodlivé klienty OAuth. Použijte náhodný token uložený v zabezpečeném 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`,
	};
}

Token zahrňte jako skryté pole ve svém souhlasném formuláři:

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

Sanitizace vstupu

Obsah ovládaný uživatelem (názvy klientů, loga, URI) může spustit škodlivé skripty, pokud není sanitizován:

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

Hlavičky CSP instruují prohlížeče, aby blokovaly nebezpečný obsah:

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

Zpracování stavu

Mezi souhlasným dialogovým oknem a OAuth callbackem musíte zajistit, že jde o stejného uživatele. Použijte stavový token uložený v KV s krátkou expirací:

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

Proč používat __Host- prefix?

__Host- prefix brání útokům na subdomény, což je obzvlášť důležité u *.workers.dev domén:

Bez __Host-, útočník, který ovládá evil.workers.dev by mohl nastavit cookies pro vaše mcp-server.workers.dev domény.

Více OAuth flows

Pokud na stejné doméně používáte více OAuth toků, rozlište své cookies pomocí jmenného prostoru:

__Host-CSRF_TOKEN_GITHUB
__Host-CSRF_TOKEN_GOOGLE
__Host-APPROVED_CLIENTS_GITHUB
__Host-APPROVED_CLIENTS_GOOGLE

Registr schválených klientů

Udržujte registr schválených ID klientů pro jednotlivé uživatele, abyste se vyhnuli opakovanému zobrazování souhlasného dialogového okna:

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

Při čtení cookie ověřte podpis HMAC ještě před tím, než datům budete důvěřovat. Pokud klient není na seznamu schválených, zobrazte souhlasné dialogové okno.

Kontrolní seznam zabezpečení

Ochrana Účel
Tokeny CSRF Prevence padělaných schválení souhlasu
Sanitizace vstupu Prevence XSS v souhlasných dialogových oknech
Hlavičky CSP Blokování vložených skriptů
Stavová vazba Prevence fixace relace
__Host- cookies Prevence útoků na subdomény
Podpisy HMAC Ověřte integritu cookie

Další kroky

Autorizace MCP

OAuth a autentizace pro MCP servery.

Sestavte vzdálený server MCP

Nasaďte MCP servery na Cloudflare.

Osvědčené postupy zabezpečení MCP

Oficiální bezpečnostní příručka specifikace MCP.