Servidores Seguridad

SSRF en servidores MCP: cómo proteger las tools que descargan URLs

Qué fallaba en los SSRF de Google, JPMorgan y DINUM, y cómo validar en TypeScript y Python la URL que recibe una tool MCP: IP, redirecciones y DNS rebinding.

Por

Datos a 13 de octubre de 2026 · especificación MCP 2026-07-28

Un SSRF en un servidor MCP ocurre cuando una tool descarga una URL que su dueño no eligió y el servidor acaba pidiendo algo dentro de su propia red, como un puerto interno o los metadatos de la nube. Entre junio y septiembre de 2026, Google, JPMorgan Chase y la DINUM francesa corrigieron fallos de este tipo en servidores MCP suyos. A primeros de octubre la prensa los juntó en una misma historia.

Repasamos qué fallaba en cada caso, qué trampas tiene validar una URL y cómo hacerlo en TypeScript y Python con código que hemos ejecutado. Al probarlo encontramos un error en nuestro primer borrador que bloqueaba cualquier IPv4.

Qué se corrigió entre junio y septiembre

Todos los casos los informó en privado el mismo investigador independiente, Syed Anas Mohiuddin. Google, la DINUM y el aviso de Wazuh le dan el crédito, y el PR de JPMorgan solo habla de «responsible disclosure». Los arreglos se publicaron entre el 18 de junio y el 18 de septiembre de 2026. Lo de octubre fue la difusión. El investigador publicó una recapitulación, Ars Technica la recogió el 5 de octubre y TNW el 6.

Organización Servidor y fallo Arreglo Fuente
Google MCP Toolbox for Databases. Su cliente HTTP seguía redirecciones sin validar la IP de destino Versión 1.5.0 (18 de junio). Afectadas de la 0.3.0 a la 1.4.0. CVE-2026-14540 PR #3448
JPMorgan Chase Servidor de búsqueda de documentación. read_documentation() tenía lista blanca, pero related() descargaba cualquier URL PR fusionado el 18 de septiembre. Sin CVE PR #19
DINUM (Francia) datagouv-mcp, de data.gouv.fr. get_dataservice_openapi_spec descargaba una URL del catálogo que rellena cualquier productor PR fusionado el 4 de septiembre. Sin CVE PR #126
INFOKOM-KI Wazuh-MCP-Server, tool blueteam_check_webshell. Su protección SSRF solo rechazaba IP literales Commit 2bbfe12 (2 de septiembre). Severidad alta, sin CVE GHSA-pw2j-pj4h-f5vg

El CVE de Google tiene dos notas. Google, que es la CNA que lo asignó, le da 8.0 en CVSS v4.0. La NVD le da 6.1 en CVSS v3.1. Las dos notas difieren sobre todo en la interacción del usuario, que la NVD considera necesaria, y en el impacto, que Google valora alto en confidencialidad e integridad. La prensa solo citó el 8.0 (registro CVE, NVD).

Las coberturas incluyen también a Weaviate, que corrigió el 7 de septiembre sus módulos de Google (PR #12961), pero Weaviate no es un servidor MCP. Y el vínculo del servidor de Wazuh con el ayuntamiento de Tangerang (Indonesia) solo lo afirma el investigador, porque no aparece ni en el repositorio ni en el aviso.

Por qué en un servidor MCP es peor

En una aplicación web clásica, la URL peligrosa la escribe una persona en un formulario. En MCP, el argumento de una tool lo escribe un modelo, y ese modelo lee texto de otros: páginas web, tickets, resultados de otras tools. La descripción del CVE de Google lo dice así: “An attacker or a malicious data-driven prompt can supply a crafted path parameter”.

En el caso de la DINUM, la URL ni siquiera la escribe el modelo. Sale de un campo del catálogo que puede rellenar cualquier productor registrado en data.gouv.fr. El PR del arreglo explica la consecuencia: “Before this change, mcp.data.gouv.fr issued that GET (not the attacker’s laptop).” Añade que un puerto interno abierto y uno cerrado devolvían errores distintos, “which is enough to scan the server’s network”.

Nos quedamos con la regla que Douglas McKee (Rapid7) dio a Ars Technica: “anything passed from an LLM to your tool should be treated like input from a stranger on the Internet”. Tu servidor suele vivir en una red con accesos que ese desconocido no tiene. Si la tool descarga lo que le piden, presta esos accesos.

Lo que dice la especificación 2026-07-28

La guía Security Best Practices tiene una sección sobre SSRF, pero escrita para otro escenario. Piensa en un cliente MCP que descarga las URL del descubrimiento OAuth que le indica un servidor quizá malicioso, y en los servidores de autorización que descargan metadatos de clientes. De las tools que descargan URLs, el caso de todos los fallos anteriores, no dice nada.

Aun así, los ataques que enumera son los mismos que amenazan a una tool, incluidos los metadatos en http://169.254.169.254/, el DNS rebinding y las cadenas de redirecciones. Y sus mitigaciones encajan tal cual en una tool:

  • El cliente SHOULD bloquear los rangos privados y reservados que recomienda RFC 9728, entre ellos 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, ::1, fc00::/7 y fe80::/10.
  • La misma validación SHOULD aplicarse a cada destino de una redirección, y la guía sugiere desactivar el seguimiento automático y validar cada salto.
  • Los operadores SHOULD considerar un proxy de salida, y la guía propone fijar la resolución DNS entre la comprobación y el uso.

Añade una advertencia que conviene leer dos veces: “Avoid implementing IP validation manually. Attackers exploit encoding tricks (octal, hex, IPv4-mapped IPv6) that custom parsers often miss.” Para las tools, la especificación obliga a los servidores a validar sus entradas, y nuestra lectura es que una URL recibida como argumento merece el mismo trato que las del descubrimiento OAuth.

Las trampas al validar una URL

Validar el texto y no la IP

El servidor de Wazuh hacía ipaddress.ip_address(host) y, si el host no era una IP, lo dejaba pasar con el comentario # not an IP - domain name, allowed. Bastaba un nombre que resolviera a una dirección interna. En nuestras pruebas usamos 127.0.0.1.nip.io, un nombre público que resuelve a 127.0.0.1. La comprobación tiene que hacerse sobre la IP resuelta, nunca sobre el texto de la URL.

Formatos alternativos de la misma dirección

http://2130706433:18080/, http://0x7f.1:18080/ y http://[::ffff:127.0.0.1]:18080/ apuntan todas a la máquina local. Una expresión regular sobre el texto no las reconoce. Por eso no escribimos ningún analizador propio. En TypeScript, new URL() normaliza las dos primeras a 127.0.0.1. En Python, getaddrinfo devuelve la dirección ya resuelta y el módulo ipaddress la clasifica.

Redirecciones

El fallo de Google estaba aquí. El cliente HTTP validaba la entrada pero seguía cualquier redirección del backend, así que una respuesta 302 bastaba para mandarlo a una dirección interna. En las pruebas lo reproducimos con una URL pública de httpbin.org que responde 302 hacia 127.0.0.1. La solución es desactivar el seguimiento automático y validar cada salto como si fuera la primera URL.

DNS rebinding

Entre que validas un nombre y que el cliente HTTP conecta, ese nombre puede resolver a otra IP. El PR de la DINUM lo resume así: “Checking the URL string before requests.get is not enough: DNS can rebind between check and connect, and a 302 can still land on an internal target.”

Google lo cierra validando en el Dialer de Go la dirección con la que de verdad se abre el socket. La DINUM valida “at connect time (same resolution as the socket)”, y el servidor de Wazuh pasa a curl la IP ya validada con --resolve.

El rango que bloqueaba todo IPv4

Nuestro primer borrador en TypeScript añadía ::ffff:0:0/96, el rango de las IPv4 mapeadas en IPv6, a la lista de bloqueo de Node. Con esa regla, https://example.com/ devolvía Destino bloqueado: 104.20.23.154.

La razón es que net.BlockList compara las direcciones IPv4 con las reglas IPv4 mapeadas, y al revés. Esa regla equivale a bloquear todo IPv4. Y sobra, porque las reglas IPv4 ya cubren las direcciones mapeadas:

BlockList con ::ffff:0:0/96 bloquea 104.20.23.154 (ipv4): true
BlockList con 127.0.0.0/8 bloquea ::ffff:127.0.0.1 (ipv6): true

Importante: un fallo así no se ve si solo pruebas que las direcciones malas quedan bloqueadas. Incluye siempre una descarga pública que tenga que funcionar.

Implementación en TypeScript

Lo probamos con Node 24.12.0, @modelcontextprotocol/server 2.3.1, undici 8.11.2 y zod 4.6.7, y el cliente de prueba negoció 2026-07-28. Si partes de cero, el montaje del servidor está en Crear un servidor MCP en TypeScript.

Trasladamos a Node la idea de Google. La opción connect del Agent de undici acepta una función lookup propia, y si la validación vive ahí comprueba la misma resolución que usa la conexión, así que el DNS rebinding no tiene ventana. Las IP literales no pasan por lookup y se validan antes. Las redirecciones se siguen a mano, como mucho tres, y la respuesta se corta a 200 000 bytes.

import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { BlockList, isIP } from 'node:net';
import { lookup, type LookupAddress } from 'node:dns';
import { Agent, fetch } from 'undici';

// Rangos que una tool nunca debe alcanzar
const bloqueadas = new BlockList();
for (const [red, bits] of [
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
  ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
  ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4],
] as const) bloqueadas.addSubnet(red, bits, 'ipv4');
for (const [red, bits] of [
  ['::', 127], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
] as const) bloqueadas.addSubnet(red, bits, 'ipv6');

const esBloqueada = (ip: string) => bloqueadas.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');

// Se ejecuta en cada conexión: valida la IP que de verdad se va a usar
function lookupSeguro(host: string, opts: any, cb: any) {
  lookup(host, { family: opts?.family ?? 0, hints: opts?.hints, all: true }, (err, lista: LookupAddress[]) => {
    if (err) return cb(err);
    const mala = lista.find((d) => esBloqueada(d.address));
    if (mala) return cb(new Error(`Destino bloqueado: ${mala.address}`));
    if (opts?.all) return cb(null, lista);
    cb(null, lista[0].address, lista[0].family);
  });
}

const agente = new Agent({ connect: { lookup: lookupSeguro } });
const MAX_BYTES = 200_000;
const MAX_SALTOS = 3;

async function descargarSeguro(texto: string): Promise<string> {
  let url = new URL(texto); // normaliza 2130706433 y 0x7f.1 a 127.0.0.1
  for (let salto = 0; salto <= MAX_SALTOS; salto++) {
    if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Solo http y https');
    if (url.username || url.password) throw new Error('Sin credenciales en la URL');
    const host = url.hostname.replace(/^\[|\]$/g, '');
    // Las IP literales no pasan por lookup: se validan aquí
    if (isIP(host) && esBloqueada(host)) throw new Error(`Destino bloqueado: ${host}`);

    const res = await fetch(url, {
      dispatcher: agente,
      redirect: 'manual',
      signal: AbortSignal.timeout(5_000),
    });
    if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
      await res.body?.cancel();
      url = new URL(res.headers.get('location')!, url); // el siguiente salto se valida igual
      continue;
    }
    const trozos: Uint8Array[] = [];
    let total = 0;
    for await (const trozo of res.body ?? []) {
      total += trozo.length;
      trozos.push(trozo);
      if (total >= MAX_BYTES) break;
    }
    return Buffer.concat(trozos).subarray(0, MAX_BYTES).toString('utf8');
  }
  throw new Error('Demasiadas redirecciones');
}

function crearServidor() {
  const server = new McpServer({ name: 'lector-seguro', version: '1.0.0' });

  server.registerTool(
    'leer_url',
    {
      description: 'Descarga una página pública (http o https) y devuelve su texto',
      inputSchema: z.object({ url: z.string().url() }),
      annotations: { readOnlyHint: true, openWorldHint: true },
    },
    async ({ url }) => {
      try {
        return { content: [{ type: 'text', text: await descargarSeguro(url) }] };
      } catch (e: any) {
        // undici envuelve el error de conexión en `cause`
        return { isError: true, content: [{ type: 'text', text: e?.cause?.message ?? e?.message ?? String(e) }] };
      }
    },
  );

  return server;
}

serveStdio(crearServidor);

El servidor arranca con serveStdio(crearServidor) porque, con server.connect(new StdioServerTransport()), nuestro cliente de prueba negociaba 2025-11-25 en lugar de 2026-07-28.

Implementación en Python

La versión de Python la ejecutamos con Python 3.13 y mcp 2.3.0, que depende de httpx2 (instalamos la 2.13.1) y ya no trae httpx. El montaje básico de un servidor está en Crear un servidor MCP en Python.

Para clasificar direcciones usamos ipaddress.ip_address(...).is_global, que es falso para las privadas, el loopback, el link-local (incluido 169.254.169.254) y 100.64.0.0/10. Python 3.13 corrigió algunos falsos positivos y negativos de esa propiedad. Los errores se lanzan como ToolError porque, en el SDK de Python, otras excepciones llegan al modelo sin detalle.

import asyncio
import ipaddress
import socket
from urllib.parse import urljoin, urlsplit

import httpx2
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

MAX_BYTES = 200_000
MAX_SALTOS = 3
mcp = MCPServer("lector-seguro", version="1.0.0")


def ip_permitida(texto: str) -> bool:
    ip = ipaddress.ip_address(texto.split("%")[0])
    if ip.version == 6 and ip.ipv4_mapped:
        ip = ip.ipv4_mapped
    return ip.is_global and not ip.is_multicast


async def validar_url(url: str) -> None:
    partes = urlsplit(url)
    if partes.scheme not in ("http", "https") or not partes.hostname:
        raise ToolError("Solo URLs http o https con host")
    if partes.username or partes.password:
        raise ToolError("Sin credenciales en la URL")
    puerto = partes.port or (443 if partes.scheme == "https" else 80)
    try:
        infos = await asyncio.get_running_loop().getaddrinfo(
            partes.hostname, puerto, type=socket.SOCK_STREAM
        )
    except socket.gaierror as e:
        raise ToolError(f"No se pudo resolver {partes.hostname}") from e
    for *_, direccion in infos:
        if not ip_permitida(direccion[0]):
            raise ToolError(f"Destino bloqueado: {direccion[0]}")


@mcp.tool()
async def leer_url(url: str) -> str:
    """Descarga una página pública (http o https) y devuelve su texto."""
    async with httpx2.AsyncClient(follow_redirects=False, timeout=5) as cliente:
        for _ in range(MAX_SALTOS + 1):
            await validar_url(url)
            async with cliente.stream("GET", url) as res:
                if res.is_redirect:
                    # el siguiente salto se valida igual que el primero
                    url = urljoin(url, res.headers["location"])
                    continue
                datos = b""
                async for trozo in res.aiter_bytes():
                    datos += trozo
                    if len(datos) >= MAX_BYTES:
                        break
                return datos[:MAX_BYTES].decode("utf-8", errors="replace")
    raise ToolError("Demasiadas redirecciones")


if __name__ == "__main__":
    mcp.run()

Importante: esta versión valida antes de conectar, y httpx2 vuelve a resolver el nombre al abrir la conexión. Entre las dos resoluciones queda la ventana del DNS rebinding, así que no la cubre. El servidor de JPMorgan tiene la misma estructura, pero su barrera principal es una lista blanca de un solo dominio.

Si tu tool acepta dominios arbitrarios, cierra esa ventana fuera del proceso, con un proxy de salida o una política de red. Validar al conectar también es posible en Python, como hace el arreglo de la DINUM.

Cómo probarlo

Montamos un servidor HTTP «interno» en 127.0.0.1 que responde SECRETO-INTERNO y un cliente MCP que llama a leer_url con cada caso de ataque, más una página pública que sí tiene que descargarse. El DNS rebinding no está entre los casos. Esta es la salida literal del servidor TypeScript:

versión: 2026-07-28
ERROR  http://127.0.0.1:18080/                       → Destino bloqueado: 127.0.0.1
ERROR  http://2130706433:18080/                      → Destino bloqueado: 127.0.0.1
ERROR  http://0x7f.1:18080/                          → Destino bloqueado: 127.0.0.1
ERROR  http://[::1]/                                 → Destino bloqueado: ::1
ERROR  http://[::ffff:127.0.0.1]:18080/              → Destino bloqueado: ::ffff:7f00:1
ERROR  http://169.254.169.254/latest/meta-data/      → Destino bloqueado: 169.254.169.254
ERROR  http://localhost:18080/                       → Destino bloqueado: ::1
ERROR  file:///etc/passwd                            → Solo http y https
ERROR  http://user:pw@example.com/                   → Sin credenciales en la URL
ERROR  http://127.0.0.1.nip.io:18080/                → Destino bloqueado: 127.0.0.1
ERROR  https://httpbin.org/redirect-to?url=http%3A%2F%2F127.0.0.1%3A18080%2F → Destino bloqueado: 127.0.0.1
OK     https://example.com/                          → <!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon 

La versión de Python da los mismos veredictos, con el servidor interno en el puerto 18090. Cambian los textos. El SDK antepone Error executing tool leer_url: al mensaje, file:///etc/passwd da Solo URLs http o https con host y la IPv4 mapeada aparece como ::ffff:127.0.0.1. Ninguno de los dos clientes llegó a leer SECRETO-INTERNO.

Te recomendamos convertir esta lista en tests de tu servidor, como los de dominio y de protocolo de la lección Testing y depuración. Tres casos dependen de servicios externos (nip.io, httpbin.org y example.com), así que necesitan red.

Defensa fuera del código

La validación en la tool es una capa, y las siguientes no dependen de que el código esté bien. Si sabes qué dominios necesita la tool, usa una lista blanca. Es lo que hizo JPMorgan, que solo admite developer.payments.jpmorgan.com. Con una lista blanca, el atacante ya no elige el dominio, y un rebinding solo podría venir de quien lo controla.

Si la tool acepta cualquier dominio, pon un proxy de salida, como pide considerar la especificación (cita Smokescreen), o una política de red que impida al proceso alcanzar rangos internos. El mantenedor de playwright-mcp respondió en esa línea a un informe del mismo investigador: “We recommend that the server uses sandbox with configurable network access for sensitive scenarios.”

En AWS, exige IMDSv2, que pide un token con un PUT a http://169.254.169.254/latest/api/token y responde 401 a las lecturas sin él. Por defecto la instancia acepta IMDSv1 e IMDSv2, así que tienes que exigirlo tú (documentación de AWS). Nuestro razonamiento es este: un SSRF que solo puede hacer GET, como el de la DINUM o el de Google, no consigue el token, mientras que con IMDSv1 activo lee los metadatos sin más. La dirección IPv6 del servicio, fd00:ec2::254, cae dentro de fc00::/7, que ya está en la lista de bloqueo.

Si usas la tool fetch de los servidores de referencia de MCP, ten en cuenta que la issue que agrupa sus informes de SSRF, la #3741, sigue abierta.

El marco del «protocol pivoting»

La prensa contó estos fallos bajo el nombre de protocol pivoting, un término del investigador. Según su texto, un atacante mete en la salida de una tool MCP una tarea A2A, el orquestador la delega a un subagente y, si este tiene un servidor MCP con SSRF, la petición sale desde dentro de la red. Él mismo escribe que el protocol pivoting “does not depend on SSRF”.

Ninguno de los avisos y PR de la tabla menciona A2A: todos describen un SSRF clásico, y estos casos no demuestran la cadena entre protocolos. Ars Technica recogió una opinión contraria, la de Markus Vervier (X41 D-Sec): “For me this is indirect prompt injection.” Qué hace cada protocolo lo explicamos en A2A frente a MCP.

El investigador lo presentará en MCPCon North America, en San José (California), el viernes 23 de octubre de 2026, con la charla «Protocol Pivoting: How SSRF in MCP Servers Enables Cross-Protocol Lateral Movement» (agenda).

Checklist para una tool que descarga URLs

  • Solo admite http y https, y rechaza las URL con usuario o contraseña.
  • Valida la IP resuelta, nunca el texto del host, y bloquea los rangos privados, reservados, de loopback, link-local y multicast.
  • Sigue las redirecciones a mano, con un máximo de saltos, y valida cada destino como el primero.
  • Valida en el momento de conectar, o cierra la ventana del DNS rebinding con un proxy de salida o una política de red.
  • Limita el tiempo de espera y el tamaño de la respuesta.
  • Entre tus tests hay al menos una descarga pública que debe funcionar.
  • En AWS, la instancia exige IMDSv2.

El resto de avisos de seguridad del periodo, de los SDK oficiales a otros servidores, está en Seguridad en servidores MCP: lo que enseñan dos meses de avisos. En el curso, la lección Seguridad avanzada del nivel Experto trata el SSRF y el DNS rebinding con la URL de entrega de webhooks de MCP Events, una extensión en borrador.

Aprende esto en el curso

La lección 1 la puedes leer ya, sin registro. Las otras 13 del Básico son gratis con una cuenta.

Empieza gratis

Fuentes

  1. CVE-2026-14540 (registro CVE) (2026-07-31)
  2. CVE-2026-14540 (NVD)
  3. MCP Toolbox for Databases: PR #3448, SSRF guard (2026-06-18)
  4. MCP Toolbox for Databases v1.5.0 (2026-06-18)
  5. JPMorgan Payments AI: PR #19, domain allowlist in related() (2026-09-18)
  6. datagouv-mcp: PR #126, harden SSRF on external APIs (2026-09-04)
  7. Wazuh-MCP-Server: GHSA-pw2j-pj4h-f5vg (2026-09-03)
  8. Weaviate: PR #12961 (2026-09-07)
  9. Especificación MCP 2026-07-28: Security Best Practices
  10. Node.js: net (BlockList, lookup)
  11. undici: Client (opción connect)
  12. Python: ipaddress
  13. AWS: Instance Metadata Service
  14. AGNTCon + MCPCon North America: agenda
  15. Protocol Pivoting, four months later (Syed Anas Mohiuddin)
  16. Google, JPMorgan and two governments fixed the same MCP flaw (TNW) (2026-10-06)
  17. MCP for agent-to-agent comms may be the riskiest protocol you've never heard of (Ars Technica) (2026-10-05)

Artículos relacionados