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:
- API endpoint hijacking — a
fetch('https://api.stripe.com/...')call resolves to an attacker's IP; if the attacker also has a certificate forapi.stripe.com(via a compromised CA or a cert for a different domain), TLS validation passes - Webhook receiver spoofing — an MCP server that posts event data to a webhook URL delivers it to an attacker who has poisoned the DNS for that domain
- SSRF amplification — DNS rebinding transforms a URL validation that passes an IP blocklist check (first resolution = safe public IP) into a connection that reaches a private IP (second resolution after TTL expiry = 192.168.x.x)
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
| Provider | DoH URL | Privacy policy | Suitable for MCP servers |
|---|---|---|---|
| Cloudflare | https://cloudflare-dns.com/dns-query | Logs cleared within 24h | Yes — fast, reliable, privacy-respecting |
https://dns.google/dns-query | Temporary logs | Yes — global anycast, high availability | |
| NextDNS | https://dns.nextdns.io/{config_id} | User-controlled | Yes — with filtering and custom blocklists |
| Quad9 | https://dns.quad9.net/dns-query | No PII logging | Yes — malware domain blocking built-in |
| OS resolver (fallback) | /etc/resolv.conf | Unknown | Only 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
Run a full security audit of your MCP server at skillaudit.dev — DNS rebinding, SSRF, and 40+ additional checks in 60 seconds.