@bytebase/dbhub 0.22.3 → 0.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,8 @@ npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
93
93
  ```
94
94
 
95
95
  > 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`.
96
98
 
97
99
  See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.
98
100
 
@@ -733,6 +733,21 @@ function resolveHost() {
733
733
  }
734
734
  return { host: "0.0.0.0", source: "default" };
735
735
  }
736
+ function resolveAllowedHosts() {
737
+ const args = parseCommandLineArgs();
738
+ const cliValue = requireFlagValue("allowed-hosts", args, "db.internal,app.example.com");
739
+ if (cliValue !== void 0) {
740
+ return { hosts: splitHostList(cliValue), source: "command line argument" };
741
+ }
742
+ const envValue = process.env.DBHUB_ALLOWED_HOSTS?.trim();
743
+ if (envValue) {
744
+ return { hosts: splitHostList(envValue), source: "environment variable" };
745
+ }
746
+ return { hosts: [], source: "default" };
747
+ }
748
+ function splitHostList(value) {
749
+ return value.split(",").map((h) => h.trim()).filter((h) => h.length > 0);
750
+ }
736
751
  function redactDSN(dsn) {
737
752
  try {
738
753
  const url = new URL(dsn);
@@ -1094,6 +1109,92 @@ function validateToolsConfig(tools, sources, configPath) {
1094
1109
  }
1095
1110
  }
1096
1111
  }
1112
+ function getRawDSNQueryParam(dsn, key) {
1113
+ const queryStart = dsn.indexOf("?");
1114
+ if (queryStart === -1) {
1115
+ return null;
1116
+ }
1117
+ for (const pair of dsn.substring(queryStart + 1).split("&")) {
1118
+ if (pair === "") {
1119
+ continue;
1120
+ }
1121
+ const eq = pair.indexOf("=");
1122
+ const rawKey = eq === -1 ? pair : pair.substring(0, eq);
1123
+ if (rawKey === key) {
1124
+ const rawValue = eq === -1 ? "" : pair.substring(eq + 1);
1125
+ try {
1126
+ return decodeURIComponent(rawValue);
1127
+ } catch {
1128
+ return rawValue;
1129
+ }
1130
+ }
1131
+ }
1132
+ return null;
1133
+ }
1134
+ function validateDSNFieldConflicts(source, configPath) {
1135
+ const conflict = (field, fieldValue, dsnValue) => {
1136
+ throw new Error(
1137
+ `Configuration file ${configPath}: source '${source.id}' has conflicting ${field}: the DSN specifies '${dsnValue}' but the ${field} field is '${fieldValue}'. Set ${field} in only one place, or make the two values match.`
1138
+ );
1139
+ };
1140
+ const info = parseConnectionInfoFromDSN(source.dsn);
1141
+ if (source.type && info?.type && source.type !== info.type) {
1142
+ conflict("type", source.type, info.type);
1143
+ }
1144
+ if (info?.type === "sqlite") {
1145
+ return;
1146
+ }
1147
+ let url;
1148
+ try {
1149
+ url = new SafeURL(source.dsn);
1150
+ } catch {
1151
+ return;
1152
+ }
1153
+ if (info) {
1154
+ if (source.host && info.host && source.host.toLowerCase() !== info.host.toLowerCase()) {
1155
+ conflict("host", source.host, info.host);
1156
+ }
1157
+ if (source.port !== void 0 && info.port !== void 0 && source.port !== info.port) {
1158
+ conflict("port", String(source.port), String(info.port));
1159
+ }
1160
+ if (source.database && info.database && source.database !== info.database) {
1161
+ conflict("database", source.database, info.database);
1162
+ }
1163
+ if (source.user && info.user && source.user !== info.user) {
1164
+ conflict("user", source.user, info.user);
1165
+ }
1166
+ }
1167
+ if (source.password && source.password !== url.password) {
1168
+ if (!url.password) {
1169
+ throw new Error(
1170
+ `Configuration file ${configPath}: source '${source.id}' has a 'password' field but the DSN has no password. The field is ignored at connection time \u2014 add the password to the DSN, or use individual connection parameters instead of a DSN.`
1171
+ );
1172
+ }
1173
+ throw new Error(
1174
+ `Configuration file ${configPath}: source '${source.id}' has a 'password' field that conflicts with the password in the DSN. Set the password in only one place.`
1175
+ );
1176
+ }
1177
+ const dsnSslmode = getRawDSNQueryParam(source.dsn, "sslmode");
1178
+ if (source.sslmode && dsnSslmode !== null && dsnSslmode !== source.sslmode) {
1179
+ conflict("sslmode", source.sslmode, dsnSslmode);
1180
+ }
1181
+ const dsnSslrootcert = getRawDSNQueryParam(source.dsn, "sslrootcert");
1182
+ if (source.sslrootcert && dsnSslrootcert !== null && expandHomeDir(source.sslrootcert) !== expandHomeDir(dsnSslrootcert)) {
1183
+ conflict("sslrootcert", expandHomeDir(source.sslrootcert), expandHomeDir(dsnSslrootcert));
1184
+ }
1185
+ const dsnInstanceName = getRawDSNQueryParam(source.dsn, "instanceName");
1186
+ if (source.instanceName && dsnInstanceName !== null && dsnInstanceName !== source.instanceName) {
1187
+ conflict("instanceName", source.instanceName, dsnInstanceName);
1188
+ }
1189
+ const dsnAuthentication = getRawDSNQueryParam(source.dsn, "authentication");
1190
+ if (source.authentication && dsnAuthentication !== null && dsnAuthentication !== source.authentication) {
1191
+ conflict("authentication", source.authentication, dsnAuthentication);
1192
+ }
1193
+ const dsnDomain = getRawDSNQueryParam(source.dsn, "domain");
1194
+ if (source.domain && dsnDomain !== null && dsnDomain !== source.domain) {
1195
+ conflict("domain", source.domain, dsnDomain);
1196
+ }
1197
+ }
1097
1198
  function validateSourceConfig(source, configPath) {
1098
1199
  const hasConnectionParams = source.type && (source.type === "sqlite" ? source.database : source.host);
1099
1200
  if (!source.dsn && !hasConnectionParams) {
@@ -1176,6 +1277,9 @@ function validateSourceConfig(source, configPath) {
1176
1277
  );
1177
1278
  }
1178
1279
  }
1280
+ if (source.dsn) {
1281
+ validateDSNFieldConflicts(source, configPath);
1282
+ }
1179
1283
  if (source.sslrootcert !== void 0) {
1180
1284
  if (source.sslmode !== "verify-ca" && source.sslmode !== "verify-full") {
1181
1285
  throw new Error(
@@ -1350,9 +1454,51 @@ function expandHomeDir(filePath) {
1350
1454
  }
1351
1455
  return filePath;
1352
1456
  }
1457
+ function mergeSourceFieldsIntoDSN(dsn, source) {
1458
+ if (source.type === "sqlite") {
1459
+ return dsn;
1460
+ }
1461
+ try {
1462
+ new SafeURL(dsn);
1463
+ } catch {
1464
+ return dsn;
1465
+ }
1466
+ const hasParam = (key) => getRawDSNQueryParam(dsn, key) !== null;
1467
+ const additions = [];
1468
+ if (source.type === "sqlserver") {
1469
+ if (source.instanceName && !hasParam("instanceName")) {
1470
+ additions.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
1471
+ }
1472
+ if (source.authentication && !hasParam("authentication")) {
1473
+ additions.push(`authentication=${encodeURIComponent(source.authentication)}`);
1474
+ }
1475
+ if (source.domain && !hasParam("domain")) {
1476
+ additions.push(`domain=${encodeURIComponent(source.domain)}`);
1477
+ }
1478
+ }
1479
+ if (source.sslmode && !hasParam("sslmode")) {
1480
+ additions.push(`sslmode=${source.sslmode}`);
1481
+ }
1482
+ if (source.sslrootcert && source.type === "postgres" && (source.sslmode === "verify-ca" || source.sslmode === "verify-full") && !hasParam("sslrootcert")) {
1483
+ const expandedCertPath = expandHomeDir(source.sslrootcert);
1484
+ additions.push(`sslrootcert=${encodeURIComponent(expandedCertPath)}`);
1485
+ }
1486
+ if (additions.length === 0) {
1487
+ return dsn;
1488
+ }
1489
+ let separator;
1490
+ if (!dsn.includes("?")) {
1491
+ separator = "?";
1492
+ } else if (dsn.endsWith("?") || dsn.endsWith("&")) {
1493
+ separator = "";
1494
+ } else {
1495
+ separator = "&";
1496
+ }
1497
+ return `${dsn}${separator}${additions.join("&")}`;
1498
+ }
1353
1499
  function buildDSNFromSource(source) {
1354
1500
  if (source.dsn) {
1355
- return source.dsn;
1501
+ return mergeSourceFieldsIntoDSN(source.dsn, source);
1356
1502
  }
1357
1503
  if (!source.type) {
1358
1504
  throw new Error(
@@ -1434,6 +1580,53 @@ async function generateRdsAuthToken(params) {
1434
1580
  return signer.getAuthToken();
1435
1581
  }
1436
1582
 
1583
+ // src/utils/error-classifier.ts
1584
+ var TUNNEL_ERROR_MARKER = "__dbhubSSHTunnelError";
1585
+ var NETWORK_CODES = /* @__PURE__ */ new Set([
1586
+ "ECONNREFUSED",
1587
+ "ETIMEDOUT",
1588
+ "ENOTFOUND",
1589
+ "EHOSTUNREACH",
1590
+ "ENETUNREACH",
1591
+ "ECONNRESET"
1592
+ ]);
1593
+ var AUTH_CODES = {
1594
+ postgres: ["28P01", "28000"],
1595
+ mysql: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
1596
+ mariadb: ["ER_ACCESS_DENIED_ERROR", 1045, 1698],
1597
+ sqlserver: ["ELOGIN"],
1598
+ sqlite: []
1599
+ // no network/auth layer
1600
+ };
1601
+ function unreachableMessage(sourceId) {
1602
+ return `Source '${sourceId}' is unreachable. Verify the database is running and reachable (host, port, network), then retry.`;
1603
+ }
1604
+ function authMessage(sourceId) {
1605
+ return `Authentication failed for source '${sourceId}'. Verify the credentials/access for this source are valid, then retry.`;
1606
+ }
1607
+ function tunnelMessage(sourceId) {
1608
+ return `SSH tunnel for source '${sourceId}' failed to establish. Verify SSH host/credentials and bastion reachability, then retry.`;
1609
+ }
1610
+ function classifyConnectionError(error, connectorType, sourceId) {
1611
+ if (!error || typeof error !== "object") {
1612
+ return null;
1613
+ }
1614
+ const err = error;
1615
+ if (err[TUNNEL_ERROR_MARKER] === true) {
1616
+ return { code: "TUNNEL_FAILED", message: tunnelMessage(sourceId) };
1617
+ }
1618
+ const code = err.code;
1619
+ if (typeof code === "string" && NETWORK_CODES.has(code)) {
1620
+ return { code: "SOURCE_UNREACHABLE", message: unreachableMessage(sourceId) };
1621
+ }
1622
+ const authCodes = AUTH_CODES[connectorType];
1623
+ const errno = err.errno;
1624
+ if (typeof code === "string" && authCodes.includes(code) || typeof errno === "number" && authCodes.includes(errno)) {
1625
+ return { code: "AUTH_FAILED", message: authMessage(sourceId) };
1626
+ }
1627
+ return null;
1628
+ }
1629
+
1437
1630
  // src/connectors/manager.ts
1438
1631
  var managerInstance = null;
1439
1632
  var AWS_IAM_TOKEN_REFRESH_MS = 14 * 60 * 1e3;
@@ -1574,10 +1767,18 @@ var ConnectorManager = class {
1574
1767
  const targetHost = url.hostname;
1575
1768
  const targetPort = parseInt(url.port) || this.getDefaultPort(dsn);
1576
1769
  const tunnel = new SSHTunnel();
1577
- const tunnelInfo = await tunnel.establish(sshConfig, {
1578
- targetHost,
1579
- targetPort
1580
- });
1770
+ let tunnelInfo;
1771
+ try {
1772
+ tunnelInfo = await tunnel.establish(sshConfig, {
1773
+ targetHost,
1774
+ targetPort
1775
+ });
1776
+ } catch (error) {
1777
+ if (error && typeof error === "object") {
1778
+ error[TUNNEL_ERROR_MARKER] = true;
1779
+ }
1780
+ throw error;
1781
+ }
1581
1782
  url.hostname = "127.0.0.1";
1582
1783
  url.port = tunnelInfo.localPort.toString();
1583
1784
  actualDSN = url.toString();
@@ -2185,11 +2386,13 @@ export {
2185
2386
  resolveTransport,
2186
2387
  resolvePort,
2187
2388
  resolveHost,
2389
+ resolveAllowedHosts,
2188
2390
  resolveSourceConfigs,
2189
2391
  BUILTIN_TOOL_EXECUTE_SQL,
2190
2392
  BUILTIN_TOOL_SEARCH_OBJECTS,
2191
2393
  loadTomlConfig,
2192
2394
  resolveTomlConfigPath,
2395
+ classifyConnectionError,
2193
2396
  ConnectorManager,
2194
2397
  mapArgumentsToArray,
2195
2398
  ToolRegistry,
package/dist/index.js CHANGED
@@ -3,17 +3,19 @@ import {
3
3
  BUILTIN_TOOL_EXECUTE_SQL,
4
4
  BUILTIN_TOOL_SEARCH_OBJECTS,
5
5
  ConnectorManager,
6
+ classifyConnectionError,
6
7
  getToolRegistry,
7
8
  initializeToolRegistry,
8
9
  isDemoMode,
9
10
  loadTomlConfig,
10
11
  mapArgumentsToArray,
12
+ resolveAllowedHosts,
11
13
  resolveHost,
12
14
  resolvePort,
13
15
  resolveSourceConfigs,
14
16
  resolveTomlConfigPath,
15
17
  resolveTransport
16
- } from "./chunk-MEBWJMLY.js";
18
+ } from "./chunk-IOKERPF2.js";
17
19
  import {
18
20
  loadConnectors
19
21
  } from "./chunk-WVVMH6FJ.js";
@@ -248,6 +250,20 @@ function trackToolRequest(metadata, startTime, extra, success, error) {
248
250
  error
249
251
  });
250
252
  }
253
+ function tryClassifyConnectionError(error, rawSourceId, displaySourceId) {
254
+ let connectorType;
255
+ try {
256
+ connectorType = ConnectorManager.getSourceConfig(rawSourceId)?.type;
257
+ } catch {
258
+ return null;
259
+ }
260
+ if (!connectorType) return null;
261
+ const classified = classifyConnectionError(error, connectorType, displaySourceId);
262
+ if (!classified) return null;
263
+ return createToolErrorResponse(classified.message, classified.code, {
264
+ source_id: displaySourceId
265
+ });
266
+ }
251
267
 
252
268
  // src/tools/execute-sql.ts
253
269
  var executeSqlSchema = {
@@ -292,6 +308,8 @@ function createExecuteSqlToolHandler(sourceId) {
292
308
  } catch (error) {
293
309
  success = false;
294
310
  errorMessage = error.message;
311
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
312
+ if (classified) return classified;
295
313
  return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
296
314
  } finally {
297
315
  trackToolRequest(
@@ -313,11 +331,11 @@ function createExecuteSqlToolHandler(sourceId) {
313
331
  import { z as z2 } from "zod";
314
332
  var searchDatabaseObjectsSchema = {
315
333
  object_type: z2.enum(["schema", "table", "view", "column", "procedure", "function", "index"]).describe("Object type to search"),
316
- pattern: z2.string().optional().default("%").describe("LIKE pattern (% = any chars, _ = one char). Default: %"),
334
+ pattern: z2.string().optional().default("%").describe("LIKE pattern (% = any chars, _ = one char)"),
317
335
  schema: z2.string().optional().describe("Filter to schema"),
318
336
  table: z2.string().optional().describe("Filter to table (requires schema; column/index only)"),
319
337
  detail_level: z2.enum(["names", "summary", "full"]).default("names").describe("Detail: names (minimal), summary (metadata), full (all)"),
320
- limit: z2.number().int().positive().max(1e3).default(100).describe("Max results (default: 100, max: 1000)")
338
+ limit: z2.number().int().positive().max(1e3).default(100).describe("Max results")
321
339
  };
322
340
  function likePatternToRegex(pattern) {
323
341
  const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".");
@@ -771,6 +789,8 @@ function createSearchDatabaseObjectsToolHandler(sourceId) {
771
789
  } catch (error) {
772
790
  success = false;
773
791
  errorMessage = error.message;
792
+ const classified = tryClassifyConnectionError(error, sourceId, effectiveSourceId);
793
+ if (classified) return classified;
774
794
  return createToolErrorResponse(
775
795
  `Error searching database objects: ${errorMessage}`,
776
796
  "SEARCH_ERROR"
@@ -875,7 +895,7 @@ function getSearchObjectsMetadata(sourceId) {
875
895
  const toolName = isSingleSource ? "search_objects" : `search_objects_${normalizeSourceId(sourceId)}`;
876
896
  const title = isSingleSource ? `Search Database Objects (${dbType})` : `Search Database Objects on ${sourceId} (${dbType})`;
877
897
  const userDescPrefix = buildSourceDescriptionPrefix(sourceConfig.description);
878
- const description = isSingleSource ? `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the ${dbType} database` : `${userDescPrefix}Search and list database objects (schemas, tables, columns, procedures, functions, indexes) on the '${sourceId}' ${dbType} database`;
898
+ const description = isSingleSource ? `${userDescPrefix}Search and list database objects on the ${dbType} database` : `${userDescPrefix}Search and list database objects on the '${sourceId}' ${dbType} database`;
879
899
  return {
880
900
  name: toolName,
881
901
  description,
@@ -916,7 +936,7 @@ function buildSearchObjectsTool(sourceId) {
916
936
  name: "object_type",
917
937
  type: "string",
918
938
  required: true,
919
- description: "Object type to search"
939
+ description: "Object type to search: schema, table, view, column, procedure, function, index"
920
940
  },
921
941
  {
922
942
  name: "pattern",
@@ -1064,6 +1084,8 @@ function createCustomToolHandler(toolConfig) {
1064
1084
  } catch (error) {
1065
1085
  success = false;
1066
1086
  errorMessage = error.message;
1087
+ const classified = tryClassifyConnectionError(error, toolConfig.source, toolConfig.source);
1088
+ if (classified) return classified;
1067
1089
  if (error instanceof z4.ZodError) {
1068
1090
  const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
1069
1091
  errorMessage = `Parameter validation failed: ${issues}`;
@@ -1469,22 +1491,78 @@ Detected change in ${configPath}, reloading configuration...`);
1469
1491
  }
1470
1492
 
1471
1493
  // src/utils/cross-origin.ts
1494
+ import os from "node:os";
1472
1495
  var INVALID_HOST_CHARS = /[\s/\\@?#]/;
1473
- function validateOrigin(originHeader, hostHeader) {
1474
- if (originHeader === void 0) return { ok: true };
1475
- const trimmedOrigin = originHeader.trim();
1476
- if (!trimmedOrigin) {
1477
- return { ok: false, status: 400, message: "Malformed Origin header" };
1496
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
1497
+ var ALLOW_ANY_HOST = "*";
1498
+ function normalizeHost(raw) {
1499
+ const trimmed = raw.trim();
1500
+ if (!trimmed) return null;
1501
+ if (trimmed === ALLOW_ANY_HOST) return ALLOW_ANY_HOST;
1502
+ if (INVALID_HOST_CHARS.test(trimmed)) return null;
1503
+ try {
1504
+ const hostname = new URL(`http://${trimmed}`).hostname.toLowerCase();
1505
+ return hostname || null;
1506
+ } catch {
1507
+ return null;
1478
1508
  }
1479
- const trimmedHost = (hostHeader ?? "").trim();
1480
- if (!trimmedHost || INVALID_HOST_CHARS.test(trimmedHost)) {
1481
- return { ok: false, status: 400, message: "Malformed Host header" };
1509
+ }
1510
+ function getSelfHosts() {
1511
+ const hosts = [];
1512
+ try {
1513
+ const hostname = os.hostname().trim();
1514
+ if (hostname) hosts.push(hostname);
1515
+ } catch {
1482
1516
  }
1483
- let originHostname;
1517
+ let interfaces = {};
1484
1518
  try {
1485
- originHostname = new URL(trimmedOrigin).hostname.toLowerCase();
1519
+ interfaces = os.networkInterfaces();
1486
1520
  } catch {
1487
- return { ok: false, status: 400, message: "Malformed Origin header" };
1521
+ }
1522
+ for (const addrs of Object.values(interfaces)) {
1523
+ for (const addr of addrs ?? []) {
1524
+ if (!addr.address || addr.internal) continue;
1525
+ const isIPv6 = addr.family === "IPv6" || addr.family === 6;
1526
+ if (isIPv6) {
1527
+ const bare = addr.address.split("%")[0];
1528
+ if (bare.toLowerCase().startsWith("fe80")) continue;
1529
+ hosts.push(`[${bare}]`);
1530
+ } else {
1531
+ hosts.push(addr.address);
1532
+ }
1533
+ }
1534
+ }
1535
+ return hosts;
1536
+ }
1537
+ function buildAllowedHosts(configured = [], bindHost, selfHosts = []) {
1538
+ const normalizedConfigured = configured.map(normalizeHost).filter((h) => h !== null);
1539
+ if (normalizedConfigured.includes(ALLOW_ANY_HOST)) {
1540
+ return /* @__PURE__ */ new Set([ALLOW_ANY_HOST]);
1541
+ }
1542
+ const hosts = /* @__PURE__ */ new Set();
1543
+ for (const h of LOOPBACK_HOSTS) {
1544
+ const normalized = normalizeHost(h);
1545
+ if (normalized) hosts.add(normalized);
1546
+ }
1547
+ const normalizedBind = bindHost ? normalizeHost(bindHost) : null;
1548
+ const bindIsWildcard = !normalizedBind || normalizedBind === "0.0.0.0" || normalizedBind === "[::]";
1549
+ if (normalizedBind && !bindIsWildcard) {
1550
+ hosts.add(normalizedBind);
1551
+ }
1552
+ if (bindIsWildcard) {
1553
+ for (const h of selfHosts) {
1554
+ const normalized = normalizeHost(h);
1555
+ if (normalized) hosts.add(normalized);
1556
+ }
1557
+ }
1558
+ for (const h of normalizedConfigured) hosts.add(h);
1559
+ return hosts;
1560
+ }
1561
+ function validateOrigin(originHeader, hostHeader, allowedHosts) {
1562
+ const allowAny = allowedHosts.has(ALLOW_ANY_HOST);
1563
+ const trimmedHost = (hostHeader ?? "").trim();
1564
+ if (!trimmedHost || INVALID_HOST_CHARS.test(trimmedHost)) {
1565
+ return { ok: false, status: 400, message: "Malformed Host header" };
1488
1566
  }
1489
1567
  let hostname;
1490
1568
  try {
@@ -1495,11 +1573,32 @@ function validateOrigin(originHeader, hostHeader) {
1495
1573
  if (!hostname) {
1496
1574
  return { ok: false, status: 400, message: "Malformed Host header" };
1497
1575
  }
1498
- if (originHostname !== hostname) {
1576
+ if (!allowAny && !allowedHosts.has(hostname)) {
1577
+ return {
1578
+ ok: false,
1579
+ status: 403,
1580
+ 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.`
1581
+ };
1582
+ }
1583
+ if (originHeader === void 0) return { ok: true };
1584
+ const trimmedOrigin = originHeader.trim();
1585
+ if (!trimmedOrigin) {
1586
+ return { ok: false, status: 400, message: "Malformed Origin header" };
1587
+ }
1588
+ let originHostname;
1589
+ try {
1590
+ originHostname = new URL(trimmedOrigin).hostname.toLowerCase();
1591
+ } catch {
1592
+ return { ok: false, status: 400, message: "Malformed Origin header" };
1593
+ }
1594
+ if (!originHostname) {
1595
+ return { ok: false, status: 400, message: "Malformed Origin header" };
1596
+ }
1597
+ if (!allowAny && !allowedHosts.has(originHostname)) {
1499
1598
  return {
1500
1599
  ok: false,
1501
1600
  status: 403,
1502
- message: "Origin does not match Host header"
1601
+ message: `Origin '${originHostname}' is not allowed`
1503
1602
  };
1504
1603
  }
1505
1604
  return { ok: true };
@@ -1557,7 +1656,7 @@ See documentation for more details on configuring database connections.
1557
1656
  const sources = sourceConfigsData.sources;
1558
1657
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1559
1658
  await connectorManager.connectWithSources(sources);
1560
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-FZPB5LRL.js");
1659
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-L3ZIOUF2.js");
1561
1660
  initializeToolRegistry2({
1562
1661
  sources: sourceConfigsData.sources,
1563
1662
  tools: sourceConfigsData.tools
@@ -1578,6 +1677,7 @@ See documentation for more details on configuring database connections.
1578
1677
  const transportData = resolveTransport();
1579
1678
  const port = transportData.type === "http" ? resolvePort().port : null;
1580
1679
  const host = transportData.type === "http" ? resolveHost().host : null;
1680
+ const allowedHosts = transportData.type === "http" ? buildAllowedHosts(resolveAllowedHosts().hosts, host ?? void 0, getSelfHosts()) : /* @__PURE__ */ new Set();
1581
1681
  const activeModes = [];
1582
1682
  const modeDescriptions = [];
1583
1683
  const isDemo = isDemoMode();
@@ -1603,7 +1703,7 @@ See documentation for more details on configuring database connections.
1603
1703
  app.use(express.json());
1604
1704
  app.use((req, res, next) => {
1605
1705
  const origin = req.headers.origin;
1606
- const result = validateOrigin(origin, req.headers.host);
1706
+ const result = validateOrigin(origin, req.headers.host, allowedHosts);
1607
1707
  if (!result.ok) {
1608
1708
  return res.status(result.status).json({
1609
1709
  error: result.status === 400 ? "Bad Request" : "Forbidden",
@@ -1669,6 +1769,11 @@ See documentation for more details on configuring database connections.
1669
1769
  const displayHost = boundHost.includes(":") ? `[${boundHost}]` : boundHost;
1670
1770
  const userHost = boundHost === "0.0.0.0" || boundHost === "::" ? "localhost" : displayHost;
1671
1771
  console.error(`HTTP server listening on ${displayHost}:${boundPort}`);
1772
+ if (allowedHosts.has(ALLOW_ANY_HOST)) {
1773
+ console.error("Allowed hosts: * (DNS-rebinding protection DISABLED \u2014 ensure DBHub is fronted by your own auth/proxy)");
1774
+ } else {
1775
+ console.error(`Allowed hosts: ${[...allowedHosts].join(", ")} (set --allowed-hosts to serve other hostnames)`);
1776
+ }
1672
1777
  if (process.env.NODE_ENV === "development") {
1673
1778
  console.error("Development mode detected!");
1674
1779
  console.error(" Workbench dev server (with HMR): http://localhost:5173");
@@ -2,7 +2,7 @@ import {
2
2
  ToolRegistry,
3
3
  getToolRegistry,
4
4
  initializeToolRegistry
5
- } from "./chunk-MEBWJMLY.js";
5
+ } from "./chunk-IOKERPF2.js";
6
6
  import "./chunk-WVVMH6FJ.js";
7
7
  import "./chunk-C7WEAPX4.js";
8
8
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebase/dbhub",
3
- "version": "0.22.3",
3
+ "version": "0.22.5",
4
4
  "mcpName": "io.github.bytebase/dbhub",
5
5
  "description": "Minimal, token-efficient Database MCP Server for PostgreSQL, MySQL, SQL Server, SQLite, MariaDB",
6
6
  "repository": {