@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
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
|
1517
|
+
let interfaces = {};
|
|
1484
1518
|
try {
|
|
1485
|
-
|
|
1519
|
+
interfaces = os.networkInterfaces();
|
|
1486
1520
|
} catch {
|
|
1487
|
-
|
|
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 (
|
|
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:
|
|
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-
|
|
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");
|
package/package.json
CHANGED