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`,
};
}