E13 · Servidor fraco
5 minRode o bloco fraco e anote quais quatro ataques passaram.
Pronto quando: passaram: 4 de 6 e saída: 1.
Aula 5 · 20 min
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
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.
Um pedido ao servidor passa por estas portas, nesta ordem. Clique em cada uma.
Conferido no código do @modelcontextprotocol/server 2.3.1 (dist/) e na página security_best_practices da revisão 2026-07-28.
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.
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
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ê.
As diferenças estão marcadas com seguro no arquivo do servidor.
cd ~/projetos/formacao-webmcp-seguranca-v72/kit node scripts/atacar-mcp.mjs seguro; echo "saída: $?"
// 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)); } };
}== @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).
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.
expectedResource (audiência), as guardas de Host e Origin, o servidor em 127.0.0.1 e o escopo por ferramenta. Servidor local que só o seu agente usa: stdio, que nem abre porta.
Handle aleatório (randomBytes) e preso ao usuário do token. A spec: "MUST NOT treat possession of a state handle as authentication".
Repassar o token do cliente para outra API (token passthrough, proibido pela spec) e guardar segredo em arquivo do repositório. O cliente também guarda credencial por emissor e não reaproveita com outro servidor de autorização (mudança da 2026-07-28).
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.
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
O kit passa limpo; a pasta de ensaio acusa a chave escrita no código.
Ataque, defesa e o seu repositório.
Rode o bloco fraco e anote quais quatro ataques passaram.
Pronto quando: passaram: 4 de 6 e saída: 1.
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.
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.
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
Pergunta 2 de 4
Bruno manda o código do rascunho da Ana e confirma o agendamento. Qual regra do MCP 2026-07-28 isso quebra?
Pergunta 3 de 4
Qual destas o SDK faz sem você configurar nada?
Pergunta 4 de 4
Por que o servidor do kit pede só mentorias:ler para entrar?
Escolha uma alternativa. A explicação aparece depois da sua tentativa.