Seguridad y confianza entre sitio y agente|INEMA.CLUB-PRO

Clase 5 · 20 min

Servidor MCP seguro: audiencia, scope mínimo, handle ligado al usuario y secreto fuera del repo

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
Pequeño servidor en un rack con candado físico y cable de red conectado, mesa oscura y luz neutra.

En esta clase vas a

  1. 1Host y Origin: el DNS rebinding contra un servidor local
  2. 2Token solo en el encabezado, con vencimiento y para este servidor (audiencia)
  3. 3Scope mínimo al entrar y scope de la acción solo cuando se pide
  4. 4Sin sesión en MCP 2026-07-28: el handle de estado no es un login

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.

01Las cinco puertas de un pedido

Un pedido al servidor pasa por estas puertas, en este orden. Haz clic en cada una.

Haz clic en cada bloque del diagrama para ver qué hace.

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.

02Seis ataques, servidor débil

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.

Terminal · débil
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs fraco; echo "saída: $?"
Ejemplo del repositorio
== @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.

03Los mismos seis, servidor seguro

Las diferencias están marcadas con seguro en el archivo del servidor.

Terminal · seguro
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
Archivo · kit/mcp/servidor.mjs
// 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)); } };
}
Ejemplo del repositorio
== @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).

04Qué es de quién

1. El SDK hace

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.

05Secretos fuera del repositorio

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.

Terminal · secretos
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: $?"
Ejemplo del repositorio
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.

06Práctica

Ataque, defensa y tu repositorio.

E13 · Servidor fraco

5 min

Ejecuta el bloque débil y anota cuáles cuatro ataques pasaron.

Listo cuando: passaram: 4 de 6 y saída: 1.

E14 · Servidor seguro

10 min

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.

E15 · Tu repositorio

10 min

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.

07Prueba de la clase

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

08En tres frases

SDKEncabezado, vigencia y scope declarado: eso lo hace él.
TúAudiencia, Host/Origin, handle con dueño y nada de passthrough.
SecretoEn el entorno, nunca en el repositorio; revisa antes de publicar.

Para ir más allá

Clase anteriorSiguiente clase