Security reference · DNS · Transport

MCP server DNS over HTTPS security

MCP servers that make outbound network requests — fetching URLs, calling APIs, resolving webhook targets — trust their OS DNS resolver to return the correct IP address. If that resolver is compromised via cache poisoning, DNS hijacking, or MITM on the UDP query, tool calls are silently redirected to attacker-controlled infrastructure, even when TLS is in use. DNS-over-HTTPS (DoH) encrypts and authenticates DNS queries, preventing interception and spoofing. This reference covers DoH configuration in Node.js, resolver pinning, IP validation after resolution, and TTL trust analysis.

The DNS hijacking attack surface for MCP servers

Standard DNS uses plaintext UDP on port 53. Queries and responses can be intercepted, modified, or spoofed by any network-adjacent attacker — a malicious router on the same network segment, a compromised ISP, or an attacker who has poisoned the resolver's cache. For MCP servers, this means:

DNS rebinding as SSRF bypass: A URL validator resolves a hostname once to check it against an IP blocklist. The attacker controls the DNS TTL — they serve a public IP for the first resolution (passing the check), then immediately after the short TTL expires, serve a private IP. The actual fetch() goes to the private IP. This is the TOCTOU in URL validation that makes hostname blocklists insufficient without IP pinning.

Configuring DNS-over-HTTPS in Node.js

Node.js 18+ supports DoH via the dns.setDefaultResultOrder and --dns-result-order flag, but for per-query DoH configuration you need to use a DoH client library or configure the system resolver. The most portable approach is using the dns module's custom resolver:

// doh-resolver.js — DNS-over-HTTPS query implementation
import https from 'https';

// RFC 8484 DoH: POST to /dns-query with application/dns-message body
// Using Cloudflare (1.1.1.1) or Google (8.8.8.8) as trusted DoH providers
async function resolveDoH(hostname, type = 'A') {
  const query = buildDnsQuery(hostname, type);

  const response = await fetch('https://cloudflare-dns.com/dns-query', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/dns-message',
      'Accept': 'application/dns-message',
    },
    body: query,
  });

  if (!response.ok) throw new Error(`DoH query failed: ${response.status}`);
  const buffer = await response.arrayBuffer();
  return parseDnsResponse(new Uint8Array(buffer));
}

// For most Node.js MCP servers, the practical approach is to use a DoH HTTP client
// npm install dns-over-http-resolver
import { Resolver } from 'dns-over-http-resolver';

const dohResolver = new Resolver();
dohResolver.setServers([
  'https://cloudflare-dns.com/dns-query',
  'https://dns.google/dns-query',
]);

// Use the DoH resolver for all outbound hostname resolution
export async function resolveHostname(hostname) {
  const addresses = await dohResolver.resolve4(hostname);
  return addresses;
}

IP validation after DoH resolution

DoH prevents DNS spoofing, but SSRF still requires validating the resolved IP before connecting. Resolve once via DoH, validate the IP against a blocklist, then connect to that IP directly (bypassing any second DNS lookup):

import { resolveHostname } from './doh-resolver.js';
import { isIP } from 'net';

// IP ranges that MCP server tool calls must never reach
const BLOCKED_CIDRS = [
  { start: '10.0.0.0', prefix: 8 },        // RFC 1918 private
  { start: '172.16.0.0', prefix: 12 },      // RFC 1918 private
  { start: '192.168.0.0', prefix: 16 },     // RFC 1918 private
  { start: '127.0.0.0', prefix: 8 },        // loopback
  { start: '169.254.0.0', prefix: 16 },     // link-local (cloud metadata)
  { start: '100.64.0.0', prefix: 10 },      // shared address space
  { start: '::1', prefix: 128 },            // IPv6 loopback
  { start: 'fc00::', prefix: 7 },           // IPv6 unique local
];

function ipToInt(ip) {
  return ip.split('.').reduce((acc, oct) => (acc << 8) | parseInt(oct), 0) >>> 0;
}

function isBlockedIp(ip) {
  const ipInt = ipToInt(ip);
  return BLOCKED_CIDRS.some(({ start, prefix }) => {
    const maskBits = 32 - prefix;
    const startInt = ipToInt(start) >>> maskBits;
    const ipShifted = ipInt >>> maskBits;
    return startInt === ipShifted;
  });
}

export async function safeFetch(url, options = {}) {
  const parsed = new URL(url);
  const hostname = parsed.hostname;

  // Skip resolution if hostname is already an IP
  if (isIP(hostname)) {
    if (isBlockedIp(hostname)) throw new Error(`Blocked IP address: ${hostname}`);
    return fetch(url, options);
  }

  // Resolve via DoH and validate before connecting
  const addresses = await resolveHostname(hostname);
  if (!addresses || addresses.length === 0) throw new Error(`DNS resolution failed for: ${hostname}`);

  for (const addr of addresses) {
    if (isBlockedIp(addr)) {
      throw new Error(`Resolved IP ${addr} for ${hostname} is in a blocked range (SSRF prevention)`);
    }
  }

  // Connect to the validated resolved IP directly to prevent rebinding
  const resolvedUrl = new URL(url);
  resolvedUrl.hostname = addresses[0];

  return fetch(resolvedUrl.toString(), {
    ...options,
    headers: {
      ...options.headers,
      'Host': hostname, // Restore original Host header for virtual hosting
    },
  });
}

Connect to the resolved IP, not the hostname again. If you validate the resolved IP but then call fetch(originalUrl), the runtime performs a second DNS lookup that may return a different IP (rebinding attack). Always connect to the IP address you validated, with the original hostname in the Host header for virtual hosting compatibility.

DoH provider selection and failover

ProviderDoH URLPrivacy policySuitable for MCP servers
Cloudflarehttps://cloudflare-dns.com/dns-queryLogs cleared within 24hYes — fast, reliable, privacy-respecting
Googlehttps://dns.google/dns-queryTemporary logsYes — global anycast, high availability
NextDNShttps://dns.nextdns.io/{config_id}User-controlledYes — with filtering and custom blocklists
Quad9https://dns.quad9.net/dns-queryNo PII loggingYes — malware domain blocking built-in
OS resolver (fallback)/etc/resolv.confUnknownOnly if DoH unavailable — plaintext UDP

Configure multiple DoH providers with failover to handle DoH provider outages without falling back to plaintext DNS:

// doh-with-failover.js
const DOH_PROVIDERS = [
  'https://cloudflare-dns.com/dns-query',
  'https://dns.google/dns-query',
  'https://dns.quad9.net/dns-query',
];

export async function resolveWithFailover(hostname) {
  const errors = [];
  for (const provider of DOH_PROVIDERS) {
    try {
      const resolver = new Resolver();
      resolver.setServers([provider]);
      const addrs = await resolver.resolve4(hostname);
      if (addrs.length > 0) return addrs;
    } catch (err) {
      errors.push(`${provider}: ${err.message}`);
    }
  }
  // Never fall back to plaintext DNS — throw instead
  throw new Error(`All DoH providers failed for ${hostname}: ${errors.join('; ')}`);
}

TTL trust and DNS pinning

DNS TTL is the time-to-live for a cached DNS record. An attacker who controls a domain can set TTL=0 (or TTL=1 second) to force re-resolution on every connection, enabling DNS rebinding. The defense is to ignore TTL and pin the resolved IP for a minimum duration you control:

// dns-pinning-cache.js
const MIN_PIN_DURATION_MS = 60_000;  // ignore TTLs below 60 seconds
const MAX_PIN_DURATION_MS = 300_000; // re-resolve after 5 minutes even with high TTL

const dnsCache = new Map(); // hostname → { addresses, expiresAt }

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

  const { addresses, ttl } = await resolveDoHWithTTL(hostname);
  const pinDuration = Math.max(MIN_PIN_DURATION_MS, Math.min(ttl * 1000, MAX_PIN_DURATION_MS));

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

  return addresses;
}

SkillAudit findings for DNS security

CRITICAL −20 SSRF-vulnerable tool uses hostname-based URL validation without post-resolution IP validation — DNS rebinding bypasses the hostname check and reaches private IP ranges.
HIGH −16 No DoH configuration on a server that handles sensitive outbound requests — DNS queries for API endpoints are sent in plaintext and vulnerable to interception.
HIGH −14 URL validation resolves hostname for blocklist check but connects using the original hostname string — second DNS lookup can return a different IP (DNS rebinding).
MEDIUM −10 No TTL pinning — short-TTL DNS records from attacker-controlled domains enable rapid rebinding between connections.
MEDIUM −8 Single DoH provider with no failover — DoH provider outage causes fallback to OS plaintext resolver.

Run a full security audit of your MCP server at skillaudit.dev — DNS rebinding, SSRF, and 40+ additional checks in 60 seconds.