← Vytvářejte agenty na Cloudflare / agents / model-context-protocol / guides
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,
});Zabezpečení souhlasového dialogu
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, "&")
.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));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
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);
}Zabezpečení cookies
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:
- Musí být nastaveno pomocí
Securepříznak (pouze HTTPS) - Musí mít
Path=/ - Nesmí mít
Domainatribut
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_GOOGLERegistr 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 |