Skip to content

fix: enforce Host allow-list on HTTP transport to block DNS rebinding#340

Merged
tianzhou merged 2 commits into
mainfrom
fix/http-dns-rebinding-host-allowlist
Jun 24, 2026
Merged

fix: enforce Host allow-list on HTTP transport to block DNS rebinding#340
tianzhou merged 2 commits into
mainfrom
fix/http-dns-rebinding-host-allowlist

Conversation

@tianzhou

Copy link
Copy Markdown
Member

Summary

The HTTP transport's DNS-rebinding guard only checked that the request Origin hostname equalled its Host hostname — self-consistency between two attacker-controlled values, not a trust decision. After DNS rebinding, a malicious page sends requests where both headers carry the attacker hostname, so the check passed, the browser reached /mcp, and could execute SQL against the configured database with no auth, no prompt injection, and no model involvement.

This replaces the equality check with an explicit Host allow-list (the actual rebinding defense) and closes three reported advisories that all share this root cause:

  • GHSA-fm8p-53ww-hf6w
  • GHSA-fp99-xwp4-hv8q
  • GHSA-qvg2-3c48-77mx

What changed

  • Validate Host against an allow-list on every request — including requests with no Origin, since a rebound same-origin POST may omit it (an Origin-gated check would miss that).
  • Origin, when present, must also be on the list — so an untrusted origin is never reflected into Access-Control-Allow-Origin.
  • Loopback is always allowed. On a wildcard bind (0.0.0.0/::), the machine's own hostname and external IPs are auto-allowed (getSelfHosts()), so local and by-IP access keep working with zero config.
  • --allowed-hosts / DBHUB_ALLOWED_HOSTS for other names (e.g. a reverse-proxy / public DNS name); "*" disables the check for users behind their own auth/proxy. Resolved list is logged at startup.

Why auto-allowing self IPs is safe

A rebound request always carries the attacker's hostname in Host (the URL-bar name), never the victim machine's own IP/name — so those entries can't match an attack. A direct cross-origin fetch to the IP still carries a foreign Origin and is rejected.

Verification (built server, --host 0.0.0.0 --demo)

Request Host Before After
Rebound evil.attacker.test (+matching Origin) 200 + reflected ACAO 403, no ACAO
Rebound host, no Origin (same-origin POST) 200 403
localhost / 127.0.0.1 200 200
Vite dev localhost:5173 → backend 200 200
Machine IPv4 / hostname (wildcard bind) 200 200 (auto-allowed)

Behavior change to note in release notes

A client reaching DBHub via a reverse-proxy or custom DNS name now needs --allowed-hosts <name> (the 403 message says exactly this). Local access, by-IP access, and the bundled workbench are unaffected.

Tests & docs

  • 30 unit tests in cross-origin.test.ts (self-host + CVE-shape cases); full unit suite green; tsc clean on changed files; build passes.
  • Docs: docs/config/command-line.mdx (new --allowed-hosts section), README.md, .env.example, CLAUDE.md.

Out of scope

Bearer-token auth for /mcp (separately tracked, issue #66). The bind default stays 0.0.0.0; the Host allow-list makes the security boundary correct regardless of bind address.

🤖 Generated with Claude Code

The HTTP transport's DNS-rebinding guard only checked that the request
Origin hostname equalled its Host hostname. That is self-consistency
between two attacker-controlled values, not a trust decision: after DNS
rebinding a malicious page sends requests where both headers carry the
attacker hostname, so the check passed and the browser reached /mcp and
could execute SQL against the configured database.

Replace the equality check with an explicit Host allow-list (the real
rebinding defense), validated on every request — including those with no
Origin, since a rebound same-origin POST may omit it. Origin, when
present, must also be on the list so an untrusted origin is never
reflected into Access-Control-Allow-Origin.

The allow-list always includes loopback. On a wildcard bind (0.0.0.0/::)
the machine's own hostname and external IPs are auto-allowed via
getSelfHosts(), so local and by-IP access keep working with no config;
this is safe because a rebound request carries the attacker's hostname,
never the victim machine's own name/IP. Operators add other names (e.g. a
reverse-proxy/public DNS name) with --allowed-hosts / DBHUB_ALLOWED_HOSTS,
or "*" to disable the check when fronted by their own auth/proxy. The
resolved list is logged at startup.

Closes GHSA-fm8p-53ww-hf6w, GHSA-fp99-xwp4-hv8q, GHSA-qvg2-3c48-77mx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 24, 2026 03:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the HTTP transport against DNS rebinding by replacing the prior OriginHost equality check with an explicit Host allow-list (and ensuring Origin, when present, is also allow-listed) to prevent browsers from reaching /mcp under attacker-controlled hostnames.

Changes:

  • Add host allow-list construction (buildAllowedHosts) with loopback defaults, optional operator overrides (--allowed-hosts / DBHUB_ALLOWED_HOSTS), and wildcard disable (*).
  • Enforce allow-list validation in the HTTP middleware for every request (including requests without Origin).
  • Add unit tests for allow-list behavior and update docs/config examples.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/utils/cross-origin.ts Implements allow-list construction, self-host discovery, and Host/Origin validation logic.
src/utils/tests/cross-origin.test.ts Adds tests for allow-list behavior, self-host discovery, and CVE-shaped cases.
src/server.ts Integrates allow-list validation into HTTP middleware and logs the active allow-list at startup.
src/config/env.ts Adds --allowed-hosts / DBHUB_ALLOWED_HOSTS parsing and validation.
README.md Documents the new DNS-rebinding protection behavior and how to unblock reverse-proxy hostnames.
docs/config/command-line.mdx Adds a dedicated --allowed-hosts section explaining behavior and usage.
CLAUDE.md Updates configuration flag documentation to include --host and --allowed-hosts.
.env.example Documents DBHUB_ALLOWED_HOSTS and how it affects Host allow-listing.

Comment thread src/utils/cross-origin.ts
Comment thread docs/config/command-line.mdx Outdated
Comment thread .env.example Outdated
Comment thread src/utils/__tests__/cross-origin.test.ts Outdated
… test

- normalizeHost(): reject crafted authority strings (e.g. "evil.com/foo",
  "evil.com@host") via INVALID_HOST_CHARS, mirroring Host-header validation
  so an operator typo cannot silently broaden the allow-list. Add a test.
- Capture getSelfHosts() once in the round-trip test (OS interface
  enumeration can change between calls → flaky).
- Docs/.env.example: list loopback IPv6 as [::1] to match the bracketed
  form the allow-list actually compares against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/server.ts:202

  • req.headers.origin is typed as string | string[] | undefined (Node header values can be arrays), but validateOrigin() expects string | undefined. This will fail tsc --strict and can also lead to reflecting an array value into Access-Control-Allow-Origin at runtime. Normalize the header to a single string (and treat non-string values as malformed) before validation and reflection.
      app.use((req, res, next) => {
        const origin = req.headers.origin;
        const result = validateOrigin(origin, req.headers.host, allowedHosts);
        if (!result.ok) {
          return res.status(result.status).json({
            error: result.status === 400 ? 'Bad Request' : 'Forbidden',
            message: result.message,
          });
        }

        // CORS headers — only reflect validated origins
        res.header('Access-Control-Allow-Origin', origin || 'http://localhost');
        res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');

@tianzhou tianzhou merged commit 5bf5c32 into main Jun 24, 2026
4 checks passed
tianzhou added a commit that referenced this pull request Jun 24, 2026
Release containing the HTTP-transport DNS-rebinding fix (#340). Merging
this to main triggers the npm trusted-publish workflow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants