Security Reference

MCP server DNS rebinding: localhost attack and host header validation defense

DNS rebinding lets an attacker control a domain whose IP address resolves first to an attacker server and then to 127.0.0.1. The browser's same-origin policy then treats the attacker's page as same-origin with your localhost MCP server — allowing arbitrary cross-origin requests to your local process.

Why MCP servers on localhost are uniquely exposed

Claude Code and other MCP clients frequently run tool servers locally on 127.0.0.1, often on a fixed port like 3000 or 8080. These servers are not accessible to the public internet — but they are accessible from any browser tab running on the same machine. DNS rebinding bridges that gap.

The attack sequence:

  1. Attacker registers rebind.evil.com with a DNS TTL of 1 second
  2. Victim visits rebind.evil.com — DNS resolves to 1.2.3.4 (attacker server)
  3. Attacker page loads JavaScript; 1 second later, DNS TTL expires
  4. Attacker's DNS server changes rebind.evil.com127.0.0.1
  5. JavaScript fetches http://rebind.evil.com:3000/mcp/tools — browser resolves to localhost
  6. Same-origin policy allows the fetch — origin is still rebind.evil.com:3000
  7. Attacker now has full read/write access to the victim's local MCP server

Real-world impact: An MCP server with file-system access (read, write, execute tools) grants an attacker performing DNS rebinding the ability to read arbitrary files, execute shell commands, or exfiltrate credentials from the victim's home directory — all from a browser tab the victim left open.

Defense 1: host header allowlist

The most reliable defense: check the Host header on every request and reject anything not on the allowlist. A DNS rebinding attack uses Host: rebind.evil.com — your allowlist will reject it.

const ALLOWED_HOSTS = new Set([
  'localhost',
  '127.0.0.1',
  '[::1]',
  // Add your production hostname here if running hosted
  process.env.MCP_HOSTNAME || ''
].filter(Boolean));

function hostGuard(req, res, next) {
  // Strip port number for comparison
  const host = (req.headers.host || '').split(':')[0].toLowerCase();

  if (!ALLOWED_HOSTS.has(host)) {
    // Log the attempt — rebinding probes are worth monitoring
    console.warn(`DNS rebinding probe? Rejected Host: ${req.headers.host}`);
    return res.status(421).json({
      error: 'misdirected_request',
      message: 'Host header not in allowlist'
    });
  }
  next();
}

// Apply before all MCP routes
app.use(hostGuard);

Defense 2: CSRF token for state-changing operations

Host header allowlists stop DNS rebinding at the HTTP level. But for MCP servers that expose a management UI or REST API alongside the JSON-RPC endpoint, CSRF protection provides a second layer that's independent of DNS:

import crypto from 'crypto';

// On server startup, generate a token and bind it to this process instance
const CSRF_TOKEN = crypto.randomBytes(32).toString('hex');

// Expose it via a GET that requires same-origin (non-credentialed cross-origin can't read this)
app.get('/mcp/csrf-token', (req, res) => {
  res.json({ token: CSRF_TOKEN });
});

// Require the token on all state-changing requests
function csrfGuard(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();

  const token = req.headers['x-csrf-token'] || req.body?._csrf;
  if (token !== CSRF_TOKEN) {
    return res.status(403).json({ error: 'invalid_csrf_token' });
  }
  next();
}

app.use(csrfGuard);

// MCP JSON-RPC endpoint — POST requires CSRF token
app.post('/mcp', csrfGuard, mcpHandler);

The MCP client (Claude Code) must fetch the CSRF token on startup and include it in subsequent requests. This is not a CSRF concern for stdio transport — only for HTTP/SSE transports.

Defense 3: bind to loopback only + document the port

An MCP server that binds to 0.0.0.0 is reachable from the local network, not just the machine. Bind to 127.0.0.1 explicitly:

// VULNERABLE: binds to all interfaces — reachable from LAN
app.listen(3000);

// SAFE: bind to loopback only
app.listen(3000, '127.0.0.1', () => {
  console.log('MCP server listening on 127.0.0.1:3000');
});

Additionally: use a high, randomized port rather than a well-known port. An attacker performing DNS rebinding must guess or enumerate your port. Port 3000 is trivially guessable; port 47381 is not. Document your port choice in package.json:config.mcpPort so legitimate clients can discover it without hardcoding it.

Defense 4: Private Network Access (PNA) headers

Chrome's Private Network Access spec adds a CORS preflight for requests from public to private networks. If your MCP server implements the required response headers, Chrome will block DNS rebinding attacks at the browser level — even before your host guard runs:

// Respond to PNA preflight
app.options('*', (req, res) => {
  if (req.headers['access-control-request-private-network']) {
    // Only allow preflights from the exact origin you control
    // Do NOT set this to '*' — that defeats the purpose
    const allowedOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000'];
    const origin = req.headers.origin;

    if (allowedOrigins.includes(origin)) {
      res.set({
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Private-Network': 'true',
        'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, X-CSRF-Token'
      });
      return res.sendStatus(204);
    }
  }
  res.sendStatus(403);
});

Browser support: PNA headers are enforced in Chrome 104+ and Edge 104+. Firefox and Safari do not yet enforce PNA. Host header allowlisting remains the cross-browser reliable defense.

SkillAudit grading criteria

FindingSeverityScore impact
HTTP transport, no host header validationHIGH−20
Server binds to 0.0.0.0 (all interfaces)HIGH−12
State-changing HTTP endpoint with no CSRF protectionMEDIUM−10
Well-known port (3000, 8080, etc.) increases guessabilityMEDIUM−5
Host header allowlist implementedPASS+8
CSRF token on state-changing routesPASS+5
PNA headers implementedPASS+5

Related SkillAudit checks