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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ DBHub supports three configuration methods:
- `--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`.
- `--auth-token`: Comma-separated bearer token(s) required on every HTTP request via `Authorization: Bearer <token>` (env `DBHUB_AUTH_TOKEN`). Unset by default (no auth); configuring a token is itself the opt-in — there is no separate enforcement flag. A comma-separated list supports zero-downtime rotation (add the new token, redeploy, drop the old one) and per-client tokens. `/healthz` is exempt. Not OAuth — a flat shared-secret allow-list, not full identity-based authorization; see `validateAuthToken` in `src/utils/auth-token.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
35 changes: 34 additions & 1 deletion docs/config/command-line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ This page covers command-line flags and environment variables. For TOML configur
```

<Warning>
The default `0.0.0.0` exposes DBHub on every network interface. For production, set `--host 127.0.0.1` and place DBHub behind a reverse proxy (nginx/Caddy) or restrict with a firewall — DBHub does not authenticate HTTP clients.
The default `0.0.0.0` exposes DBHub on every network interface. For production, set `--host 127.0.0.1` and place DBHub behind a reverse proxy (nginx/Caddy) or restrict with a firewall, or configure `--auth-token` (see below) to require a bearer token on every request.
</Warning>
</ParamField>

Expand Down Expand Up @@ -127,6 +127,37 @@ This page covers command-line flags and environment variables. For TOML configur
</Warning>
</ParamField>

### --auth-token

<ParamField path="--auth-token" type="string" env="DBHUB_AUTH_TOKEN">
Comma-separated list of bearer tokens required on every HTTP request. Only used when `--transport=http`. Unset by default — auth is off unless you configure this.

Clients must send a matching token as `Authorization: Bearer <token>`; requests without one get `401 Unauthorized` with a `WWW-Authenticate: Bearer` header. `/healthz` is exempt so uptime monitors don't need a token.

```bash
# Single token
npx @bytebase/dbhub@latest --transport http --auth-token "s3cr3t-token" --dsn "..."

# Multiple tokens (rotate without downtime, or issue one per client)
npx @bytebase/dbhub@latest --transport http --auth-token "token-for-ci,token-for-agent" --dsn "..."
```

Client request (`/api/sources` is a plain `GET`; the MCP endpoint itself
requires a JSON-RPC POST body, so it's not a copy-pasteable example):

```bash
curl -H "Authorization: Bearer s3cr3t-token" http://localhost:8080/api/sources
```

<Note>
Configuring a token *is* the opt-in — there's no separate "require auth" flag to remember. A comma-separated list lets you rotate a leaked or expiring token by adding the new one, redeploying, and then removing the old one, and lets you hand different tokens to different clients so one can be revoked without affecting the others.
</Note>

<Warning>
This is a flat shared-secret check, not OAuth — it answers "does this request have the secret," not "who is this user" (no per-user scopes or audit trail). If you need real identity-based authorization, front DBHub with your own OAuth-aware proxy or IdP-integrated gateway instead.
</Warning>
</ParamField>

### --dsn

<ParamField path="--dsn" type="string" env="DSN">
Expand Down Expand Up @@ -357,6 +388,8 @@ npx @bytebase/dbhub@latest --dsn "..." \
| `--transport` | `TRANSPORT` | string | Transport mode: stdio or http (default: `stdio`) |
| `--port` | `PORT` | number | HTTP server port (http transport only, default: `8080`) |
| `--host` | `DBHUB_HOST` | string | HTTP bind address (http transport only, default: `0.0.0.0`) |
| `--allowed-hosts` | `DBHUB_ALLOWED_HOSTS` | string | Extra hostnames accepted in Host/Origin headers (http transport only, default: loopback + this machine) |
| `--auth-token` | `DBHUB_AUTH_TOKEN` | string | Comma-separated bearer token(s) required on requests (http transport only, default: auth disabled) |
| `--demo` | - | boolean | Use sample employee database |
| `--id` | `ID` | string | Instance identifier for tool names |
| `--config` | - | string | Path to TOML config file (default: `./dbhub.toml`) |
Expand Down
41 changes: 41 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,47 @@ function splitHostList(value: string): string[] {
.filter((h) => h.length > 0);
}

/**
* Resolve the list of bearer tokens accepted by the HTTP transport.
*
* Sources (highest priority first):
* 1. --auth-token=token1,token2
* 2. DBHUB_AUTH_TOKEN=token1,token2 environment variable
*
* An empty list (the default) means auth is disabled — configuring a token
* is itself the opt-in, so there is no separate "--require-auth" flag to
* forget. A comma-separated list supports zero-downtime token rotation (add
* the new token, redeploy, remove the old one) and per-client tokens.
*/
export function resolveAuthTokens(): { tokens: string[]; source: string } {
const args = parseCommandLineArgs();

const cliValue = requireFlagValue("auth-token", args, "secret1,secret2");
if (cliValue !== undefined) {
return { tokens: splitTokenList(cliValue), source: "command line argument" };
}

const envValue = process.env.DBHUB_AUTH_TOKEN?.trim();
if (envValue) {
return { tokens: splitTokenList(envValue), source: "environment variable" };
}

return { tokens: [], source: "default" };
}

/**
* Split a comma-separated token list, trimming and dropping empty entries.
* Deliberately separate from `splitHostList()` even though the bodies match
* today: hostnames may later gain normalization (lowercasing, port-stripping)
* that must never apply to tokens, which are compared byte-for-byte.
*/
function splitTokenList(value: string): string[] {
return value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
}

/**
* Redact sensitive information from a DSN string
* Replaces the password with asterisks
Expand Down
43 changes: 36 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import { fileURLToPath } from "url";

import { ConnectorManager } from "./connectors/manager.js";
import { ConnectorRegistry } from "./connectors/interface.js";
import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveSourceConfigs, isDemoMode } from "./config/env.js";
import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveAuthTokens, 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, buildAllowedHosts, getSelfHosts, ALLOW_ANY_HOST } from "./utils/cross-origin.js";
import { validateAuthToken } from "./utils/auth-token.js";

// Create __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -163,6 +164,12 @@ See documentation for more details on configuring database connections.
? buildAllowedHosts(resolveAllowedHosts().hosts, host ?? undefined, getSelfHosts())
: new Set<string>();

// Bearer token auth for the HTTP transport (issue #66). Configuring a
// token is itself the opt-in — an empty list (the default) leaves
// today's unauthenticated behavior unchanged. Only meaningful for http,
// but resolving it is cheap enough not to gate on transport type.
const { tokens: authTokens, source: authTokenSource } = resolveAuthTokens();

// Print ASCII art banner with version and slogan
// Collect active modes
const activeModes: string[] = [];
Expand Down Expand Up @@ -224,7 +231,7 @@ See documentation for more details on configuring database connections.
// mirroring them for exactly this reason.)
res.header('Access-Control-Allow-Origin', origin || 'http://localhost');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name');
res.header('Access-Control-Allow-Credentials', 'true');

if (req.method === 'OPTIONS') {
Expand All @@ -233,15 +240,30 @@ See documentation for more details on configuring database connections.
next();
});

// Serve static frontend files
const frontendPath = path.join(__dirname, "public");
app.use(express.static(frontendPath));

// Health check endpoint
// Health check endpoint. Registered before the auth middleware below so
// it never requires a token — uptime monitors don't have one — without
// the auth middleware needing to know about specific unauthenticated
// routes; it responds directly and never calls next().
app.get("/healthz", (req, res) => {
res.status(200).send("OK");
});

// Bearer token auth (issue #66): rejects requests missing a valid
// `Authorization: Bearer <token>` header when --auth-token/DBHUB_AUTH_TOKEN
// is configured; a no-op when it isn't.
app.use((req, res, next) => {
const result = validateAuthToken(req.headers.authorization, authTokens);
if (!result.ok) {
res.header('WWW-Authenticate', 'Bearer');
return res.status(result.status).json({ error: 'Unauthorized', message: result.message });
}
next();
});

// Serve static frontend files
const frontendPath = path.join(__dirname, "public");
app.use(express.static(frontendPath));

// Data sources API endpoints
app.get("/api/sources", listSources);
app.get("/api/sources/:sourceId", getSource);
Expand Down Expand Up @@ -301,6 +323,13 @@ See documentation for more details on configuring database connections.
console.error(`Allowed hosts: ${[...allowedHosts].join(', ')} (set --allowed-hosts to serve other hostnames)`);
}

// Surface whether bearer token auth is enforced (issue #66).
if (authTokens.length > 0) {
console.error(`Auth: bearer token required (${authTokens.length} token(s) configured via ${authTokenSource})`);
} else {
console.error('Auth: disabled (set --auth-token or DBHUB_AUTH_TOKEN to require a bearer token)');
}

// 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
58 changes: 58 additions & 0 deletions src/utils/__tests__/auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { validateAuthToken } from '../auth-token.js';
Comment thread
tianzhou marked this conversation as resolved.

describe('validateAuthToken', () => {
it('allows any request when no tokens are configured', () => {
expect(validateAuthToken(undefined, [])).toEqual({ ok: true });
expect(validateAuthToken('Bearer whatever', [])).toEqual({ ok: true });
});

it('accepts a matching bearer token', () => {
const result = validateAuthToken('Bearer secret123', ['secret123']);
expect(result).toEqual({ ok: true });
});

it('accepts a token that matches any entry in a multi-token list', () => {
const tokens = ['first-token', 'second-token', 'third-token'];
expect(validateAuthToken('Bearer second-token', tokens)).toEqual({ ok: true });
});

it('rejects a missing Authorization header', () => {
const result = validateAuthToken(undefined, ['secret123']);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ status: 401 });
});

it('rejects a header without the Bearer scheme', () => {
const result = validateAuthToken('secret123', ['secret123']);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ status: 401 });
});

it('rejects a wrong scheme such as Basic auth', () => {
const result = validateAuthToken('Basic dXNlcjpwYXNz', ['secret123']);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ status: 401 });
});

it('rejects an incorrect token', () => {
const result = validateAuthToken('Bearer wrong-token', ['secret123']);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ status: 401 });
});

it('rejects an empty bearer token', () => {
const result = validateAuthToken('Bearer ', ['secret123']);
expect(result.ok).toBe(false);
});

it('rejects a token differing only in length from a configured one', () => {
const result = validateAuthToken('Bearer secret1234extra', ['secret123']);
expect(result.ok).toBe(false);
});

it('is case sensitive on token comparison', () => {
const result = validateAuthToken('Bearer SECRET123', ['secret123']);
expect(result.ok).toBe(false);
});
});
61 changes: 61 additions & 0 deletions src/utils/auth-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { timingSafeEqual } from "node:crypto";

/**
* Result of validating an HTTP request's `Authorization` header against the
* configured bearer token allow-list.
*/
export type AuthTokenValidation =
| { ok: true }
| { ok: false; status: 401; message: string };

const BEARER_PREFIX = "Bearer ";

/**
* Constant-time string equality. `timingSafeEqual` throws on unequal-length
* buffers, so unequal lengths are rejected up front — this leaks only the
* length of the configured token, not which bytes matched, which is the same
* trade-off `timingSafeEqual` itself makes.
*/
function constantTimeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

/**
* Bearer token auth for the HTTP transport (issue #66): "anyone with the
* server URL can access the database." An empty `tokens` list means auth is
* disabled — configuring a token is itself the opt-in (see
* `resolveAuthTokens()` in `src/config/env.ts`), so there is no separate
* "--require-auth" flag to forget to set.
*
* This intentionally stops at a shared-secret allow-list rather than
* implementing the MCP spec's full OAuth 2.1 resource-server model (RFC 9728
* protected-resource metadata, authorization-server discovery, dynamic client
* registration, PKCE). That machinery solves multi-tenant identity
* federation; DBHub's actual gap is coarser — "is this request from someone
* who has the secret," not "who is this user and what scopes do they have."
*/
export function validateAuthToken(
authorizationHeader: string | undefined,
tokens: string[]
): AuthTokenValidation {
if (tokens.length === 0) return { ok: true };

if (!authorizationHeader || !authorizationHeader.startsWith(BEARER_PREFIX)) {
return {
ok: false,
status: 401,
message: "Missing or malformed Authorization header. Expected: Bearer <token>",
};
}

const presented = authorizationHeader.slice(BEARER_PREFIX.length);
const matches = tokens.some((token) => constantTimeEqual(presented, token));
if (!matches) {
return { ok: false, status: 401, message: "Invalid bearer token" };
}

return { ok: true };
}
Loading