diff --git a/.env.example b/.env.example index 2b4bfeee..9cb1ec91 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,15 @@ PORT=8080 # HOST env var that some shells and CI systems set automatically. # DBHUB_HOST=0.0.0.0 +# Allowed Host header values for the HTTP transport (DNS-rebinding protection). +# Loopback (localhost, 127.0.0.1, [::1]) is always allowed, and when bound to a +# wildcard address this machine's hostname and IPs are added automatically — so +# local and by-IP access work with no config. Set this only for OTHER names +# that resolve to DBHub, e.g. a reverse-proxy/public DNS name (comma-separated), +# or clients using that Host will get 403. +# Use "*" to disable the check entirely (only behind your own auth/proxy). +# DBHUB_ALLOWED_HOSTS=dbhub.example.com,db.internal + # SSH Tunnel Configuration (optional) # Use these settings to connect through an SSH bastion host # SSH_HOST=bastion.example.com diff --git a/CLAUDE.md b/CLAUDE.md index 2bb83db4..cacc03c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,8 @@ DBHub supports three configuration methods (in priority order): - `--dsn`: Database connection string - `--transport`: `stdio` (default) or `http` for streamable HTTP transport (endpoint: `/mcp`) - `--port`: HTTP server port (default: 8080) +- `--host`: HTTP bind host (default: `0.0.0.0`; env `DBHUB_HOST`) +- `--allowed-hosts`: Comma-separated extra hostnames accepted in the HTTP `Host`/`Origin` headers, for DNS-rebinding protection (env `DBHUB_ALLOWED_HOSTS`). Loopback is always allowed; on a wildcard bind (`0.0.0.0`/`::`) this machine's hostname and IPs are auto-allowed so local/by-IP access needs no config. Set the flag for other names (e.g. a reverse-proxy/public DNS name); use `*` to disable the check when fronted by your own auth/proxy. See `buildAllowedHosts`/`getSelfHosts` in `src/utils/cross-origin.ts`. - `--config`: Path to TOML configuration file - `--demo`: Use bundled SQLite employee database - `--readonly`: Restrict to read-only SQL operations (deprecated - use TOML configuration instead) diff --git a/README.md b/README.md index 1e39e24d..b33cbfd7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo ``` > The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients. +> +> The HTTP transport also has built-in DNS-rebinding protection: it only accepts requests whose `Host` is loopback, this machine's own hostname/IPs, or a name you allow via [`--allowed-hosts`](https://dbhub.ai/config/command-line#allowed-hosts). If a client behind a reverse proxy or custom DNS name gets a `403`, add that hostname with `--allowed-hosts`. See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters. diff --git a/docs/config/command-line.mdx b/docs/config/command-line.mdx index f1cc135d..0b935dc8 100644 --- a/docs/config/command-line.mdx +++ b/docs/config/command-line.mdx @@ -71,6 +71,46 @@ Command-line flags are passed when starting DBHub. These have the highest priori +### --allowed-hosts + + + Comma-separated list of additional hostnames the HTTP transport accepts in the + `Host` (and `Origin`) headers. This is DBHub's **DNS-rebinding protection**: + requests whose `Host` is not on the list are rejected with `403`, so a + malicious web page cannot rebind a hostname to your DBHub instance and drive + its MCP tools from the victim's browser. Only used when `--transport=http`. + + The list always includes loopback (`localhost`, `127.0.0.1`, `[::1]`). When + bound to a wildcard address (the default `0.0.0.0` / `::`), this machine's own + hostname and external IP addresses are added automatically, so reaching DBHub + by IP or machine name works without any extra configuration. You only need + this flag for **other** names that resolve to DBHub — most commonly a + reverse-proxy or public DNS name. + + ```bash + # Serve behind a public/reverse-proxy hostname + npx @bytebase/dbhub@latest --transport http --allowed-hosts "dbhub.example.com" --dsn "..." + + # Multiple hostnames + npx @bytebase/dbhub@latest --transport http --allowed-hosts "dbhub.example.com,db.internal" --dsn "..." + + # Disable the check entirely (only when fronted by your own auth/proxy) + npx @bytebase/dbhub@latest --transport http --allowed-hosts "*" --dsn "..." + ``` + + + A port in an entry is ignored — only the hostname is matched. IPv6 literals + must be bracketed, e.g. `[2001:db8::1]`. The active allow-list is printed at + startup. If a legitimate client gets a `403` "Host ... is not allowed", add + its hostname here. + + + + `--allowed-hosts "*"` turns off DNS-rebinding protection. Use it only when + DBHub sits behind your own authentication and/or proxy. + + + ### --dsn diff --git a/src/config/env.ts b/src/config/env.ts index 4ae22f01..50dda991 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -416,6 +416,44 @@ export function resolveHost(): { host: string; source: string } { return { host: "0.0.0.0", source: "default" }; } +/** + * Resolve the list of additional hostnames the HTTP transport accepts in the + * `Host`/`Origin` headers (DNS-rebinding allow-list). Loopback hosts and the + * concrete bind host are always allowed by buildAllowedHosts(); this returns + * only the operator-supplied extras. + * + * Sources (highest priority first): + * 1. --allowed-hosts=host1,host2 + * 2. DBHUB_ALLOWED_HOSTS=host1,host2 environment variable + * + * Use a single "*" to disable Host validation (only when DBHub is fronted by + * your own authentication/proxy). Entries may include a port, which is ignored + * (only the hostname is matched). IPv6 literals must be bracketed, e.g. [::1]. + */ +export function resolveAllowedHosts(): { hosts: string[]; source: string } { + const args = parseCommandLineArgs(); + + const cliValue = requireFlagValue("allowed-hosts", args, "db.internal,app.example.com"); + if (cliValue !== undefined) { + return { hosts: splitHostList(cliValue), source: "command line argument" }; + } + + const envValue = process.env.DBHUB_ALLOWED_HOSTS?.trim(); + if (envValue) { + return { hosts: splitHostList(envValue), source: "environment variable" }; + } + + return { hosts: [], source: "default" }; +} + +/** Split a comma-separated host list, trimming and dropping empty entries. */ +function splitHostList(value: string): string[] { + return value + .split(",") + .map((h) => h.trim()) + .filter((h) => h.length > 0); +} + /** * Redact sensitive information from a DSN string * Replaces the password with asterisks diff --git a/src/server.ts b/src/server.ts index ad9642a5..7878615e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,14 +9,14 @@ import { fileURLToPath } from "url"; import { ConnectorManager } from "./connectors/manager.js"; import { ConnectorRegistry } from "./connectors/interface.js"; -import { resolveTransport, resolvePort, resolveHost, resolveSourceConfigs, isDemoMode } from "./config/env.js"; +import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveSourceConfigs, isDemoMode } from "./config/env.js"; import { registerTools } from "./tools/index.js"; import { listSources, getSource } from "./api/sources.js"; import { listRequests } from "./api/requests.js"; import { generateStartupTable, buildSourceDisplayInfo } from "./utils/startup-table.js"; import { getToolsForSource } from "./utils/tool-metadata.js"; import { startConfigWatcher } from "./utils/config-watcher.js"; -import { validateOrigin } from "./utils/cross-origin.js"; +import { validateOrigin, buildAllowedHosts, getSelfHosts, ALLOW_ANY_HOST } from "./utils/cross-origin.js"; // Create __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); @@ -135,6 +135,15 @@ See documentation for more details on configuring database connections. const port = transportData.type === "http" ? resolvePort().port : null; const host = transportData.type === "http" ? resolveHost().host : null; + // DNS-rebinding allow-list for the HTTP transport: loopback is always + // permitted; a wildcard bind also auto-allows this machine's hostname/IPs so + // network clients work without extra config, and operators add any other + // served hostnames via --allowed-hosts / DBHUB_ALLOWED_HOSTS ("*" disables). + const allowedHosts = + transportData.type === "http" + ? buildAllowedHosts(resolveAllowedHosts().hosts, host ?? undefined, getSelfHosts()) + : new Set(); + // Print ASCII art banner with version and slogan // Collect active modes const activeModes: string[] = []; @@ -172,17 +181,15 @@ See documentation for more details on configuring database connections. // Enable JSON parsing app.use(express.json()); - // Cross-origin fetch guard: when a browser sends Origin, require - // it to match the Host header. This blocks the common case of an - // attacker's site on a different origin issuing an authenticated - // fetch to a locally bound DBHub; it is NOT a complete defense - // against DNS rebinding, since a rebinding attacker controls both - // the DNS record and the Origin string and can trivially make them - // agree. A Host-header allowlist is tracked as follow-up hardening. - // Non-browser MCP clients typically omit Origin and are unaffected. + // DNS-rebinding guard: validate the Host header against an explicit + // allow-list (loopback + the bind host + any --allowed-hosts) on every + // request, and validate Origin when present. A rebound attacker hostname + // is not on the list and is rejected even though Origin and Host agree — + // closing GHSA-fm8p-53ww-hf6w / GHSA-fp99-xwp4-hv8q / GHSA-qvg2-3c48-77mx. + // Non-browser MCP clients targeting an allowed host are unaffected. app.use((req, res, next) => { const origin = req.headers.origin; - const result = validateOrigin(origin, req.headers.host); + const result = validateOrigin(origin, req.headers.host, allowedHosts); if (!result.ok) { return res.status(result.status).json({ error: result.status === 400 ? 'Bad Request' : 'Forbidden', @@ -277,6 +284,14 @@ See documentation for more details on configuring database connections. console.error(`HTTP server listening on ${displayHost}:${boundPort}`); + // Surface the DNS-rebinding allow-list so operators know which Host + // values are accepted (and how to widen it for network deployments). + if (allowedHosts.has(ALLOW_ANY_HOST)) { + console.error('Allowed hosts: * (DNS-rebinding protection DISABLED — ensure DBHub is fronted by your own auth/proxy)'); + } else { + console.error(`Allowed hosts: ${[...allowedHosts].join(', ')} (set --allowed-hosts to serve other hostnames)`); + } + // In development mode, suggest using the Vite dev server for hot reloading. // Vite serves from localhost; use the same hostname for the backend hint so // cross-origin calls from Vite satisfy the DNS-rebinding middleware check. diff --git a/src/utils/__tests__/cross-origin.test.ts b/src/utils/__tests__/cross-origin.test.ts index 8210cfaf..8328a93c 100644 --- a/src/utils/__tests__/cross-origin.test.ts +++ b/src/utils/__tests__/cross-origin.test.ts @@ -1,45 +1,179 @@ import { describe, it, expect } from 'vitest'; -import { validateOrigin } from '../cross-origin.js'; +import { validateOrigin, buildAllowedHosts, getSelfHosts, ALLOW_ANY_HOST } from '../cross-origin.js'; + +// Default allow-list for a loopback deployment (no operator-configured hosts, +// wildcard bind which contributes nothing). +const loopback = buildAllowedHosts([], '0.0.0.0'); + +describe('buildAllowedHosts', () => { + it('always includes loopback hostnames', () => { + const hosts = buildAllowedHosts([], '0.0.0.0'); + expect(hosts.has('localhost')).toBe(true); + expect(hosts.has('127.0.0.1')).toBe(true); + expect(hosts.has('[::1]')).toBe(true); + }); + + it('does not add wildcard bind addresses as hostnames', () => { + expect(buildAllowedHosts([], '0.0.0.0').has('0.0.0.0')).toBe(false); + expect(buildAllowedHosts([], '::').has('[::]')).toBe(false); + }); + + it('adds a concrete bind host', () => { + expect(buildAllowedHosts([], '192.168.1.10').has('192.168.1.10')).toBe(true); + }); + + it('adds operator-configured hosts, ignoring ports and case', () => { + const hosts = buildAllowedHosts(['Example.com:8443', 'db.internal'], '127.0.0.1'); + expect(hosts.has('example.com')).toBe(true); + expect(hosts.has('db.internal')).toBe(true); + }); + + it('drops crafted configured entries instead of normalizing them to a hostname', () => { + // "evil.com/foo" and "evil.com@host" would URL-parse to a hostname; reject + // them so an operator typo cannot silently broaden the allow-list. + const hosts = buildAllowedHosts(['evil.com/foo', 'evil.com@trusted.example'], '127.0.0.1'); + expect(hosts.has('evil.com')).toBe(false); + expect(hosts.has('trusted.example')).toBe(false); + }); + + it('collapses to the wildcard sentinel when "*" is configured', () => { + const hosts = buildAllowedHosts(['*', 'example.com'], '127.0.0.1'); + expect(hosts.has(ALLOW_ANY_HOST)).toBe(true); + expect(hosts.size).toBe(1); + }); + + it('normalizes a bracketed IPv6 configured host', () => { + expect(buildAllowedHosts(['[fe80::1]'], '127.0.0.1').has('[fe80::1]')).toBe(true); + }); + + it('auto-allows self hosts only when bound to a wildcard address', () => { + const selfHosts = ['my-laptop', '192.168.1.5', '[2001:db8::1]']; + + // Wildcard bind → self hosts are reachable, so they are allowed. + const wildcard = buildAllowedHosts([], '0.0.0.0', selfHosts); + expect(wildcard.has('my-laptop')).toBe(true); + expect(wildcard.has('192.168.1.5')).toBe(true); + expect(wildcard.has('[2001:db8::1]')).toBe(true); + + // Concrete bind → only that address is reachable; self hosts are omitted. + const concrete = buildAllowedHosts([], '127.0.0.1', selfHosts); + expect(concrete.has('192.168.1.5')).toBe(false); + expect(concrete.has('my-laptop')).toBe(false); + expect(concrete.has('127.0.0.1')).toBe(true); + }); + + it('lower-cases and normalizes self host entries', () => { + const hosts = buildAllowedHosts([], '::', ['My-Host', '10.0.0.2']); + expect(hosts.has('my-host')).toBe(true); + expect(hosts.has('10.0.0.2')).toBe(true); + }); + + it('wildcard config overrides self hosts', () => { + const hosts = buildAllowedHosts(['*'], '0.0.0.0', ['192.168.1.5']); + expect(hosts.has(ALLOW_ANY_HOST)).toBe(true); + expect(hosts.size).toBe(1); + }); +}); + +describe('getSelfHosts', () => { + it('returns an array of non-empty host strings', () => { + const hosts = getSelfHosts(); + expect(Array.isArray(hosts)).toBe(true); + for (const h of hosts) { + expect(typeof h).toBe('string'); + expect(h.length).toBeGreaterThan(0); + } + }); + + it('does not include loopback or IPv6 link-local addresses', () => { + const hosts = getSelfHosts(); + expect(hosts).not.toContain('127.0.0.1'); + expect(hosts).not.toContain('::1'); + expect(hosts.some((h) => h.toLowerCase().startsWith('[fe80'))).toBe(false); + }); + + it('produces entries that survive the allow-list and validate against themselves', () => { + // Whatever this machine reports must round-trip: building an allow-list from + // it (wildcard bind) and validating that Host returns ok. Capture once — + // OS interface enumeration can change between calls. + const selfHosts = getSelfHosts(); + const allowed = buildAllowedHosts([], '0.0.0.0', selfHosts); + for (const h of selfHosts) { + expect(validateOrigin(undefined, h, allowed)).toEqual({ ok: true }); + } + }); +}); describe('validateOrigin', () => { - it('allows requests with no Origin header (non-browser clients)', () => { - expect(validateOrigin(undefined, 'localhost:8080')).toEqual({ ok: true }); + it('allows requests with no Origin header to an allowed host', () => { + expect(validateOrigin(undefined, 'localhost:8080', loopback)).toEqual({ ok: true }); }); it('allows matching origin and host (hostname)', () => { - expect(validateOrigin('http://localhost:5173', 'localhost:8080')).toEqual({ ok: true }); + expect(validateOrigin('http://localhost:5173', 'localhost:8080', loopback)).toEqual({ ok: true }); }); it('allows matching origin and host (IPv4)', () => { - expect(validateOrigin('http://127.0.0.1:5173', '127.0.0.1:8080')).toEqual({ ok: true }); + expect(validateOrigin('http://127.0.0.1:5173', '127.0.0.1:8080', loopback)).toEqual({ ok: true }); }); it('allows matching origin and host for IPv6 bracketed literals', () => { // Regression: .split(":")[0] mangled [::1]:8080 to "["; URL parsing preserves ::1. - expect(validateOrigin('http://[::1]:5173', '[::1]:8080')).toEqual({ ok: true }); + expect(validateOrigin('http://[::1]:5173', '[::1]:8080', loopback)).toEqual({ ok: true }); }); - it('allows matching origin and host for full IPv6 addresses', () => { - expect( - validateOrigin('http://[fe80::1]:5173', '[fe80::1]:8080') - ).toEqual({ ok: true }); + it('allows a cross-loopback origin/host pair (both on the allow-list)', () => { + // 127.0.0.1 and ::1 are both loopback, so reflecting one to the other is safe. + expect(validateOrigin('http://127.0.0.1:5173', '[::1]:8080', loopback)).toEqual({ ok: true }); }); it('is case-insensitive on hostnames', () => { - expect(validateOrigin('http://LocalHost:5173', 'localhost:8080')).toEqual({ ok: true }); + expect(validateOrigin('http://LocalHost:5173', 'localhost:8080', loopback)).toEqual({ ok: true }); }); - it('rejects when Origin host does not match Host header', () => { - const result = validateOrigin('http://evil.com', 'localhost:8080'); - expect(result).toEqual({ - ok: false, - status: 403, - message: 'Origin does not match Host header', - }); + it('rejects a rebound attacker host even when Origin matches it (the CVE)', () => { + // This is the DNS-rebinding shape: Host and Origin agree, but the hostname + // is not one we serve, so the request must be refused. + const result = validateOrigin( + 'http://evil.attacker.test', + 'evil.attacker.test:8080', + loopback + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(403); + expect(result.message).toContain('not allowed'); + } + }); + + it('rejects a rebound attacker host with no Origin header (same-origin POST)', () => { + const result = validateOrigin(undefined, 'evil.attacker.test:8080', loopback); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(403); + }); + + it('rejects an off-list Origin even when Host is allowed', () => { + const result = validateOrigin('http://evil.com', 'localhost:8080', loopback); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(403); + expect(result.message).toContain("Origin 'evil.com'"); + } + }); + + it('allows an operator-configured deployment host', () => { + const allowed = buildAllowedHosts(['app.internal'], '0.0.0.0'); + expect(validateOrigin('http://app.internal', 'app.internal:8080', allowed)).toEqual({ ok: true }); + }); + + it('allows any host/origin when the wildcard is configured', () => { + const any = buildAllowedHosts(['*'], '0.0.0.0'); + expect(validateOrigin('http://evil.attacker.test', 'evil.attacker.test', any)).toEqual({ ok: true }); + expect(validateOrigin(undefined, 'anything.example', any)).toEqual({ ok: true }); }); it('rejects when Origin is malformed with status 400', () => { - const result = validateOrigin('not a url', 'localhost:8080'); + const result = validateOrigin('not a url', 'localhost:8080', loopback); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(400); @@ -48,7 +182,7 @@ describe('validateOrigin', () => { }); it('rejects when Host header is malformed with status 400', () => { - const result = validateOrigin('http://localhost:5173', ''); + const result = validateOrigin('http://localhost:5173', '', loopback); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(400); @@ -56,16 +190,10 @@ describe('validateOrigin', () => { } }); - it('rejects when IPv4 origin does not match IPv6 host', () => { - const result = validateOrigin('http://127.0.0.1:5173', '[::1]:8080'); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.status).toBe(403); - }); - it('rejects an explicitly empty Origin header as malformed (400)', () => { // `Origin:` (empty value) is not the same as "Origin absent"; a browser // would not produce it, and letting it through bypasses the guard. - expect(validateOrigin('', 'localhost:8080')).toEqual({ + expect(validateOrigin('', 'localhost:8080', loopback)).toEqual({ ok: false, status: 400, message: 'Malformed Origin header', @@ -73,7 +201,7 @@ describe('validateOrigin', () => { }); it('rejects a whitespace-only Origin header as malformed (400)', () => { - expect(validateOrigin(' ', 'localhost:8080')).toEqual({ + expect(validateOrigin(' ', 'localhost:8080', loopback)).toEqual({ ok: false, status: 400, message: 'Malformed Origin header', @@ -82,10 +210,9 @@ describe('validateOrigin', () => { it('rejects a Host header containing a path separator as malformed (400)', () => { // `new URL("http://evil.com/localhost:8080").hostname` silently yields - // "evil.com"; if an attacker also sets Origin: http://evil.com the - // naked string-equality match would pass. + // "evil.com"; the char filter rejects the crafted Host outright. expect( - validateOrigin('http://evil.com', 'evil.com/localhost:8080') + validateOrigin('http://evil.com', 'evil.com/localhost:8080', loopback) ).toEqual({ ok: false, status: 400, @@ -94,10 +221,10 @@ describe('validateOrigin', () => { }); it('rejects a Host header containing a userinfo character as malformed (400)', () => { - // `new URL("http://evil.com@localhost:8080").hostname` yields - // "localhost"; a crafted Origin: http://localhost would then match. + // `new URL("http://evil.com@localhost:8080").hostname` yields "localhost"; + // the char filter rejects the crafted Host before it can match the list. expect( - validateOrigin('http://localhost:8080', 'evil.com@localhost:8080') + validateOrigin('http://localhost:8080', 'evil.com@localhost:8080', loopback) ).toEqual({ ok: false, status: 400, @@ -107,7 +234,7 @@ describe('validateOrigin', () => { it('rejects a Host header containing whitespace as malformed (400)', () => { expect( - validateOrigin('http://localhost:8080', 'localhost 8080') + validateOrigin('http://localhost:8080', 'localhost 8080', loopback) ).toEqual({ ok: false, status: 400, diff --git a/src/utils/cross-origin.ts b/src/utils/cross-origin.ts index 70a59e03..2fdadc3a 100644 --- a/src/utils/cross-origin.ts +++ b/src/utils/cross-origin.ts @@ -1,85 +1,243 @@ +import os from "node:os"; + /** - * Result of validating an HTTP request's Origin against its Host header. + * Result of validating an HTTP request's Host (and Origin) headers against + * the configured allow-list. */ export type OriginValidation = | { ok: true } | { ok: false; status: 400 | 403; message: string }; /** - * Check that a request's Origin hostname equals its Host header hostname. + * DNS-rebinding defense for the HTTP transport. * - * Scope and limitations: + * The previous implementation only checked that the request's `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 issues requests where *both* headers carry the attacker's + * hostname, so the equality check passes and the browser reaches `/mcp` + * (GHSA-fm8p-53ww-hf6w, GHSA-fp99-xwp4-hv8q, GHSA-qvg2-3c48-77mx). * - * - This is a cross-origin-fetch guard, not a full DNS-rebinding defense. - * A true rebinding attacker controls both the DNS record the browser - * resolves *and* the Origin the script sends, so they can trivially - * arrange for `Origin` and `Host` to agree while still pointing at a - * local service. A proper defense requires validating `Host` against - * an allowlist and/or requiring authentication — tracked as future - * hardening for the HTTP transport. - * - What this *does* block is the simpler case of a browser on a - * different origin (e.g., an attacker's public site) making an - * authenticated cross-origin fetch: browsers always attach the real - * Origin on such requests, and it will not match the local Host. - * - Browsers always send `Origin` on cross-origin fetches; non-browser - * MCP clients typically omit it, so a missing `Origin` is allowed - * through. + * The real fix is to validate the `Host` header against an explicit allow-list + * of hostnames the operator actually serves on — loopback by default. A + * rebound `Host: evil.attacker.test` is not on that list and is rejected + * regardless of what `Origin` says. The check runs even when `Origin` is + * absent, because a rebound *same-origin* POST (the page and `/mcp` share the + * attacker hostname) may omit `Origin` entirely. * * WHATWG URL parsing is used for both headers so IPv6 bracket notation - * (e.g., `[::1]:8080`) is handled correctly — a naive `split(':')[0]` on - * the Host header yields `"["` for IPv6 literals, which breaks the match. + * (e.g. `[::1]:8080`) is handled correctly — a naive `split(':')[0]` on the + * Host header yields `"["` for IPv6 literals. */ + // Characters that must not appear in a Host header per RFC 3986's host/port // grammar. `new URL("http://" + host)` is lax — `evil.com/foo` silently // parses to hostname `evil.com` with path `/foo`, and `evil.com@localhost` // parses to hostname `localhost` with `evil.com` treated as userinfo. -// Either case would let a crafted Host header slip past the equality match. +// Either case would let a crafted Host header slip past the allow-list. const INVALID_HOST_CHARS = /[\s/\\@?#]/; -export function validateOrigin( - originHeader: string | undefined, - hostHeader: string | undefined -): OriginValidation { - // A genuinely absent Origin is allowed: non-browser MCP clients routinely - // omit it, and the whole check is only meaningful for browser cross-origin - // fetches. An explicitly present but empty (or whitespace-only) Origin - // is not what a browser would ever send, so treat it as malformed rather - // than silently bypassing the guard. - if (originHeader === undefined) return { ok: true }; +// Loopback hostnames are always allowed: this is the default, safe-by-design +// access path for a developer-workstation MCP server. `[::1]` is bracketed to +// match the canonical form `new URL().hostname` produces for IPv6 literals. +const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; - const trimmedOrigin = originHeader.trim(); - if (!trimmedOrigin) { - return { ok: false, status: 400, message: 'Malformed Origin header' }; +// Sentinel entry that disables Host/Origin validation entirely. For operators +// who deliberately expose DBHub on a network behind their own auth/proxy and +// accept the risk. +export const ALLOW_ANY_HOST = "*"; + +/** + * Normalize a host entry (from config or a bind address) to the canonical, + * lower-cased, port-stripped hostname that `new URL().hostname` yields, so it + * can be compared against parsed request headers. IPv6 literals must be + * bracketed (e.g. `[::1]`). Returns `null` for empty/unparseable input, or the + * `ALLOW_ANY_HOST` sentinel passed straight through. + */ +function normalizeHost(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (trimmed === ALLOW_ANY_HOST) return ALLOW_ANY_HOST; + // Mirror the Host-header validation: reject crafted authority strings such as + // "evil.com/foo" or "evil.com@localhost" that URL parsing would otherwise + // silently normalize to an unintended hostname (e.g. an operator typo + // broadening the allow-list). + if (INVALID_HOST_CHARS.test(trimmed)) return null; + try { + const hostname = new URL(`http://${trimmed}`).hostname.toLowerCase(); + return hostname || null; + } catch { + return null; } +} - const trimmedHost = (hostHeader ?? '').trim(); - if (!trimmedHost || INVALID_HOST_CHARS.test(trimmedHost)) { - return { ok: false, status: 400, message: 'Malformed Host header' }; +/** + * Collect the machine's own hostname and external (non-loopback) interface IP + * addresses. Used to auto-allow clients that reach DBHub by IP or machine name + * when bound to a wildcard address, so the common network-access case works + * without `--allowed-hosts`. + * + * This is safe against DNS rebinding: a rebound request carries the *attacker's* + * hostname in `Host` (the name in the browser's URL bar), never the victim + * machine's own IP or name, so these entries can never match an attack. A + * direct cross-origin fetch to one of these IPs still carries a foreign + * `Origin` and is rejected by the Origin check. + */ +export function getSelfHosts(): string[] { + const hosts: string[] = []; + + try { + const hostname = os.hostname().trim(); + if (hostname) hosts.push(hostname); + } catch { + // hostname unavailable — skip } - let originHostname: string; + let interfaces: ReturnType = {}; try { - originHostname = new URL(trimmedOrigin).hostname.toLowerCase(); + interfaces = os.networkInterfaces(); } catch { - return { ok: false, status: 400, message: 'Malformed Origin header' }; + // interface enumeration unavailable — skip + } + + for (const addrs of Object.values(interfaces)) { + for (const addr of addrs ?? []) { + // Loopback is already on the list; skip internal addresses. + if (!addr.address || addr.internal) continue; + + // Node reports family as the string "IPv6" (or the number 6 on newer + // releases); handle both. + const isIPv6 = addr.family === "IPv6" || (addr.family as unknown) === 6; + if (isIPv6) { + const bare = addr.address.split("%")[0]; // drop zone id, e.g. fe80::1%en0 + // Link-local addresses need a zone id to be routable and just add + // noise to the allow-list — skip them. + if (bare.toLowerCase().startsWith("fe80")) continue; + hosts.push(`[${bare}]`); + } else { + hosts.push(addr.address); + } + } + } + + return hosts; +} + +/** + * Build the set of hostnames the HTTP transport will accept in the `Host` + * header. Always includes loopback. When bound to a concrete (non-wildcard) + * address, that address is added. When bound to a wildcard (0.0.0.0 / ::), the + * machine's own hostname/IPs (`selfHosts`) are added so network clients work + * without extra config. Operator-configured hosts are always added. A single + * `*` entry anywhere disables the check (returns a set containing only `*`). + */ +export function buildAllowedHosts( + configured: string[] = [], + bindHost?: string, + selfHosts: string[] = [] +): Set { + const normalizedConfigured = configured + .map(normalizeHost) + .filter((h): h is string => h !== null); + + if (normalizedConfigured.includes(ALLOW_ANY_HOST)) { + return new Set([ALLOW_ANY_HOST]); + } + + const hosts = new Set(); + for (const h of LOOPBACK_HOSTS) { + const normalized = normalizeHost(h); + if (normalized) hosts.add(normalized); + } + + const normalizedBind = bindHost ? normalizeHost(bindHost) : null; + // Wildcard binds (0.0.0.0 / ::) are not real hostnames a client targets. + const bindIsWildcard = + !normalizedBind || normalizedBind === "0.0.0.0" || normalizedBind === "[::]"; + + if (normalizedBind && !bindIsWildcard) { + hosts.add(normalizedBind); + } + + // Self hostnames/IPs are only reachable — and thus only relevant — when bound + // to a wildcard address; a concrete bind already contributed its one address. + if (bindIsWildcard) { + for (const h of selfHosts) { + const normalized = normalizeHost(h); + if (normalized) hosts.add(normalized); + } + } + + for (const h of normalizedConfigured) hosts.add(h); + + return hosts; +} + +/** + * Validate a request's `Host` (and `Origin`, when present) against the + * allow-list. The `Host` check is the DNS-rebinding defense and always runs; + * the `Origin` check additionally guards the CORS reflection so an arbitrary + * origin is never echoed back into `Access-Control-Allow-Origin`. + */ +export function validateOrigin( + originHeader: string | undefined, + hostHeader: string | undefined, + allowedHosts: Set +): OriginValidation { + const allowAny = allowedHosts.has(ALLOW_ANY_HOST); + + // 1. Validate the Host header (the rebinding defense — runs unconditionally). + const trimmedHost = (hostHeader ?? "").trim(); + if (!trimmedHost || INVALID_HOST_CHARS.test(trimmedHost)) { + return { ok: false, status: 400, message: "Malformed Host header" }; } let hostname: string; try { hostname = new URL(`http://${trimmedHost}`).hostname.toLowerCase(); } catch { - return { ok: false, status: 400, message: 'Malformed Host header' }; + return { ok: false, status: 400, message: "Malformed Host header" }; } - if (!hostname) { - return { ok: false, status: 400, message: 'Malformed Host header' }; + return { ok: false, status: 400, message: "Malformed Host header" }; + } + + if (!allowAny && !allowedHosts.has(hostname)) { + return { + ok: false, + status: 403, + message: + `Host '${hostname}' is not allowed. Only loopback is permitted by default; ` + + `set --allowed-hosts (or DBHUB_ALLOWED_HOSTS) to serve other hostnames. ` + + `This protects against DNS rebinding.`, + }; + } + + // 2. Origin is only sent by browsers on cross-origin fetches; non-browser + // MCP clients omit it. When present it must also be an allowed host so we + // never reflect an untrusted origin in the CORS response. + if (originHeader === undefined) return { ok: true }; + + const trimmedOrigin = originHeader.trim(); + if (!trimmedOrigin) { + return { ok: false, status: 400, message: "Malformed Origin header" }; + } + + let originHostname: string; + try { + originHostname = new URL(trimmedOrigin).hostname.toLowerCase(); + } catch { + return { ok: false, status: 400, message: "Malformed Origin header" }; + } + if (!originHostname) { + return { ok: false, status: 400, message: "Malformed Origin header" }; } - if (originHostname !== hostname) { + if (!allowAny && !allowedHosts.has(originHostname)) { return { ok: false, status: 403, - message: 'Origin does not match Host header', + message: `Origin '${originHostname}' is not allowed`, }; }