Security reference · DNS · SSRF

MCP server DNS caching security

DNS rebinding is a class of SSRF attack that exploits the gap between DNS resolution and TCP connection. An attacker registers a domain that initially resolves to an external IP address, passes it through an MCP tool's URL allowlist check, then changes the DNS record to an internal IP — 127.0.0.1, 169.254.169.254 (AWS metadata), or a private range. When the MCP server connects, it reaches the internal target even though the allowlist check approved the external address. This reference covers DNS rebinding variants, TTL manipulation mechanics, and the IP-level mitigations that close the attack window.

How DNS rebinding bypasses SSRF allowlists

Standard SSRF protection resolves the URL's hostname and checks the resulting IP against a blocklist of private ranges before making the request. DNS rebinding defeats this in two steps:

  1. Phase 1 (allowlist pass): The attacker controls attacker.com, which resolves to an external IP like 203.0.113.10 with a very short TTL (0 or 1 second). The MCP server's SSRF check resolves the hostname, sees a public IP, and allows the request.
  2. Phase 2 (rebind): Between the allowlist check and the actual fetch, the TTL expires. The attacker updates the DNS record to point to 127.0.0.1. The MCP server opens a new TCP connection to attacker.com — which now resolves to localhost.

The race window: Even with a 0-TTL DNS record, the rebind attack requires a race between the SSRF check resolution and the fetch resolution. In Node.js, dns.resolve() and the TCP connect in fetch() both call the OS resolver separately — there is no built-in TTL caching that would prevent re-resolution. The attack is reliably exploitable when there is any async work between the check and the fetch.

Localhost rebinding variant

The classic rebinding targets internal services on the MCP server's host or VPC. The most common target is:

The correct mitigation: resolve-then-connect with IP validation

The standard mitigation is to perform DNS resolution explicitly before the fetch, validate the resolved IP against a private-range blocklist, and then connect directly to the validated IP — bypassing the OS resolver on the actual connection. This ensures the IP checked is the IP connected to, with no rebind window between check and connect.

import dns from 'node:dns/promises';
import net from 'node:net';
import { IPCIDR } from 'ip-cidr'; // npm: ip-cidr

const BLOCKED_RANGES = [
  '0.0.0.0/8',
  '10.0.0.0/8',
  '127.0.0.0/8',
  '169.254.0.0/16',   // link-local (AWS metadata endpoint)
  '172.16.0.0/12',
  '192.168.0.0/16',
  '::1/128',          // IPv6 loopback
  'fc00::/7',         // IPv6 private
  'fe80::/10',        // IPv6 link-local
];

function isPrivateIP(ip) {
  for (const range of BLOCKED_RANGES) {
    if (new IPCIDR(range).contains(ip)) return true;
  }
  return false;
}

async function safeFetch(rawUrl, options = {}) {
  const parsed = new URL(rawUrl);
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error('FORBIDDEN_PROTOCOL: ' + parsed.protocol);
  }

  // Step 1: explicit DNS resolution
  let addresses;
  try {
    addresses = await dns.resolve(parsed.hostname); // returns IPv4/IPv6 array
  } catch {
    throw new Error('DNS_RESOLUTION_FAILED');
  }

  if (!addresses.length) throw new Error('NO_DNS_RESULT');

  // Step 2: check every resolved address against private IP ranges
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new Error('SSRF_BLOCKED: ' + addr + ' is in a private IP range');
    }
  }

  // Step 3: connect directly to the validated IP, not the hostname
  // This prevents re-resolution at connect time
  const validatedIP = addresses[0];
  const connectUrl = rawUrl.replace(parsed.hostname, net.isIPv6(validatedIP) ? '[' + validatedIP + ']' : validatedIP);

  return fetch(connectUrl, {
    ...options,
    headers: {
      ...options.headers,
      // Restore original Host header so virtual hosting works
      'Host': parsed.host,
    },
    // Additional: set a short connect timeout
    signal: AbortSignal.timeout(5000),
  });
}

IPv6 bypass: If your SSRF blocklist only checks IPv4 addresses, an attacker can register a domain that resolves only to ::1 (IPv6 loopback). Always include IPv6 private ranges in your blocklist and check dns.resolve6() separately from dns.resolve4().

DNS TTL pinning

An alternative mitigation is to implement local DNS caching that respects only a minimum TTL — never honoring TTLs below 30 seconds. This extends the window in which the checked IP matches the connected IP, reducing but not eliminating the rebind risk. This is a defense-in-depth measure, not a standalone fix:

import dns from 'node:dns/promises';

// Simple TTL-aware DNS cache — ignores TTLs < 30 seconds
const dnsCache = new Map();
const MIN_CACHE_TTL_MS = 30_000;

async function cachedResolve(hostname) {
  const cached = dnsCache.get(hostname);
  if (cached && cached.expiresAt > Date.now()) {
    return cached.addresses;
  }

  // dns.resolve with TTL option (Node 17+)
  const records = await dns.resolve4(hostname, { ttl: true });
  const addresses = records.map(r => r.address);
  const minTTL = Math.min(...records.map(r => r.ttl));
  const effectiveTTL = Math.max(minTTL * 1000, MIN_CACHE_TTL_MS);

  dnsCache.set(hostname, { addresses, expiresAt: Date.now() + effectiveTTL });
  return addresses;
}

DNS rebinding variation: browser-based rebinding via MCP web UI

If an MCP server has a web UI served on localhost, attackers can use browser-based DNS rebinding to reach it from a malicious page. The attacker's page at attacker.com makes a fetch to attacker.com:7777 (the same port as the MCP web UI). The browser enforces same-origin policy using the hostname, not the IP. After rebinding, attacker.com resolves to 127.0.0.1, and the browser's fetch succeeds because the hostname matches.

Mitigations specific to MCP web UIs:

SkillAudit findings: DNS rebinding

Critical Fetch tool handler makes outbound HTTP requests to user-controlled URLs with no IP validation — classic SSRF with DNS rebinding exposure. Score penalty: −25 points.
High IP blocklist checked on hostname resolution but fetch uses hostname directly — rebind window between check and connect. Score penalty: −15 points.
High IPv4 blocklist only — IPv6 loopback (::1) and link-local addresses (fe80::/10) not blocked. Score penalty: −10 points.
Medium No minimum DNS TTL enforcement — 0-TTL records allow rapid rebinding. Score penalty: −6 points.
Medium Admin UI bound to 0.0.0.0 — accessible from non-localhost interfaces, makes browser rebinding viable. Score penalty: −5 points.

Run a SkillAudit scan on your MCP server to detect SSRF and DNS rebinding vulnerabilities in fetch tool handlers automatically.