Segurança e confiança entre site e agente|INEMA.CLUB-PRO

Aula 5 · 20 min

Servidor MCP seguro: audiência, escopo mínimo, handle preso ao usuário e segredo fora do repo

O OAuth do servidor você montou no curso 1. Aqui é o outro lado: seis ataques da lista de segurança do MCP (revisão 2026-07-28) contra o mesmo servidor, escrito de dois jeitos. Você vê o que o SDK oficial barra sozinho e o que só barra se você configurar.

Começar a aula
Pequeno servidor em rack com cadeado físico e cabo de rede plugado, mesa escura e luz neutra.

Nesta aula você vai

  1. 1Host e Origin: o DNS rebinding contra servidor local
  2. 2Token só no cabeçalho, com validade e para este servidor (audiência)
  3. 3Escopo mínimo na entrada e escopo da ação só quando ela é pedida
  4. 4Sem sessão no MCP 2026-07-28: o handle de estado não é login

Objetivo: rodar 6 ataques contra um servidor MCP fraco e um seguro e separar o que o SDK barra sozinho do que depende da sua configuração.

01As cinco portas de um pedido

Um pedido ao servidor passa por estas portas, nesta ordem. Clique em cada uma.

Clique em cada bloco do diagrama para ver o que ele faz.

Conferido no código do @modelcontextprotocol/server 2.3.1 (dist/) e na página security_best_practices da revisão 2026-07-28.

02Seis ataques, servidor fraco

O mesmo servidor com os erros comuns: aceita token na URL, não confere audiência, não pede escopo na ação e usa handle sequencial sem dono. Tokens gerados na hora; nada no repositório.

Terminal · fraco
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs fraco; echo "saída: $?"
Exemplo do repositório
== @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

O ataque 1 parou no token: um servidor local sem token cairia. O 6 o SDK barra sempre. Os quatro do meio dependem de você.

03Os mesmos seis, servidor seguro

As diferenças estão marcadas com seguro no arquivo do servidor.

Terminal · seguro
cd ~/projetos/formacao-webmcp-seguranca-v72/kit
node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
Arquivo · kit/mcp/servidor.mjs
// servidor.mjs — servidor MCP por HTTP (revisão 2026-07-28, SDK oficial v2 2.3.1) em dois jeitos:
//   seguro → como a aula 5 manda;   fraco → erros comuns, para os ataques terem onde acertar.
// Serviço fictício de mentorias, dados em memória. Não chama modelo nem serviço pago.
// Os tokens NÃO ficam aqui: quem chama passa a tabela de tokens (no kit, gerada na hora com 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 aleatório e imprevisível; fraco: sequencial (r1, r2…), fácil de adivinhar
  const novoHandle = () => seguro ? randomBytes(16).toString('hex') : `r${++seq}`;
  const usuario = ctx => ctx.http?.authInfo?.extra?.usuario;       // vem do token verificado, nunca do 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 não tem sessão: ter o handle NÃO é estar autenticado. Confira o dono.
      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 escopo só aparece quando a ação é pedida (step-up); fraco: qualquer token serve
      ...(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;
  };
}

// Sobe o servidor HTTP em 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'],                                    // escopo mínimo para entrar
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(URL_MCP),
    ...(seguro && { expectedResource: URL_MCP })                          // seguro: token tem de ser PARA este servidor
  });
  const mcp = createMcpHandler(criarServidorMcp({ seguro }));
  const app = toNodeHandler({
    async fetch(request) {
      if (!seguro) {
        // ERRO COMUM: aceitar o token na URL (fica em log, histórico e Referer). A spec proí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 com WWW-Authenticate
      return mcp.fetch(request, { authInfo: auth });
    }
  });
  const hostOk = localhostHostValidation(), origemOk = localhostOriginValidation();
  http.on('request', (req, res) => {
    // seguro: recusa Host/Origin de fora (DNS rebinding) ANTES de qualquer outra coisa
    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)); } };
}
Exemplo do repositório
== @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 recusa vem de um lugar: Host (guarda do adaptador Node), token e audiência (SDK, com expectedResource), escopo (requireScopes), handle (o seu código).

04O que é de quem

1. O SDK faz

Lê o token só do cabeçalho, recusa token sem validade ou vencido e responde 403 com o escopo que falta quando a ferramenta declara requireScopes.

05Segredo fora do repositório

Um verificador mínimo (não substitui gitleaks nem o secret scanning do GitHub). O segundo comando planta uma chave falsa numa pasta de ensaio para você ver a acusação.

Terminal · segredos
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: $?"
Exemplo do repositório
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

O kit passa limpo; a pasta de ensaio acusa a chave escrita no código.

06Prática

Ataque, defesa e o seu repositório.

E13 · Servidor fraco

5 min

Rode o bloco fraco e anote quais quatro ataques passaram.

Pronto quando: passaram: 4 de 6 e saída: 1.

E14 · Servidor seguro

10 min

Rode o bloco seguro. Depois, no mcp/servidor.mjs, tire expectedResource do modo seguro e rode de novo. Desfaça no fim.

Pronto quando: passaram: 0 de 6; sem expectedResource, PASSOU 3 · token de outro recurso.

E15 · Seu repositório

10 min

Rode o verificador no repositório do seu servidor ou site.

Pronto quando: node scripts/segredos.mjs [pasta do seu repositório] → 0 segredos e saída: 0.

07Teste da aula

Pergunta 1 de 4

Um token emitido para outro serviço do mesmo provedor chega ao seu servidor. No SDK 2.3.1, o que faz ele ser recusado?competência

08Em três frases

SDKCabeçalho, validade e escopo declarado: ele faz.
VocêAudiência, Host/Origin, handle com dono e nada de passthrough.
SegredoNo ambiente, nunca no repositório; confira antes de publicar.

Para ir além

Aula anteriorPróxima aula