E13 · Servidor fraco
5 minEjecuta el bloque débil y anota cuáles cuatro ataques pasaron.
Listo cuando: passaram: 4 de 6 y saída: 1.
Clase 5 · 20 min
El OAuth del servidor lo armaste en el curso 1. Aquí está el otro lado: seis ataques de la lista de seguridad de MCP (revisión 2026-07-28) contra el mismo servidor, escrito de dos formas. Ves qué bloquea el SDK oficial por sí solo y qué solo bloquea si lo configuras.
Comenzar la clase
Objetivo: ejecutar 6 ataques contra un servidor MCP débil y uno seguro, y separar lo que el SDK bloquea por sí solo de lo que depende de tu configuración.
Un pedido al servidor pasa por estas puertas, en este orden. Haz clic en cada una.
Verificado en el código de @modelcontextprotocol/server 2.3.1 (dist/) y en la página security_best_practices de la revisión 2026-07-28.
El mismo servidor con los errores comunes: acepta el token en la URL, no verifica la audiencia, no pide scope en la acción y usa un handle secuencial sin dueño. Tokens generados al momento; nada en el repositorio.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit node scripts/atacar-mcp.mjs fraco; echo "saída: $?"
== @modelcontextprotocol/server 2.3.1 · MCP 2026-07-28 · servidor: fraco bloqueado 1 · Host de fora (DNS rebinding) → HTTP 401 (o token barrou; servidor sem token cairia) PASSOU 2 · token na query string → aceito PASSOU 3 · token de outro recurso → aceito PASSOU 4 · escopo de leitura confirma agendamento → agendado: m-101 para ana PASSOU 5 · handle de outro usuário → agendado: m-102 para ana bloqueado 6 · token vencido → invalid_token: Token has expired passaram: 4 de 6 saída: 1
El ataque 1 se detuvo en el token: un servidor local sin token caería. El 6 el SDK siempre lo bloquea. Los cuatro del medio dependen de ti.
Las diferencias están marcadas con seguro en el archivo del servidor.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
// servidor.mjs — servidor MCP por HTTP (revisión 2026-07-28, SDK oficial v2 2.3.1) de dos formas:
// seguro → como lo indica la clase 5; fraco → errores comunes, para que los ataques tengan dónde acertar.
// Servicio ficticio de mentorías, datos en memoria. No llama a modelos ni a servicios de pago.
// Los tokens NO quedan aquí: quien llama pasa la tabla de tokens (en el kit, generada al momento con crypto.randomBytes).
import { createServer as criarHttp } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { McpServer, createMcpHandler, requireBearerAuth, requireScopes, getOAuthProtectedResourceMetadataUrl, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import * as z from 'zod/v4';
const MENTORIAS = [
{ id: 'm-101', titulo: 'Revisar as ferramentas da sua página', vagas: 2 },
{ id: 'm-102', titulo: 'Servidor MCP com token', vagas: 1 }
];
export function criarServidorMcp({ seguro }) {
const rascunhos = new Map(); // handle → { dono, mentoria }
let seq = 0;
// seguro: handle aleatorio e impredecible; fraco: secuencial (r1, r2…), fácil de adivinar
const novoHandle = () => seguro ? randomBytes(16).toString('hex') : `r${++seq}`;
const usuario = ctx => ctx.http?.authInfo?.extra?.usuario; // viene del token verificado, nunca del cliente
return function criar() {
const server = new McpServer({ name: 'mentorias', version: '1.0.0' });
server.registerTool('ver_mentorias', {
description: 'Lista as mentorias com vaga. Só lê.',
inputSchema: z.object({}),
annotations: { readOnlyHint: true }
}, async () => ({ content: [{ type: 'text', text: MENTORIAS.map(m => `${m.id} · ${m.titulo} · ${m.vagas} vagas`).join('\n') }] }));
server.registerTool('criar_rascunho', {
description: 'Começa um agendamento e devolve o código do rascunho, que vai nas próximas chamadas.',
inputSchema: z.object({ mentoria: z.string().regex(/^m-[0-9]{3}$/) }),
annotations: { readOnlyHint: false }
}, async ({ mentoria }, ctx) => {
const h = novoHandle();
rascunhos.set(h, { dono: usuario(ctx), mentoria });
return { content: [{ type: 'text', text: h }] };
});
server.registerTool('ver_rascunho', {
description: 'Mostra um rascunho de agendamento pelo código.',
inputSchema: z.object({ rascunho: z.string().max(64) }),
annotations: { readOnlyHint: true }
}, async ({ rascunho }, ctx) => {
const r = rascunhos.get(rascunho);
// MCP 2026-07-28 no tiene sesión: tener el handle NO es estar autenticado. Verifica el dueño.
if (!r || (seguro && r.dono !== usuario(ctx))) return { content: [{ type: 'text', text: 'rascunho não encontrado' }], isError: true };
return { content: [{ type: 'text', text: `rascunho de ${r.dono} para ${r.mentoria}` }] };
});
server.registerTool('confirmar_agendamento', {
description: 'Confirma o agendamento de um rascunho. Tem consequência: ocupa uma vaga.',
inputSchema: z.object({ rascunho: z.string().max(64) }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
// seguro: este scope solo aparece cuando se pide la acción (step-up); fraco: cualquier token sirve
...(seguro && { scopeChallenge: requireScopes('mentorias:agendar') })
}, async ({ rascunho }, ctx) => {
const r = rascunhos.get(rascunho);
if (!r || (seguro && r.dono !== usuario(ctx))) return { content: [{ type: 'text', text: 'rascunho não encontrado' }], isError: true };
rascunhos.delete(rascunho);
return { content: [{ type: 'text', text: `agendado: ${r.mentoria} para ${r.dono}` }] };
});
return server;
};
}
// Levanta el servidor HTTP en 127.0.0.1. tokens = { <token>: { usuario, escopos, recurso?, expira? } }
export async function subir({ seguro, tokens, porta = 0 }) {
const http = criarHttp();
await new Promise(ok => http.listen(porta, '127.0.0.1', ok));
const URL_MCP = new URL(`http://127.0.0.1:${http.address().port}/mcp`);
const igual = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
const verifier = {
async verifyAccessToken(token) {
const chave = Object.keys(tokens).find(k => igual(k, token));
if (!chave) throw new OAuthError(OAuthErrorCode.InvalidToken, 'token desconhecido');
const t = tokens[chave];
return { token, clientId: t.usuario, scopes: t.escopos, expiresAt: t.expira ?? Math.floor(Date.now() / 1000) + 600,
resource: new URL(t.recurso ?? URL_MCP), extra: { usuario: t.usuario } };
}
};
const portao = requireBearerAuth({
verifier,
requiredScopes: ['mentorias:ler'], // scope mínimo para entrar
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(URL_MCP),
...(seguro && { expectedResource: URL_MCP }) // seguro: el token tiene que ser PARA este servidor
});
const mcp = createMcpHandler(criarServidorMcp({ seguro }));
const app = toNodeHandler({
async fetch(request) {
if (!seguro) {
// ERROR COMÚN: aceptar el token en la URL (queda en logs, historial y Referer). La spec lo prohíbe.
const t = new URL(request.url).searchParams.get('access_token');
if (t && !request.headers.get('authorization')) {
const h = new Headers(request.headers); h.set('authorization', `Bearer ${t}`);
request = new Request(request, { headers: h });
}
}
const auth = await portao(request);
if (auth instanceof Response) return auth; // 401 / 403 con WWW-Authenticate
return mcp.fetch(request, { authInfo: auth });
}
});
const hostOk = localhostHostValidation(), origemOk = localhostOriginValidation();
http.on('request', (req, res) => {
// seguro: rechaza Host/Origin de fuera (DNS rebinding) ANTES de cualquier otra cosa
if (seguro && (!hostOk(req, res) || !origemOk(req, res))) return;
void app(req, res);
});
return { url: URL_MCP, fechar: async () => { await mcp.close?.(); await new Promise(f => http.close(f)); } };
}== @modelcontextprotocol/server 2.3.1 · MCP 2026-07-28 · servidor: seguro bloqueado 1 · Host de fora (DNS rebinding) → HTTP 403 (Host recusado antes do token) bloqueado 2 · token na query string → invalid_token: Missing Authorization header bloqueado 3 · token de outro recurso → invalid_token: Token was not issued for this resource bloqueado 4 · escopo de leitura confirma agendamento → Insufficient scope: required "mentorias:agendar" bloqueado 5 · handle de outro usuário → rascunho não encontrado bloqueado 6 · token vencido → invalid_token: Token has expired passaram: 0 de 6 saída: 0
Cada rechazo viene de un lugar: Host (guarda del adaptador Node), token y audiencia (SDK, con expectedResource), scope (requireScopes), handle (tu código).
Lee el token solo del encabezado, rechaza el token sin vigencia o vencido y responde 403 con el scope que falta cuando la tool declara requireScopes.
expectedResource (audiencia), las guardas de Host y Origin, el servidor en 127.0.0.1 y el scope por tool. Servidor local que solo usa tu agente: stdio, que ni siquiera abre un puerto.
Handle aleatorio (randomBytes) y atado al usuario del token. La spec: "MUST NOT treat possession of a state handle as authentication".
Reenviar el token del cliente a otra API (token passthrough, prohibido por la spec) ni guardar secretos en un archivo del repositorio. El cliente también guarda la credencial por emisor y no la reutiliza con otro servidor de autorización (cambio de la 2026-07-28).
Un verificador mínimo (no reemplaza a gitleaks ni al secret scanning de GitHub). El segundo comando planta una clave falsa en una carpeta de ensayo para que veas la alerta.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/segredos.mjs .; echo "saída: $?"
E=${TMPDIR:-/tmp}/ensaio-seguranca/vazou; mkdir -p $E
printf "export const API_KEY = '%s';\n" $(openssl rand -hex 16) > $E/config.mjs
node scripts/segredos.mjs $E; echo "saída: $?"70 arquivos lidos · 0 segredos saída: 0 SEGREDO config.mjs:1: segredo atribuído no código (API_KEY = 'a…) 1 arquivo lido · 1 segredo saída: 1
El kit pasa limpio; la carpeta de ensayo acusa la clave escrita en el código.
Ataque, defensa y tu repositorio.
Ejecuta el bloque débil y anota cuáles cuatro ataques pasaron.
Listo cuando: passaram: 4 de 6 y saída: 1.
Ejecuta el bloque seguro. Después, en mcp/servidor.mjs, quita expectedResource del modo seguro y ejecútalo de nuevo. Deshaz el cambio al final.
Listo cuando: passaram: 0 de 6; sin expectedResource, PASSOU 3 · token de outro recurso.
Ejecuta el verificador en el repositorio de tu servidor o sitio.
Listo cuando: node scripts/segredos.mjs [carpeta de tu repositorio] → 0 segredos y saída: 0.
Pregunta 1 de 4
Un token emitido para otro servicio del mismo proveedor llega a tu servidor. En el SDK 2.3.1, ¿qué hace que sea rechazado?competencia
Pregunta 2 de 4
Bruno manda el código del borrador de Ana y confirma la reserva. ¿Qué regla de MCP 2026-07-28 rompe esto?
Pregunta 3 de 4
¿Cuál de estas hace el SDK sin que configures nada?
Pregunta 4 de 4
¿Por qué el servidor del kit pide solo mentorias:ler para entrar?
Elige una alternativa. La explicación aparece después de tu intento.