MCP Server Security

Advanced SSRF in MCP servers: beyond basic URL validation

36.7% of community MCP servers have SSRF vulnerabilities. Most developers add basic URL validation — blocking 127.0.0.1 and localhost — and consider SSRF mitigated. But DNS rebinding, open redirects, and cloud metadata endpoints bypass naive validation entirely. This guide covers the advanced patterns.

Why basic URL validation is insufficient

The typical "fix" for SSRF is a blocklist of private IP ranges checked against the URL before the request is made. This approach fails in three ways: DNS resolution happens at request time, not validation time; HTTP redirects can lead to private addresses after the initial URL passes; and cloud-specific metadata endpoints have non-standard addresses that generic IP blocklists miss.

DNS rebinding attacks

In a DNS rebinding attack, the attacker controls a domain whose DNS TTL is set to 1 second. The first DNS lookup resolves to a public IP (passes your validation). A second lookup — at request time — resolves to 169.254.169.254 or another private address. The gap between "validate the URL" and "make the request" is exploited.

// Vulnerable: validate then fetch — DNS can change between the two
async function fetchUrl(url) {
  const parsed = new URL(url)
  if (isPrivateIp(await dns.resolve(parsed.hostname))) {
    throw new Error('Private IP not allowed')
  }
  return fetch(url)  // DNS may resolve differently here
}

// Mitigation: resolve DNS once, bind the connection to that IP
import { Resolver } from 'dns/promises'
const resolver = new Resolver()

async function safeFetch(url) {
  const parsed = new URL(url)
  const [resolvedIp] = await resolver.resolve4(parsed.hostname)
  if (isPrivateIp(resolvedIp)) throw new Error('Private IP not allowed')

  // Fetch using the resolved IP directly, spoofing the Host header
  const targetUrl = new URL(url)
  targetUrl.hostname = resolvedIp
  return fetch(targetUrl.toString(), {
    headers: { Host: parsed.hostname }
  })
}

Open redirect chains

An MCP server validates the initial URL but follows redirects. A legitimate public URL redirects — via a URL-shortener, an open redirect on a trusted domain, or a misconfigured CDN — to an internal address. The validation passed because the first URL was clean.

// Vulnerable: follows redirects automatically
const response = await fetch(userUrl, { redirect: 'follow' })

// Safe: validate every hop in the redirect chain
async function safeFetchNoRedirect(url) {
  validateUrl(url)  // throws on private IP
  const response = await fetch(url, { redirect: 'manual' })
  if (response.status >= 300 && response.status < 400) {
    const location = response.headers.get('location')
    if (!location) throw new Error('Redirect with no Location header')
    validateUrl(location)           // validate the redirect target too
    return safeFetchNoRedirect(new URL(location, url).toString())
  }
  return response
}

// Or: disallow redirects entirely for security-sensitive fetches
const response = await fetch(userUrl, { redirect: 'error' })

Cloud metadata endpoint detection

AWS, GCP, and Azure all expose instance metadata at non-RFC-1918 addresses that many private-IP blocklists miss. AWS uses 169.254.169.254 (link-local), GCP uses the same plus metadata.google.internal, and Azure uses 169.254.169.254 and fd00:ec2::254. A complete blocklist must include all of these.

import ipaddr from 'ipaddr.js'

const BLOCKED_RANGES = [
  '10.0.0.0/8',
  '172.16.0.0/12',
  '192.168.0.0/16',
  '127.0.0.0/8',
  '169.254.0.0/16',    // link-local — includes AWS/GCP/Azure metadata
  '::1/128',           // IPv6 loopback
  'fc00::/7',          // IPv6 unique local
  'fe80::/10'          // IPv6 link-local
]

function isPrivateIp(ip) {
  const addr = ipaddr.parse(ip)
  return BLOCKED_RANGES.some(cidr => {
    const [range, bits] = ipaddr.parseCIDR(cidr)
    try { return addr.match(range, bits) }
    catch { return false }  // IP family mismatch — not in this range
  })
}

// Also block by hostname — DNS resolution may not catch these
const BLOCKED_HOSTNAMES = new Set([
  'localhost',
  'metadata.google.internal',
  'metadata.goog',
  '169.254.169.254'
])

function validateUrl(url) {
  const parsed = new URL(url)
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error('Only http/https allowed')
  }
  if (BLOCKED_HOSTNAMES.has(parsed.hostname)) {
    throw new Error('Blocked hostname')
  }
}

IPv6 and URL parser confusion

IPv6 URLs use bracket notation: http://[::1]/admin. Some URL validation implementations check only IPv4 private ranges and miss IPv6 loopback entirely. Additionally, URL parsers in different languages and libraries differ in how they handle embedded credentials, unicode hostnames, and non-standard ports — and these differences can be exploited to bypass hostname checks.

// Always normalise with the WHATWG URL API before validating
function normaliseUrl(raw) {
  const u = new URL(raw)  // throws on malformed URLs
  // Check both hostname (bracket-stripped for IPv6) and host
  if (u.hostname === '::1' || u.hostname === '0000:0000:0000:0000:0000:0000:0000:0001') {
    throw new Error('IPv6 loopback blocked')
  }
  return u.toString()
}

What SkillAudit flags

Check your MCP server for advanced SSRF

SkillAudit checks for DNS rebinding patterns, redirect-chain SSRF, and metadata endpoint exposure. Paste your GitHub URL for a free graded report.

Run a free audit →