Skip to content

Commit 5bf5c32

Browse files
tianzhouclaude
andauthored
fix: enforce Host allow-list on HTTP transport to block DNS rebinding (#340)
* fix: enforce Host allow-list on HTTP transport to block DNS rebinding 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> * fix: address review — harden normalizeHost, doc IPv6 brackets, dedupe 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 365204e commit 5bf5c32

8 files changed

Lines changed: 481 additions & 90 deletions

File tree

‎.env.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ PORT=8080
4040
# HOST env var that some shells and CI systems set automatically.
4141
# DBHUB_HOST=0.0.0.0
4242

43+
# Allowed Host header values for the HTTP transport (DNS-rebinding protection).
44+
# Loopback (localhost, 127.0.0.1, [::1]) is always allowed, and when bound to a
45+
# wildcard address this machine's hostname and IPs are added automatically — so
46+
# local and by-IP access work with no config. Set this only for OTHER names
47+
# that resolve to DBHub, e.g. a reverse-proxy/public DNS name (comma-separated),
48+
# or clients using that Host will get 403.
49+
# Use "*" to disable the check entirely (only behind your own auth/proxy).
50+
# DBHUB_ALLOWED_HOSTS=dbhub.example.com,db.internal
51+
4352
# SSH Tunnel Configuration (optional)
4453
# Use these settings to connect through an SSH bastion host
4554
# SSH_HOST=bastion.example.com

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ DBHub supports three configuration methods (in priority order):
121121
- `--dsn`: Database connection string
122122
- `--transport`: `stdio` (default) or `http` for streamable HTTP transport (endpoint: `/mcp`)
123123
- `--port`: HTTP server port (default: 8080)
124+
- `--host`: HTTP bind host (default: `0.0.0.0`; env `DBHUB_HOST`)
125+
- `--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`.
124126
- `--config`: Path to TOML configuration file
125127
- `--demo`: Use bundled SQLite employee database
126128
- `--readonly`: Restrict to read-only SQL operations (deprecated - use TOML configuration instead)

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
9393
```
9494

9595
> 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.
96+
>
97+
> 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`.
9698
9799
See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
98100

‎docs/config/command-line.mdx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,46 @@ Command-line flags are passed when starting DBHub. These have the highest priori
7171
</Warning>
7272
</ParamField>
7373

74+
### --allowed-hosts
75+
76+
<ParamField path="--allowed-hosts" type="string" env="DBHUB_ALLOWED_HOSTS" default="(loopback + this machine)">
77+
Comma-separated list of additional hostnames the HTTP transport accepts in the
78+
`Host` (and `Origin`) headers. This is DBHub's **DNS-rebinding protection**:
79+
requests whose `Host` is not on the list are rejected with `403`, so a
80+
malicious web page cannot rebind a hostname to your DBHub instance and drive
81+
its MCP tools from the victim's browser. Only used when `--transport=http`.
82+
83+
The list always includes loopback (`localhost`, `127.0.0.1`, `[::1]`). When
84+
bound to a wildcard address (the default `0.0.0.0` / `::`), this machine's own
85+
hostname and external IP addresses are added automatically, so reaching DBHub
86+
by IP or machine name works without any extra configuration. You only need
87+
this flag for **other** names that resolve to DBHub — most commonly a
88+
reverse-proxy or public DNS name.
89+
90+
```bash
91+
# Serve behind a public/reverse-proxy hostname
92+
npx @bytebase/dbhub@latest --transport http --allowed-hosts "dbhub.example.com" --dsn "..."
93+
94+
# Multiple hostnames
95+
npx @bytebase/dbhub@latest --transport http --allowed-hosts "dbhub.example.com,db.internal" --dsn "..."
96+
97+
# Disable the check entirely (only when fronted by your own auth/proxy)
98+
npx @bytebase/dbhub@latest --transport http --allowed-hosts "*" --dsn "..."
99+
```
100+
101+
<Note>
102+
A port in an entry is ignored — only the hostname is matched. IPv6 literals
103+
must be bracketed, e.g. `[2001:db8::1]`. The active allow-list is printed at
104+
startup. If a legitimate client gets a `403` "Host ... is not allowed", add
105+
its hostname here.
106+
</Note>
107+
108+
<Warning>
109+
`--allowed-hosts "*"` turns off DNS-rebinding protection. Use it only when
110+
DBHub sits behind your own authentication and/or proxy.
111+
</Warning>
112+
</ParamField>
113+
74114
### --dsn
75115

76116
<ParamField path="--dsn" type="string" env="DSN">

‎src/config/env.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,44 @@ export function resolveHost(): { host: string; source: string } {
416416
return { host: "0.0.0.0", source: "default" };
417417
}
418418

419+
/**
420+
* Resolve the list of additional hostnames the HTTP transport accepts in the
421+
* `Host`/`Origin` headers (DNS-rebinding allow-list). Loopback hosts and the
422+
* concrete bind host are always allowed by buildAllowedHosts(); this returns
423+
* only the operator-supplied extras.
424+
*
425+
* Sources (highest priority first):
426+
* 1. --allowed-hosts=host1,host2
427+
* 2. DBHUB_ALLOWED_HOSTS=host1,host2 environment variable
428+
*
429+
* Use a single "*" to disable Host validation (only when DBHub is fronted by
430+
* your own authentication/proxy). Entries may include a port, which is ignored
431+
* (only the hostname is matched). IPv6 literals must be bracketed, e.g. [::1].
432+
*/
433+
export function resolveAllowedHosts(): { hosts: string[]; source: string } {
434+
const args = parseCommandLineArgs();
435+
436+
const cliValue = requireFlagValue("allowed-hosts", args, "db.internal,app.example.com");
437+
if (cliValue !== undefined) {
438+
return { hosts: splitHostList(cliValue), source: "command line argument" };
439+
}
440+
441+
const envValue = process.env.DBHUB_ALLOWED_HOSTS?.trim();
442+
if (envValue) {
443+
return { hosts: splitHostList(envValue), source: "environment variable" };
444+
}
445+
446+
return { hosts: [], source: "default" };
447+
}
448+
449+
/** Split a comma-separated host list, trimming and dropping empty entries. */
450+
function splitHostList(value: string): string[] {
451+
return value
452+
.split(",")
453+
.map((h) => h.trim())
454+
.filter((h) => h.length > 0);
455+
}
456+
419457
/**
420458
* Redact sensitive information from a DSN string
421459
* Replaces the password with asterisks

‎src/server.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import { fileURLToPath } from "url";
99

1010
import { ConnectorManager } from "./connectors/manager.js";
1111
import { ConnectorRegistry } from "./connectors/interface.js";
12-
import { resolveTransport, resolvePort, resolveHost, resolveSourceConfigs, isDemoMode } from "./config/env.js";
12+
import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveSourceConfigs, isDemoMode } from "./config/env.js";
1313
import { registerTools } from "./tools/index.js";
1414
import { listSources, getSource } from "./api/sources.js";
1515
import { listRequests } from "./api/requests.js";
1616
import { generateStartupTable, buildSourceDisplayInfo } from "./utils/startup-table.js";
1717
import { getToolsForSource } from "./utils/tool-metadata.js";
1818
import { startConfigWatcher } from "./utils/config-watcher.js";
19-
import { validateOrigin } from "./utils/cross-origin.js";
19+
import { validateOrigin, buildAllowedHosts, getSelfHosts, ALLOW_ANY_HOST } from "./utils/cross-origin.js";
2020

2121
// Create __dirname equivalent for ES modules
2222
const __filename = fileURLToPath(import.meta.url);
@@ -135,6 +135,15 @@ See documentation for more details on configuring database connections.
135135
const port = transportData.type === "http" ? resolvePort().port : null;
136136
const host = transportData.type === "http" ? resolveHost().host : null;
137137

138+
// DNS-rebinding allow-list for the HTTP transport: loopback is always
139+
// permitted; a wildcard bind also auto-allows this machine's hostname/IPs so
140+
// network clients work without extra config, and operators add any other
141+
// served hostnames via --allowed-hosts / DBHUB_ALLOWED_HOSTS ("*" disables).
142+
const allowedHosts =
143+
transportData.type === "http"
144+
? buildAllowedHosts(resolveAllowedHosts().hosts, host ?? undefined, getSelfHosts())
145+
: new Set<string>();
146+
138147
// Print ASCII art banner with version and slogan
139148
// Collect active modes
140149
const activeModes: string[] = [];
@@ -172,17 +181,15 @@ See documentation for more details on configuring database connections.
172181
// Enable JSON parsing
173182
app.use(express.json());
174183

175-
// Cross-origin fetch guard: when a browser sends Origin, require
176-
// it to match the Host header. This blocks the common case of an
177-
// attacker's site on a different origin issuing an authenticated
178-
// fetch to a locally bound DBHub; it is NOT a complete defense
179-
// against DNS rebinding, since a rebinding attacker controls both
180-
// the DNS record and the Origin string and can trivially make them
181-
// agree. A Host-header allowlist is tracked as follow-up hardening.
182-
// Non-browser MCP clients typically omit Origin and are unaffected.
184+
// DNS-rebinding guard: validate the Host header against an explicit
185+
// allow-list (loopback + the bind host + any --allowed-hosts) on every
186+
// request, and validate Origin when present. A rebound attacker hostname
187+
// is not on the list and is rejected even though Origin and Host agree —
188+
// closing GHSA-fm8p-53ww-hf6w / GHSA-fp99-xwp4-hv8q / GHSA-qvg2-3c48-77mx.
189+
// Non-browser MCP clients targeting an allowed host are unaffected.
183190
app.use((req, res, next) => {
184191
const origin = req.headers.origin;
185-
const result = validateOrigin(origin, req.headers.host);
192+
const result = validateOrigin(origin, req.headers.host, allowedHosts);
186193
if (!result.ok) {
187194
return res.status(result.status).json({
188195
error: result.status === 400 ? 'Bad Request' : 'Forbidden',
@@ -277,6 +284,14 @@ See documentation for more details on configuring database connections.
277284

278285
console.error(`HTTP server listening on ${displayHost}:${boundPort}`);
279286

287+
// Surface the DNS-rebinding allow-list so operators know which Host
288+
// values are accepted (and how to widen it for network deployments).
289+
if (allowedHosts.has(ALLOW_ANY_HOST)) {
290+
console.error('Allowed hosts: * (DNS-rebinding protection DISABLED — ensure DBHub is fronted by your own auth/proxy)');
291+
} else {
292+
console.error(`Allowed hosts: ${[...allowedHosts].join(', ')} (set --allowed-hosts to serve other hostnames)`);
293+
}
294+
280295
// In development mode, suggest using the Vite dev server for hot reloading.
281296
// Vite serves from localhost; use the same hostname for the backend hint so
282297
// cross-origin calls from Vite satisfy the DNS-rebinding middleware check.

0 commit comments

Comments
 (0)