Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
40 changes: 40 additions & 0 deletions docs/config/command-line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ Command-line flags are passed when starting DBHub. These have the highest priori
</Warning>
</ParamField>

### --allowed-hosts

<ParamField path="--allowed-hosts" type="string" env="DBHUB_ALLOWED_HOSTS" default="(loopback + this machine)">
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 "..."
```

<Note>
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.
</Note>

<Warning>
`--allowed-hosts "*"` turns off DNS-rebinding protection. Use it only when
DBHub sits behind your own authentication and/or proxy.
</Warning>
</ParamField>

### --dsn

<ParamField path="--dsn" type="string" env="DSN">
Expand Down
38 changes: 38 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 26 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();

// Print ASCII art banner with version and slogan
// Collect active modes
const activeModes: string[] = [];
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading