← Blog

Claude skill DNS rebinding security

MCP skills that run an HTTP server on a non-standard port — a local dashboard on localhost:3456, a WebSocket server on 0.0.0.0:8765, a Prometheus metrics endpoint on :9090 — are vulnerable to DNS rebinding attacks that completely bypass the browser’s same-origin policy. DNS rebinding works by registering a domain (e.g., attacker.example.com) with a very short TTL, initially pointing to the attacker’s own server. After the victim’s browser loads the attacker’s page, the attacker changes the DNS record to point to 127.0.0.1. The browser’s DNS cache expires (due to the short TTL), it re-resolves attacker.example.com to 127.0.0.1, and the browser now considers requests from the page to attacker.example.com:3456 to be same-origin with the already-loaded page. The skill’s HTTP server at 127.0.0.1:3456 receives these requests and responds — the attacker’s page can now read the response bodies, call the skill’s API, and exfiltrate any secrets stored in the skill. SA-SKL-DNS covers four DNS rebinding attack surfaces.

By SkillAudit  |  3 Nov 2026

DNS rebinding is not a theoretical attack. Browser mitigations — Private Network Access headers, DNS rebinding filters in Chrome, CORS preflight requests — provide partial protection but require specific server-side cooperation to be effective. A skill HTTP server that does not validate the Host header in incoming requests will accept requests from a DNS-rebound attacker domain as though they came from localhost. The browser’s same-origin policy cannot distinguish a legitimate localhost:3456 request from an attacker’s request that arrives at 127.0.0.1:3456 with Host: attacker.example.com — the port and address are the same. The only server-side defence is to validate that the Host header in every request matches an expected value (typically localhost or 127.0.0.1) and reject requests with unexpected host values.

SA-SKL-DNS-001: HTTP server on non-standard port without Host header validation

An MCP skill HTTP server listening on a non-standard port (anything other than 80 or 443) without Host header validation is fully vulnerable to DNS rebinding. The attack flow: (1) victim visits attacker.example.com in their browser, (2) the page sets a very short DNS TTL, (3) the attacker waits for the victim’s session to load, then changes the DNS record to point to 127.0.0.1, (4) the victim’s browser re-resolves attacker.example.com to 127.0.0.1, (5) the attacker’s JavaScript makes requests to http://attacker.example.com:3456/api/tools — the browser sends these to 127.0.0.1:3456 with Host: attacker.example.com:3456, (6) the skill server, not checking the Host header, processes the request and returns the response, (7) the attacker’s page reads the response and exfiltrates data. The countermeasure is a Host header allowlist middleware that rejects requests with unexpected host values at the earliest point in the request pipeline.

// VULNERABLE: HTTP skill server with no Host header validation
import express from 'express';
const app = express();

// Skill server on non-standard port — accessible from DNS-rebound attacker domain
app.listen(3456, '0.0.0.0', () => {
  console.log('MCP skill server running on :3456');
});

app.get('/api/tools', (req, res) => {
  // BUG: Host header not checked
  // Attacker DNS-rebinds to 127.0.0.1 → browser sends request with Host: attacker.example.com
  // Server processes it and returns tool list (including private tools, capabilities)
  res.json({ tools: listAvailableTools() });
});

// SECURE: Host header allowlist middleware
const ALLOWED_HOSTS = new Set([
  'localhost',
  '127.0.0.1',
  `localhost:${process.env.PORT ?? 3456}`,
  `127.0.0.1:${process.env.PORT ?? 3456}`,
  '::1',
  `[::1]:${process.env.PORT ?? 3456}`,
]);

function validateHost(req, res, next) {
  const host = req.headers.host;

  if (!host) {
    // Missing Host header — reject; HTTP/1.1 requires Host
    return res.status(400).json({ error: 'Missing Host header' });
  }

  // Strip port suffix for comparison if it matches our own port
  const hostWithoutPort = host.replace(/:\d+$/, '');
  const fullHost = host;

  if (!ALLOWED_HOSTS.has(host) && !ALLOWED_HOSTS.has(hostWithoutPort)) {
    // Host does not match any expected localhost variant
    // This is a DNS rebinding attempt or misconfigured proxy
    return res.status(403).json({ error: 'Forbidden: unexpected Host header' });
  }

  next();
}

// Apply Host validation as first middleware — before any route handler
app.use(validateHost);

// Also: bind to 127.0.0.1 only, not 0.0.0.0, if the skill is local-only
// 0.0.0.0 binding makes the skill accessible from the network, expanding the attack surface
app.listen(3456, '127.0.0.1', () => {
  // Only reachable from the local machine — reduces DNS rebinding risk
  console.log('MCP skill server running on 127.0.0.1:3456 (local only)');
});

// DETECTION (SkillAudit):
// app.listen() on non-standard port WITHOUT Host header validation middleware
const noDnsProtection = searchAST(
  'app.listen(port > 1024) WITHOUT validateHost middleware checking req.headers.host'
);
if (noDnsProtection) → HIGH  // SA-SKL-DNS-001

Binding to 127.0.0.1 does not prevent DNS rebinding: DNS rebinding makes the attacker’s domain resolve to 127.0.0.1. The browser then sends HTTP requests to 127.0.0.1:<port> on the victim’s machine. Even if the skill is bound to 127.0.0.1 (not 0.0.0.0), those requests reach the skill. The Host header check is the necessary additional defence: 127.0.0.1 binding prevents access from the network, but not from a DNS-rebound browser on the same machine.

HIGH
SA-SKL-DNS-001 — No Host header validation: HTTP skill server on non-standard port processes requests with any Host header value; DNS rebinding resolves attacker domain to 127.0.0.1 after TTL expires; browser considers requests to attacker domain + skill port as same-origin; attacker reads all API responses and exfiltrates skill data. Detection: app.listen(port > 1024) without Host allowlist middleware → HIGH.

SA-SKL-DNS-002: CORS wildcard origin without Host validation enables full cross-origin reads

Some skill HTTP servers set Access-Control-Allow-Origin: * to allow the MCP client application to call the skill API from a browser context. The wildcard allows any origin to read responses. Combined with DNS rebinding, the wildcard means the attacker does not even need to exploit the same-origin policy to read responses — the CORS header explicitly permits it. Even without DNS rebinding, a CORS wildcard on a skill server that accepts authentication cookies or API keys exposes all endpoints to any website the victim visits while authenticated. The correct CORS policy for a skill server is an explicit allowlist of the exact origins that need cross-origin access (the MCP client application’s origin), not a wildcard.

// VULNERABLE: CORS wildcard without Host validation
import cors from 'cors';
import express from 'express';

const app = express();

// BUG: wildcard allows any origin to read all responses
// Without DNS rebind: any website can call the skill API if user is authenticated
// With DNS rebind: attacker.example.com explicitly permitted to read all responses
app.use(cors({ origin: '*' }));

// ALSO VULNERABLE: overly broad origin list
app.use(cors({
  origin: [
    'https://claude.ai',
    'https://cursor.sh',
    'http://localhost:*',  // BUG: localhost wildcard matches attacker.localhost too
  ],
}));

// SECURE: explicit origin allowlist + Host validation (belt-and-suspenders)
const ALLOWED_ORIGINS = new Set([
  'https://claude.ai',
  'https://cursor.sh',
  'http://localhost:3000',  // local dev only — specific port
  // Add other legitimate MCP client origins as needed
]);

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (same-origin / server-to-server / curl)
    if (!origin) return callback(null, true);

    if (ALLOWED_ORIGINS.has(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`CORS: origin '${origin}' not allowed`));
    }
  },
  credentials: true,  // required for session cookies to be included
}));

// Also apply Host validation (see DNS-001) as defence-in-depth
app.use(validateHost);

// DETECTION (SkillAudit):
// cors({ origin: '*' }) on a skill HTTP server
// cors({ origin: * }) where * is a regex matching localhost:*
const corsWildcard = searchAST(
  'cors({ origin: "*" }) OR cors({ origin: /localhost/ })'
);
if (corsWildcard) → HIGH  // SA-SKL-DNS-002

CORS wildcard + credentials is blocked by browsers but not all HTTP clients: browsers block Access-Control-Allow-Origin: * when the request includes credentials (credentials: 'include'). However, non-browser HTTP clients (curl, Python requests, the MCP SDK itself) do not enforce CORS. The wildcard is still dangerous for browser contexts where the skill is visited directly, and it makes the intent of the CORS policy ambiguous. Use an explicit allowlist regardless of whether credentials are involved.

HIGH
SA-SKL-DNS-002 — CORS wildcard without Host validation: cors({ origin: '*' }) on skill HTTP server; DNS rebinding makes attacker domain resolve to skill IP; CORS header explicitly permits attacker origin to read responses; no Host header check to reject the rebounded origin. Detection: cors({ origin: '*' }) on any skill HTTP endpoint → HIGH.

SA-SKL-DNS-003: WebSocket server without Origin header validation

WebSocket connections are initiated by a browser new WebSocket(url) call. Unlike HTTP requests, the browser does not apply CORS preflight to WebSocket connections. The browser sends an Origin header in the WebSocket upgrade request, and the server is responsible for checking it. A WebSocket server that does not validate the Origin header will accept connections from any origin — including a DNS-rebound attacker domain. Once the WebSocket connection is established, the attacker’s page can send and receive messages over the skill’s WebSocket channel. In MCP skills that use WebSocket for real-time tool output streaming or bidirectional communication, this allows the attacker to call skill tools and receive their output, read in-flight audit results, or inject tool call messages. The fix is to check req.headers.origin (or upgrade.headers.origin) during the WebSocket upgrade and reject connections from unexpected origins.

// VULNERABLE: WebSocket server without Origin header check
import { WebSocketServer } from 'ws';
import http from 'http';

const server = http.createServer(app);
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  // BUG: Origin header not checked
  // DNS-rebound attacker.example.com connects — browser allows it
  // No CORS preflight for WebSocket — attacker can connect from any origin

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());
    // Attacker sends tool call messages and receives results
    handleToolCall(msg, ws);
  });
});

// SECURE: validate Origin header during WebSocket upgrade
const ALLOWED_WS_ORIGINS = new Set([
  'https://claude.ai',
  'http://localhost:3000',
  // add other trusted origins
]);

const wss = new WebSocketServer({
  server,
  // verifyClient called before upgrade is accepted — reject invalid Origins here
  verifyClient: ({ origin, req }, callback) => {
    // origin is extracted from the Upgrade request's Origin header by the ws library
    if (!origin) {
      // No origin header — likely a server-to-server connection, allow
      return callback(true);
    }

    if (ALLOWED_WS_ORIGINS.has(origin)) {
      callback(true);  // accept
    } else {
      callback(false, 403, 'Forbidden: WebSocket origin not allowed');
    }
  },
});

// Also validate Host header on the upgrade request (belt-and-suspenders):
wss.on('connection', (ws, req) => {
  const host = req.headers.host;
  if (!ALLOWED_HOSTS.has(host) && !ALLOWED_HOSTS.has(host?.replace(/:\d+$/, ''))) {
    ws.close(1008, 'Policy violation: unexpected Host header');
    return;
  }

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString());
    handleToolCall(msg, ws);
  });
});

// DETECTION (SkillAudit):
// new WebSocketServer({ server }) WITHOUT verifyClient checking origin
const noWsOrigin = searchAST(
  'new WebSocketServer(*) WITHOUT verifyClient: function checking origin'
);
if (noWsOrigin) → HIGH  // SA-SKL-DNS-003

WebSocket does not have CORS preflight: the browser sends a WebSocket upgrade request with an Origin header, but unlike XHR/fetch, there is no preflight OPTIONS request that the browser enforces before the connection. The server must read the Origin header and close the connection if it is not in the allowlist. The ws library’s verifyClient option handles this during the upgrade handshake before the connection object is created.

HIGH
SA-SKL-DNS-003 — WebSocket without Origin check: WebSocketServer accepts connections without validating the Origin header; DNS rebinding makes attacker domain resolve to skill IP; browser initiates WebSocket from attacker’s page to skill’s WS port; no CORS preflight enforced; attacker sends tool call messages and reads real-time output. Detection: WebSocketServer without verifyClient origin check → HIGH.

SA-SKL-DNS-004: internal health and metrics endpoint exposed via DNS rebinding

Skills that expose an internal health check or Prometheus metrics endpoint — typically on the same process at /health, /metrics, or a separate port like :9090 — may not apply the same authentication and Host validation to these endpoints as to the main API. A DNS rebinding attack against the main skill port exposes these endpoints too. Prometheus /metrics is particularly sensitive: it exposes all registered metric labels, which often include database query strings, upstream API endpoint paths, error messages containing secrets, and runtime environment variables logged as label values by careless metric.labels({ env: process.env.SECRET_KEY }) calls. The fix is to bind the metrics server to 127.0.0.1 only and add an IP-based allowlist middleware that rejects requests not originating from the loopback address — combined with Host validation to block DNS rebinding.

// VULNERABLE: metrics endpoint exposed without auth or IP check
import express from 'express';
import promClient from 'prom-client';

const app = express();

// BUG: /metrics served on main app port without authentication
// DNS rebinding allows attacker.example.com to read all Prometheus metrics
// Metrics may include: DB query strings, API response payloads, error messages with secrets
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.end(await promClient.register.metrics());
});

// ALSO VULNERABLE: separate metrics server on :9090 without Host validation
const metricsApp = express();
metricsApp.get('/metrics', async (req, res) => {
  // BUG: Host header not checked — DNS rebinding reaches this endpoint
  res.set('Content-Type', promClient.register.contentType);
  res.end(await promClient.register.metrics());
});
metricsApp.listen(9090, '0.0.0.0');  // BUG: 0.0.0.0 makes it network-accessible too

// SECURE: metrics server bound to 127.0.0.1 only + Host validation
import net from 'net';

const metricsApp = express();

// IP allowlist: only loopback
function requireLocalhost(req, res, next) {
  const remoteAddr = req.socket.remoteAddress;
  const isLoopback = remoteAddr === '127.0.0.1' || remoteAddr === '::1'
    || remoteAddr === '::ffff:127.0.0.1';

  if (!isLoopback) {
    return res.status(403).json({ error: 'Forbidden: metrics accessible from localhost only' });
  }

  // Also check Host header against DNS rebinding (even on loopback)
  const host = req.headers.host ?? '';
  const hostWithoutPort = host.replace(/:\d+$/, '');
  if (!ALLOWED_HOSTS.has(host) && !ALLOWED_HOSTS.has(hostWithoutPort)) {
    return res.status(403).json({ error: 'Forbidden: unexpected Host header on metrics endpoint' });
  }

  next();
}

metricsApp.get('/metrics', requireLocalhost, async (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.end(await promClient.register.metrics());
});

// Bind to 127.0.0.1 only — network-unreachable
metricsApp.listen(9090, '127.0.0.1');

// Also: scrub secrets from metric labels
// BAD:  apiCounter.labels({ apiKey: process.env.ANTHROPIC_API_KEY }).inc()
// GOOD: apiCounter.labels({ tier: 'free' }).inc()  // only non-sensitive labels

// DETECTION (SkillAudit):
// GET /metrics route WITHOUT requireLocalhost middleware checking remoteAddress
// OR metrics server listening on 0.0.0.0 without Host validation
const exposedMetrics = searchAST(
  'app.get("/metrics" | "/health", handler) WITHOUT requireLocalhost middleware'
);
if (exposedMetrics) → MEDIUM  // SA-SKL-DNS-004

Secrets in metric labels persist in time-series databases: Prometheus scrapes metrics and stores them in a TSDB (Prometheus itself, Thanos, Grafana Mimir). If a metric label ever contained a secret — even briefly — that label value is stored in the time-series database indefinitely. Sanitize metric labels before registration. Common offenders: error message labels containing exception.message (which may include SQL queries or API responses), environment labels set from process.env, and URL labels set from req.url (which may contain tokens in query strings).

MEDIUM
SA-SKL-DNS-004 — Unprotected metrics endpoint: Prometheus /metrics or /health endpoint on main app port or separate port without IP allowlist + Host validation; DNS rebinding grants attacker read access to all runtime metrics; metric labels may contain error messages, DB query strings, or secrets. Detection: app.get('/metrics') without requireLocalhost or IP check middleware → MEDIUM.

Remediation summary

FindingSeverityFix
DNS-001: No Host header validationHIGHAdd Host allowlist middleware checking req.headers.host against localhost variants; reject with 403 on unexpected host; bind to 127.0.0.1 (not 0.0.0.0) for local-only skills
DNS-002: CORS wildcard without Host validationHIGHReplace cors({ origin: '*' }) with explicit origin allowlist; add Host validation middleware; never use wildcards on endpoints that process authenticated requests
DNS-003: WebSocket without Origin checkHIGHUse verifyClient option in WebSocketServer to check origin against allowlist; also validate Host header on each WS connection event
DNS-004: Exposed metrics endpointMEDIUMBind metrics server to 127.0.0.1 only; add requireLocalhost middleware checking req.socket.remoteAddress + Host header; scrub secrets from all metric labels

SkillAudit detects all four SA-SKL-DNS-001 through DNS-004 DNS rebinding patterns through static analysis of Express server setup, CORS configuration, WebSocket upgrade handlers, and metrics endpoint definitions. For related network security patterns in MCP skills, see SA-SKL-NET network egress security and SA-SKL-CSP Content Security Policy patterns. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for DNS rebinding vulnerabilities

SkillAudit detects missing Host header validation, CORS wildcards, WebSocket Origin checks, and exposed internal endpoints across the full SA-SKL-DNS family in 60 seconds.

Get your free audit