fix(connection): safely copy and redact connection credentials

This commit is contained in:
AiLi authored and GitHub committed 2026-09-24 12:19:02 +08:00
1 parent 81f963fe60
commit 1aa8b21a87
8 files changed
+450 -59

No files matched your search

@@ -78,6 +78,7 @@ import { isTableVGroupGroupableRowType, selectedTableVGroupMoveTargets, tableVGr
import { findTreeNodeById } from "@/lib/sql/newQueryContext";
import { resolveTableVGroupDropTarget, setTableVGroupDropTargetNodeId, tableVGroupDropTargetNodeId } from "@/lib/sidebar/sidebarTableVGroupDrag";
import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation";
import { redactConnectionStringSecrets } from "@/lib/connection/connectionStringRedaction";
import { isFocusSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { encodeSpannerResourcePath } from "@/lib/connection/spannerResourcePath";
import { hexToRgba } from "@/lib/common/color";
@@ -516,10 +517,6 @@ function isLocalFileConnection(config: Pick<ConnectionConfig, "db_type" | "port"
return config.db_type === "sqlite" || config.db_type === "duckdb" || config.db_type === "access" || (config.db_type === "h2" && config.port === 0);
}
function redactedConnectionString(value: string): string {
return value.replace(/(:\/\/[^/\s:@?#;]+):([^@\s/?#;]+)@/g, "$1:***@").replace(/([?&;](?:password|pwd|pass|token|secret|key)=)[^&;]*/gi, "$1***");
}
function hostForDisplay(host: string): string {
if (!host.includes(":") || host.startsWith("[") || host.includes("://") || host.includes(",")) return host;
return `[${host}]`;
@@ -534,11 +531,11 @@ function tooltipDatabaseValue(config: ConnectionConfig): string {
function connectionTooltipUrl(config: ConnectionConfig): string {
const explicit = cleanTooltipValue(config.connection_string);
if (explicit) return redactedConnectionString(explicit);
if (explicit) return redactConnectionStringSecrets(explicit);
const host = cleanTooltipValue(config.host);
if (!host) return "";
if (host.includes("://")) return redactedConnectionString(host);
if (host.includes("://")) return redactConnectionStringSecrets(host);
if (isLocalFileConnection(config)) {
if (config.db_type === "access") return `jdbc:ucanaccess://${host}`;
@@ -554,7 +551,7 @@ function connectionTooltipUrl(config: ConnectionConfig): string {
const path = database ? `/${encodedDatabase}` : "";
const params = cleanTooltipValue(config.url_params);
const query = params ? (params.startsWith("?") ? params : `?${params}`) : "";
return redactedConnectionString(`${scheme}://${userInfo}${hostForDisplay(host)}${port}${path}${query}`);
return redactConnectionStringSecrets(`${scheme}://${userInfo}${hostForDisplay(host)}${port}${path}${query}`);
}
const detailTooltip = computed(() => {
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { redactConnectionStringSecrets } from "@/lib/connection/connectionStringRedaction";
describe("shared connection string redaction", () => {
it.each([
["redis://:synthetic-secret@cache.example.com:6379/0", "redis://:***@cache.example.com:6379/0"],
["jdbc:mysql://db.example.com/app?pass%77ord=synthetic-secret&connectTimeout=5000", "jdbc:mysql://db.example.com/app?pass%77ord=***&connectTimeout=5000"],
["postgresql://db.example.com/app?sslpassword=key-secret&oauth_client_secret=client-secret", "postgresql://db.example.com/app?sslpassword=***&oauth_client_secret=***"],
["jdbc:sqlserver://db.example.com;password={head;tail};encrypt=true", "jdbc:sqlserver://db.example.com;password=***;encrypt=true"],
["postgresql://db.example.com/app?passcode=synthetic-secret&role=analyst", "postgresql://db.example.com/app?passcode=***&role=analyst"],
["postgresql://db.example.com/app?apikey=synthetic-secret", "postgresql://db.example.com/app?apikey=***"],
["postgresql://db.example.com/app?api_key=synthetic-secret", "postgresql://db.example.com/app?api_key=***"],
["postgresql://db.example.com/app?api%5Fkey=synthetic-secret", "postgresql://db.example.com/app?api%5Fkey=***"],
["postgresql://db.example.com/app?access_token=synthetic-secret", "postgresql://db.example.com/app?access_token=***"],
["postgresql://db.example.com/app?client_secret=synthetic-secret", "postgresql://db.example.com/app?client_secret=***"],
["postgresql://db.example.com/app?passphrase=synthetic-secret", "postgresql://db.example.com/app?passphrase=***"],
])("removes credentials from %s", (connectionString, expected) => {
expect(redactConnectionStringSecrets(connectionString)).toBe(expected);
});
it("leaves unrelated and malformed-escaped non-secret parameters unchanged", () => {
const url = "postgresql://db.example.com/app?application%ZZ=visible&sslmode=require";
expect(redactConnectionStringSecrets(url)).toBe(url);
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildConnectionUrlCopy, CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS, connectionSupportsUrlCopy, connectionUrlCopyFormats, type ConnectionUrlCopyConfig } from "@/lib/connection/connectionUrlBuilder";
import { parseConnectionUrl } from "@/lib/connection/connectionUrl";
function config(overrides: Partial<ConnectionUrlCopyConfig>): ConnectionUrlCopyConfig {
return {
@@ -15,6 +16,9 @@ function config(overrides: Partial<ConnectionUrlCopyConfig>): ConnectionUrlCopyC
};
}
const ASCII_SYMBOLS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
const ENCODED_ASCII_SYMBOLS = "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~";
describe("connectionUrlCopyFormats", () => {
it("lists all formats for a PostgreSQL connection with a stored password", () => {
expect(connectionUrlCopyFormats(config({}))).toEqual(["url", "urlWithPassword", "jdbcUrl", "jdbcUrlWithCredentials", "hostPort", "dsn", "dsnWithPassword", "psqlCommand"]);
@@ -28,6 +32,13 @@ describe("connectionUrlCopyFormats", () => {
expect(formats).toEqual(["url", "jdbcUrl", "hostPort", "dsn", "psqlCommand"]);
});
it("offers an unredacted URL when its query contains the only secret", () => {
const search = config({ db_type: "elasticsearch", host: "search.example.com", port: 9200, username: "", password: "", database: "", url_params: "token=abc&pretty" });
expect(buildConnectionUrlCopy(search, "url")).toBe("http://search.example.com:9200?token=***&pretty");
expect(buildConnectionUrlCopy(search, "urlWithPassword")).toBe("http://search.example.com:9200?token=abc&pretty");
expect(connectionUrlCopyFormats(search)).toContain("urlWithPassword");
});
it("hides everything for file-based connections", () => {
expect(connectionSupportsUrlCopy(config({ db_type: "sqlite", host: "/data/app.db", port: 0 }))).toBe(false);
expect(connectionUrlCopyFormats(config({ db_type: "duckdb", host: "/data/app.duckdb", port: 0 }))).toEqual([]);
@@ -64,6 +75,19 @@ describe("buildConnectionUrlCopy standard URL", () => {
expect(text).toBe("postgresql://user%40corp:p%40ss%3Aw%2Ford%23@db.example.com:5432/app%20db");
});
it("round-trips every ASCII symbol in URL credentials", () => {
const text = buildConnectionUrlCopy(config({ password: ASCII_SYMBOLS }), "urlWithPassword");
expect(text).toBe(`postgresql://app_user:${ENCODED_ASCII_SYMBOLS}@db.example.com:5432/appdb`);
expect(parseConnectionUrl(text ?? "").password).toBe(ASCII_SYMBOLS);
});
it("preserves credentials that already look percent-encoded", () => {
const password = "%25%40%5E";
const text = buildConnectionUrlCopy(config({ password }), "urlWithPassword");
expect(text).toBe("postgresql://app_user:%2525%2540%255E@db.example.com:5432/appdb");
expect(parseConnectionUrl(text ?? "").password).toBe(password);
});
it("appends url_params and adds sslmode when ssl is enabled", () => {
expect(buildConnectionUrlCopy(config({ url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc");
expect(buildConnectionUrlCopy(config({ ssl: true, url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc&sslmode=require");
@@ -112,6 +136,14 @@ describe("buildConnectionUrlCopy standard URL", () => {
// even though no password field is stored.
expect(connectionUrlCopyFormats(jdbcConfig)).toEqual(["url", "urlWithPassword"]);
});
it("redacts an explicit URL with a password but no username", () => {
const redis = config({ db_type: "redis", host: "cache.example.com", port: 6379, username: "", password: "", database: "0", connection_string: "redis://:synthetic-secret@cache.example.com:6379/0" });
expect(buildConnectionUrlCopy(redis, "url")).toBe("redis://:***@cache.example.com:6379/0");
expect(buildConnectionUrlCopy(redis, "urlWithPassword")).toBe(redis.connection_string);
expect(connectionUrlCopyFormats(redis)).toContain("urlWithPassword");
expect(buildConnectionUrlCopy({ ...redis, connection_string: "redis://cache.example.com:6379/0" }, "url")).toBe("redis://cache.example.com:6379/0");
});
});
describe("buildConnectionUrlCopy JDBC URL", () => {
@@ -120,6 +152,33 @@ describe("buildConnectionUrlCopy JDBC URL", () => {
expect(buildConnectionUrlCopy(config({}), "jdbcUrlWithCredentials")).toBe("jdbc:postgresql://db.example.com:5432/appdb?user=app_user&password=secret");
});
it.each([
{ db_type: "mysql", port: 3306 },
{ db_type: "postgres", port: 5432 },
] as const)("round-trips every ASCII symbol in $db_type JDBC credentials", ({ db_type, port }) => {
const text = buildConnectionUrlCopy(config({ db_type, port, password: ASCII_SYMBOLS }), "jdbcUrlWithCredentials");
expect(text).toContain(`password=${ENCODED_ASCII_SYMBOLS}`);
const encodedPassword = text?.match(/(?:[?&;,:]|^)password=([^&;,:]*)/i)?.[1];
expect(encodedPassword).toBe(ENCODED_ASCII_SYMBOLS);
expect(decodeURIComponent(encodedPassword ?? "")).toBe(ASCII_SYMBOLS);
});
it("does not double-decode percent-looking MySQL JDBC credentials", () => {
const password = "%25%40%5E";
const text = buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, password }), "jdbcUrlWithCredentials");
expect(text).toBe("jdbc:mysql://db.example.com:3306/appdb?user=app_user&password=%2525%2540%255E");
expect(parseConnectionUrl(text ?? "").password).toBe(password);
});
it("redacts a percent-encoded password property name accepted by the MySQL importer", () => {
const mysql = config({ db_type: "mysql", port: 3306, username: "", password: "", url_params: "pass%77ord=synthetic-secret&connectTimeout=5000" });
const jdbcWithCredentials = buildConnectionUrlCopy(mysql, "jdbcUrlWithCredentials")!;
expect(parseConnectionUrl(jdbcWithCredentials).password).toBe("synthetic-secret");
expect(buildConnectionUrlCopy(mysql, "url")).toContain("pass%77ord=***&connectTimeout=5000");
expect(buildConnectionUrlCopy(mysql, "jdbcUrl")).toContain("pass%77ord=***&connectTimeout=5000");
expect(connectionUrlCopyFormats(mysql)).toContain("jdbcUrlWithCredentials");
});
it("adds driver-specific ssl properties", () => {
expect(buildConnectionUrlCopy(config({ ssl: true }), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb?ssl=true");
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, ssl: true }), "jdbcUrl")).toBe("jdbc:mysql://db.example.com:3306/appdb?sslMode=REQUIRED");
@@ -186,6 +245,19 @@ describe("buildConnectionUrlCopy host:port, DSN and psql", () => {
expect(buildConnectionUrlCopy(config({ password: "" }), "dsnWithPassword")).toBe(buildConnectionUrlCopy(config({ password: "" }), "dsn"));
});
it.each(["password", "pass%77ord", "sslpassword", "oauth_client_secret", "passcode", "apikey", "api_key", "access_token", "client_secret", "passphrase"])("redacts a %s supplied only through DSN parameters", (key) => {
const postgres = config({ password: "", url_params: `application_name=svc&${key}=synthetic-secret&sslmode=require` });
expect(buildConnectionUrlCopy(postgres, "dsn")).toBe(`host=db.example.com port=5432 user=app_user dbname=appdb application_name=svc ${key}=*** sslmode=require`);
expect(buildConnectionUrlCopy(postgres, "dsnWithPassword")).toBe(`host=db.example.com port=5432 user=app_user dbname=appdb application_name=svc ${key}=synthetic-secret sslmode=require`);
expect(connectionUrlCopyFormats(postgres)).toContain("dsnWithPassword");
});
it("redacts both stored and parameter passwords in the plain DSN", () => {
const postgres = config({ url_params: "password=secondary-secret" });
expect(buildConnectionUrlCopy(postgres, "dsn")).toBe("host=db.example.com port=5432 user=app_user dbname=appdb password=***");
expect(buildConnectionUrlCopy(postgres, "dsnWithPassword")).toBe("host=db.example.com port=5432 user=app_user password=secret dbname=appdb password=secondary-secret");
});
it("builds a psql command without embedding the password", () => {
expect(buildConnectionUrlCopy(config({}), "psqlCommand")).toBe("psql -h db.example.com -p 5432 -U app_user -d appdb");
expect(buildConnectionUrlCopy(config({ database: undefined }), "psqlCommand", { database: "reporting" })).toBe("psql -h db.example.com -p 5432 -U app_user -d reporting");
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { buildConnectionUrlCopy, connectionUrlCopyFormats, type ConnectionUrlCopyConfig } from "@/lib/connection/connectionUrlBuilder";
import { parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { parseJdbcProperties } from "@/lib/connection/jdbcProperties";
const base: ConnectionUrlCopyConfig = { db_type: "sqlserver", host: "db.example.com", port: 1433, username: "user", password: "secret", database: "app", ssl: false };
const symbols = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
describe("dialect-specific JDBC credential copying", () => {
it.each([symbols, " pass;word} ", "%25%40%5E", "中文🔑", "}", "'", " "])("round-trips SQL Server credentials: %j", (password) => {
const url = buildConnectionUrlCopy({ ...base, username: symbols, password }, "jdbcUrlWithCredentials")!;
expect(parseConnectionUrl(url)).toMatchObject({ username: symbols, password });
});
it("uses Microsoft's brace escaping rather than URI escapes", () => {
expect(buildConnectionUrlCopy({ ...base, password: 'pass";{}word' }, "jdbcUrlWithCredentials")).toBe('jdbc:sqlserver://db.example.com:1433;databaseName=app;user=user;password={pass";{}}word}');
});
it("preserves legacy percent-encoded SQL Server properties when importing", () => {
expect(parseConnectionUrl("jdbc:sqlserver://db.example.com;databaseName=main%20db;user=admin%40corp;password=s%40cret")).toMatchObject({
database: "main db",
username: "admin@corp",
password: "s@cret",
});
});
it("keeps percent escapes literal in braced SQL Server properties and copied URLs", () => {
const config = { ...base, database: "db%20name", username: "user%40corp", password: "%21%25" };
const url = buildConnectionUrlCopy(config, "jdbcUrlWithCredentials")!;
expect(url).toContain("databaseName={db%20name};user={user%40corp};password={%21%25}");
expect(parseConnectionUrl(url)).toMatchObject({ database: config.database, username: config.username, password: config.password });
expect(parseConnectionUrl("jdbc:sqlserver://db.example.com;user=admin;password={%21%25}").password).toBe("%21%25");
});
it("quotes database names and preserves quoted additional properties", () => {
const url = buildConnectionUrlCopy({ ...base, database: "db;name}", url_params: "applicationName={a&b;c};encrypt=false", ssl: true }, "jdbcUrlWithCredentials")!;
expect(parseConnectionUrl(url)).toMatchObject({ database: "db;name}", urlParams: "applicationName={a&b;c};encrypt=false" });
expect(url).not.toContain("encrypt=true");
});
it.each(["password={open", "password={closed}tail", "password=bad{value"])("rejects malformed SQL Server properties: %s", (properties) => {
expect(() => parseConnectionUrl(`jdbc:sqlserver://db.example.com;${properties}`)).toThrow("Invalid SQL Server JDBC properties");
});
it.each([symbols, " pass,word' ", "%25%40%5E", "中文🔑", " "])("quotes Teradata credentials: %j", (password) => {
const url = buildConnectionUrlCopy({ ...base, db_type: "teradata", username: symbols, password }, "jdbcUrlWithCredentials")!;
const properties = parseJdbcProperties(url.slice(url.indexOf("/", "jdbc:teradata://".length) + 1), "teradata")!;
expect(properties.find(({ key }) => key === "user")?.value).toBe(symbols);
expect(properties.find(({ key }) => key === "password")?.value).toBe(password);
});
it("uses Teradata single-quote escaping", () => {
expect(buildConnectionUrlCopy({ ...base, db_type: "teradata", password: "a,b'c" }, "jdbcUrlWithCredentials")).toBe("jdbc:teradata://db.example.com/DBS_PORT=1433,DATABASE=app,user=user,password='a,b''c'");
});
it.each(["'", "'head", "tail'", "''"])("does not offer unsafe Teradata credential URLs: %j", (password) => {
const config = { ...base, db_type: "teradata" as const, password };
expect(buildConnectionUrlCopy(config, "jdbcUrlWithCredentials")).toBeNull();
expect(connectionUrlCopyFormats(config)).not.toContain("jdbcUrlWithCredentials");
expect(buildConnectionUrlCopy(config, "jdbcUrl")).not.toBeNull();
});
it("checks the normalized Teradata username for unsafe boundary quotes", () => {
const config = { ...base, db_type: "teradata" as const, username: " 'admin' ", password: "" };
expect(buildConnectionUrlCopy(config, "jdbcUrlWithCredentials")).toBeNull();
expect(connectionUrlCopyFormats(config)).not.toContain("jdbcUrlWithCredentials");
});
it.each(["oracle", "saphana", "exasol", "snowflake"] as const)("preserves existing encoding for unverified %s driver", (db_type) => {
expect(buildConnectionUrlCopy({ ...base, db_type, password: "a!b'c" }, "jdbcUrlWithCredentials")).toContain("password=a!b'c");
});
});
describe("JDBC secret redaction", () => {
it.each([
["jdbc:sqlserver://db.example.com;user=user;password={head;tail}}secret};encrypt=true", "jdbc:sqlserver://db.example.com;user=user;password=***;encrypt=true"],
["jdbc:teradata://db.example.com/USER=user,PASSWORD='head,tail''secret',CHARSET=UTF8", "jdbc:teradata://db.example.com/USER=user,PASSWORD=***,CHARSET=UTF8"],
["jdbc:teradata://db.example.com/PASSWORD=secret", "jdbc:teradata://db.example.com/PASSWORD=***"],
["jdbc:sqlserver://db.example.com;password={unclosed;secret", "jdbc:sqlserver://db.example.com;***"],
["jdbc:teradata://db.example.com/PASSWORD='unclosed,secret", "jdbc:teradata://db.example.com/***"],
["jdbc:sqlserver://db.example.com;password={a;b};PASSWORD=second;accessToken={token;tail}", "jdbc:sqlserver://db.example.com;password=***;PASSWORD=***;accessToken=***"],
])("redacts the entire secret in %s", (connection_string, redacted) => {
const connection = { ...base, db_type: "jdbc" as const, host: "", password: "", connection_string };
expect(buildConnectionUrlCopy(connection, "jdbcUrl")).toBe(redacted);
expect(buildConnectionUrlCopy(connection, "url")).toBe(redacted);
expect(buildConnectionUrlCopy(connection, "jdbcUrlWithCredentials")).toBe(connection_string);
expect(connectionUrlCopyFormats(connection)).toContain("urlWithPassword");
});
it.each([
{ db_type: "sqlserver" as const, url_params: "password={head;tail}" },
{ db_type: "teradata" as const, url_params: "PASSWORD='head,tail'" },
])("redacts secrets in additional $db_type properties", ({ db_type, url_params }) => {
const config = { ...base, db_type, username: "", password: "", url_params };
expect(buildConnectionUrlCopy(config, "jdbcUrl")).toContain("***");
expect(buildConnectionUrlCopy(config, "jdbcUrl")).not.toContain("tail");
expect(connectionUrlCopyFormats(config)).toContain("jdbcUrlWithCredentials");
});
it.each(["clientKeyPassword", "keyStoreSecret", "trustStorePassword"])("redacts SQL Server %s in copied URLs", (key) => {
const config = { ...base, username: "", password: "", url_params: `${key}={head;tail}` };
const jdbcUrl = buildConnectionUrlCopy(config, "jdbcUrl")!;
expect(jdbcUrl).toContain(`${key}=***`);
expect(jdbcUrl).not.toContain("head;tail");
const standardUrl = buildConnectionUrlCopy(config, "url")!;
expect(standardUrl).toContain(`${key}=***`);
expect(standardUrl).not.toContain("tail");
expect(buildConnectionUrlCopy(config, "jdbcUrlWithCredentials")).toContain("head;tail");
expect(connectionUrlCopyFormats(config)).toContain("jdbcUrlWithCredentials");
const explicit = { ...config, db_type: "jdbc" as const, host: "", url_params: "", connection_string: `jdbc:sqlserver://db.example.com;${key}={head;tail}` };
expect(buildConnectionUrlCopy(explicit, "url")).toBe(`jdbc:sqlserver://db.example.com;${key}=***`);
expect(buildConnectionUrlCopy(explicit, "jdbcUrl")).toBe(`jdbc:sqlserver://db.example.com;${key}=***`);
expect(connectionUrlCopyFormats(explicit)).toContain("urlWithPassword");
});
it("preserves SQL Server query delimiters around a redacted braced value", () => {
const config = { ...base, username: "", password: "", url_params: "applicationName={DBX; Client}&TRUSTSTOREPASSWORD={head;tail}&encrypt=false" };
expect(buildConnectionUrlCopy(config, "url")).toContain("?applicationName={DBX; Client}&TRUSTSTOREPASSWORD=***&encrypt=false");
expect(buildConnectionUrlCopy(config, "jdbcUrl")).toContain(";applicationName={DBX; Client};TRUSTSTOREPASSWORD=***;encrypt=false");
});
it("leaves a non-secret SQL Server standard URL query unchanged", () => {
const config = { ...base, username: "", password: "", url_params: "encrypt&applicationName=DBX" };
expect(buildConnectionUrlCopy(config, "url")).toBe("mssql://db.example.com:1433/app?encrypt&applicationName=DBX");
});
it("preserves valueless SQL Server query flags while redacting a later secret", () => {
const config = { ...base, username: "", password: "", url_params: "encrypt&trustStorePassword={head;tail}&applicationName=DBX" };
expect(buildConnectionUrlCopy(config, "url")).toBe("mssql://db.example.com:1433/app?encrypt&trustStorePassword=***&applicationName=DBX");
});
it("keeps a malformed non-secret standard query but fails closed on an ambiguous secret", () => {
const withoutSecret = { ...base, username: "", password: "", url_params: "encrypt&applicationName={open" };
expect(buildConnectionUrlCopy(withoutSecret, "url")).toBe("mssql://db.example.com:1433/app?encrypt&applicationName={open");
const withSecret = { ...withoutSecret, url_params: "encrypt&applicationName={open;trustStorePassword=hidden" };
expect(buildConnectionUrlCopy(withSecret, "url")).toBe("mssql://db.example.com:1433/app?***");
});
});
@@ -0,0 +1,12 @@
import { isSecretConnectionProperty, redactJdbcProperties, redactSqlServerQuery } from "@/lib/connection/jdbcProperties";
/** Shared by copyable connection strings and sidebar previews. */
export function redactConnectionStringSecrets(value: string): string {
// Password-only userinfo (for example redis://:password@host) is valid too.
const withoutUserInfo = value.replace(/(:\/\/[^/\s:@?#;]*):([^@\s/?#;]+)@/g, "$1:***@");
const properties = withoutUserInfo.match(/^(jdbc:sqlserver:\/\/[^;]*;)([\s\S]*)$/i) ?? withoutUserInfo.match(/^(jdbc:teradata:\/\/[^/]*\/)([\s\S]*)$/i);
if (properties) return properties[1] + redactJdbcProperties(properties[2], /^jdbc:sqlserver:/i.test(withoutUserInfo) ? "sqlserver" : "teradata");
const sqlServerQuery = withoutUserInfo.match(/^(mssql:\/\/[^?]*\?)([\s\S]*)$/i);
if (sqlServerQuery) return sqlServerQuery[1] + redactSqlServerQuery(sqlServerQuery[2]);
return withoutUserInfo.replace(/([?&;])([^=?&;]+)=([^&;]*)/g, (part, separator: string, key: string) => (isSecretConnectionProperty(key) ? `${separator}${key}=***` : part));
}
@@ -2,6 +2,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { h2JdbcUrlHasPasswordParam, h2JdbcUrlHasUserParam, parseH2JdbcUrl } from "@/lib/database/h2Connection";
import { damengSslFormConfig } from "@/lib/database/damengSslOptions";
import { normalizeRedisDatabaseValue } from "@/lib/redis/redisDatabaseIndex";
import { parseJdbcProperties } from "@/lib/connection/jdbcProperties";
export interface ParsedConnectionUrl {
name?: string;
@@ -462,16 +463,14 @@ function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null {
const profile = SCHEME_PROFILES.sqlserver;
const props = new Map<string, string>();
const urlParams: string[] = [];
for (const part of (match[3] || "").split(";")) {
if (!part) continue;
const [rawKey, ...rest] = part.split("=");
const key = rawKey.trim();
const value = rest.join("=");
const properties = parseJdbcProperties(match[3] || "", "sqlserver");
if (!properties) throw new Error("Invalid SQL Server JDBC properties");
for (const { key, value, raw: part, quoted } of properties) {
const normalizedKey = key.toLowerCase();
if (normalizedKey === "databasename" || normalizedKey === "database" || normalizedKey === "user") {
props.set(normalizedKey, value);
} else if (normalizedKey === "password") {
props.set(normalizedKey, value);
// Braces are literal JDBC values; unquoted %xx values retain DBX's old import behavior.
const importedValue = quoted ? value : decodeUrlPart(value);
if (normalizedKey === "databasename" || normalizedKey === "database" || normalizedKey === "user" || normalizedKey === "password") {
props.set(normalizedKey, importedValue);
} else {
urlParams.push(part);
}
@@ -484,9 +483,9 @@ function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null {
host: match[1],
port: match[2] ? Number(match[2]) : profile.defaultPort,
...(match[2] ? { portExplicit: true } : {}),
username: decodeUrlPart(props.get("user") || ""),
password: decodeUrlPart(props.get("password") || ""),
database: decodeUrlPart(props.get("databasename") || props.get("database") || "") || undefined,
username: props.get("user") || "",
password: props.get("password") || "",
database: props.get("databasename") || props.get("database") || undefined,
urlParams: urlParams.join(";"),
ssl: false,
};
@@ -1,6 +1,8 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect";
import { effectiveRedisDatabaseIndex } from "@/lib/redis/redisDatabaseIndex";
import { isSecretConnectionProperty, normalizeJdbcProperties, quoteJdbcProperty } from "@/lib/connection/jdbcProperties";
import { redactConnectionStringSecrets } from "@/lib/connection/connectionStringRedaction";
/**
* Builds copy-ready connection strings (standard URL / JDBC URL / libpq DSN /
@@ -153,6 +155,10 @@ function effectiveDatabase(config: ConnectionUrlCopyConfig, options?: Connection
return database;
}
function effectiveUsername(config: ConnectionUrlCopyConfig): string {
return config.username?.trim() ?? "";
}
function queryHasParam(params: string, keys: string[]): boolean {
if (!params) return false;
const wanted = new Set(keys.map((key) => key.toLowerCase()));
@@ -177,21 +183,25 @@ function shouldAppendPort(config: ConnectionUrlCopyConfig): boolean {
return Number(config.port) > 0 && !host.includes(",") && !host.includes("://");
}
function buildUserInfo(config: ConnectionUrlCopyConfig, includePassword: boolean): string {
const user = config.username?.trim() ?? "";
const password = config.password ?? "";
if (includePassword) {
if (user && password) return `${encodeURIComponent(user)}:${encodeURIComponent(password)}@`;
if (user) return `${encodeURIComponent(user)}@`;
if (password) return `:${encodeURIComponent(password)}@`;
return "";
}
return user ? `${encodeURIComponent(user)}@` : "";
/**
* RFC 3986 component encoding for credentials. `encodeURIComponent` leaves
* `!'()*` unescaped, so encode those too; only unreserved characters remain
* literal and every encoded credential can be decoded back byte-for-byte.
*/
function encodeCredential(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
}
/** Mirrors the sidebar tooltip redaction so shared strings never leak secrets. */
function redactConnectionStringSecrets(value: string): string {
return value.replace(/(:\/\/[^/\s:@?#;]+):([^@\s/?#;]+)@/g, "$1:***@").replace(/([?&;](?:password|pwd|pass|token|secret|key)=)[^&;]*/gi, "$1***");
function buildUserInfo(config: ConnectionUrlCopyConfig, includePassword: boolean): string {
const user = effectiveUsername(config);
const password = config.password ?? "";
if (includePassword) {
if (user && password) return `${encodeCredential(user)}:${encodeCredential(password)}@`;
if (user) return `${encodeCredential(user)}@`;
if (password) return `:${encodeCredential(password)}@`;
return "";
}
return user ? `${encodeCredential(user)}@` : "";
}
function standardUrlScheme(config: ConnectionUrlCopyConfig): string | null {
@@ -272,12 +282,12 @@ function jdbcPrefixFor(config: ConnectionUrlCopyConfig): string | null {
}
}
function jdbcCredentialParams(config: ConnectionUrlCopyConfig, withCredentials: boolean): string[] {
function jdbcCredentialParams(config: ConnectionUrlCopyConfig, withCredentials: boolean, encode: (value: string) => string = encodeURIComponent): string[] {
if (!withCredentials) return [];
const params: string[] = [];
const user = config.username?.trim() ?? "";
if (user) params.push(`user=${encodeURIComponent(user)}`);
if (config.password) params.push(`password=${encodeURIComponent(config.password)}`);
const user = effectiveUsername(config);
if (user) params.push(`user=${encode(user)}`);
if (config.password) params.push(`password=${encode(config.password)}`);
return params;
}
@@ -289,12 +299,20 @@ function jdbcCredentialParams(config: ConnectionUrlCopyConfig, withCredentials:
function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string, withCredentials: boolean): string | null | undefined {
const host = config.host?.trim() ?? "";
const rawParams = (config.url_params ?? "").trim();
const credentials = jdbcCredentialParams(config, withCredentials);
const propertyStyle = config.db_type === "sqlserver" || config.db_type === "teradata" ? config.db_type : null;
const credentials = jdbcCredentialParams(config, withCredentials, propertyStyle ? (value) => quoteJdbcProperty(value, propertyStyle) : encodeURIComponent);
switch (config.db_type) {
case "sqlserver": {
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const props = joinNonEmpty(";", [database && `databaseName=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/&/g, ";"), config.ssl && !queryHasParam(rawParams, ["encrypt"]) && "encrypt=true"]);
const params = normalizeJdbcProperties(rawParams, "sqlserver");
if (!params) return null;
const props = joinNonEmpty(";", [
database && `databaseName=${quoteJdbcProperty(database, "sqlserver")}`,
...credentials,
...params.map(({ key, value }) => `${key}=${quoteJdbcProperty(value, "sqlserver")}`),
config.ssl && !params.some(({ key }) => key.toLowerCase() === "encrypt") && "encrypt=true",
]);
return `jdbc:sqlserver://${formatHostForUrl(host)}${portPart}${props ? `;${props}` : ""}`;
}
case "oracle": {
@@ -316,7 +334,13 @@ function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string,
return `jdbc:sap://${formatHostForUrl(host)}${portPart}/${query ? `?${query}` : ""}`;
}
case "teradata": {
const props = joinNonEmpty(",", [Number(config.port) > 0 && `DBS_PORT=${config.port}`, database && `DATABASE=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/[;&]/g, ",")]);
const params = normalizeJdbcProperties(rawParams, "teradata");
if (!params) return null;
// Teradata 20.00.00.56 unescapes before stripping boundary quotes.
// Do not offer generated URLs that silently change a property value.
const values = [database, ...params.map(({ value }) => value), ...(withCredentials ? [effectiveUsername(config), config.password ?? ""] : [])];
if (values.some((value) => value.startsWith("'") || value.endsWith("'"))) return null;
const props = joinNonEmpty(",", [Number(config.port) > 0 && `DBS_PORT=${config.port}`, database && `DATABASE=${quoteJdbcProperty(database, "teradata")}`, ...credentials, ...params.map(({ key, value }) => `${key}=${quoteJdbcProperty(value, "teradata")}`)]);
return `jdbc:teradata://${formatHostForUrl(host)}${props ? `/${props}` : ""}`;
}
case "exasol": {
@@ -345,7 +369,9 @@ function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string,
function jdbcQuery(config: ConnectionUrlCopyConfig, prefix: string, withCredentials: boolean): string {
const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, "");
const parts = [...jdbcCredentialParams(config, withCredentials)];
// Strict encoding is enabled only for verified query-style drivers.
const encode = prefix === "jdbc:mysql" || prefix === "jdbc:postgresql" ? encodeCredential : encodeURIComponent;
const parts = [...jdbcCredentialParams(config, withCredentials, encode)];
if (raw) parts.push(raw);
if (config.ssl) {
if (PG_JDBC_PREFIXES.has(prefix) && !queryHasParam(raw, ["ssl", "sslmode"])) {
@@ -403,7 +429,7 @@ function buildKeyvalueDsn(config: ConnectionUrlCopyConfig, options: ConnectionUr
if (!host) return null;
const parts = [`host=${quoteDsnValue(host)}`];
if (shouldAppendPort(config)) parts.push(`port=${config.port}`);
const user = config.username?.trim() ?? "";
const user = effectiveUsername(config);
if (user) parts.push(`user=${quoteDsnValue(user)}`);
if (options.includePassword && config.password) parts.push(`password=${quoteDsnValue(config.password)}`);
const database = effectiveDatabase(config, options);
@@ -412,7 +438,9 @@ function buildKeyvalueDsn(config: ConnectionUrlCopyConfig, options: ConnectionUr
for (const param of raw.split("&")) {
const [key, ...rest] = param.split("=");
if (!key.trim()) continue;
parts.push(`${key.trim()}=${quoteDsnValue(decodeURIComponent(rest.join("=")))}`);
// Raw URL parameters can carry a password even when config.password is empty.
const value = !options.includePassword && isSecretConnectionProperty(key) ? "***" : decodeURIComponent(rest.join("="));
parts.push(`${key.trim()}=${quoteDsnValue(value)}`);
}
if (config.ssl && !queryHasParam(raw, ["sslmode"])) parts.push("sslmode=require");
return parts.join(" ");
@@ -423,23 +451,13 @@ function buildPsqlCommand(config: ConnectionUrlCopyConfig, options: ConnectionUr
if (!host) return null;
const args = ["psql", "-h", host];
if (shouldAppendPort(config)) args.push("-p", String(config.port));
const user = config.username?.trim() ?? "";
const user = effectiveUsername(config);
if (user) args.push("-U", user);
const database = effectiveDatabase(config, options);
if (database) args.push("-d", database);
return args.join(" ");
}
/**
* Whether a password-inclusive copy would actually carry a secret: either a
* stored password or an explicit connection string with embedded credentials.
*/
function hasCopyableSecret(config: ConnectionUrlCopyConfig): boolean {
if (config.password) return true;
const explicit = explicitConnectionString(config);
return !!explicit && redactConnectionStringSecrets(explicit) !== explicit;
}
/**
* Lists the copy formats available for a connection, deduplicating entries
* that would produce identical text (e.g. no stored password, or an explicit
@@ -451,13 +469,14 @@ export function connectionUrlCopyFormats(config: ConnectionUrlCopyConfig | undef
if (!connectionSupportsUrlCopy(config)) return [];
const candidate = config as ConnectionUrlCopyConfig;
const formats: ConnectionUrlCopyFormat[] = [];
const hasSecret = hasCopyableSecret(candidate);
const url = buildStandardUrl(candidate, { includePassword: false });
const url = redactCopy(buildStandardUrl(candidate, { includePassword: false }));
const urlWithPassword = buildStandardUrl(candidate, { includePassword: true });
const jdbcUrl = redactCopy(buildJdbcUrl(candidate, { withCredentials: false }));
const jdbcUrlWithCredentials = buildJdbcUrl(candidate, { withCredentials: true });
// Judge the same strings offered by the menu; a secret may live only in URL parameters.
const hasSecret = !!candidate.password || [urlWithPassword, jdbcUrlWithCredentials].some((value) => value !== null && redactCopy(value) !== value);
if (url) formats.push("url");
if (urlWithPassword && hasSecret && urlWithPassword !== url) formats.push("urlWithPassword");
const jdbcUrl = buildJdbcUrl(candidate, { withCredentials: false });
const jdbcUrlWithCredentials = buildJdbcUrl(candidate, { withCredentials: true });
if (jdbcUrl && jdbcUrl !== url) formats.push("jdbcUrl");
// Without a secret the "with credentials" URL would only add `user=` (or
// nothing), which is misleading next to its label — hide it in that case.
@@ -478,11 +497,11 @@ export function buildConnectionUrlCopy(config: ConnectionUrlCopyConfig | undefin
const candidate = config as ConnectionUrlCopyConfig;
switch (format) {
case "url":
return buildStandardUrl(candidate, { ...options, includePassword: false });
return redactCopy(buildStandardUrl(candidate, { ...options, includePassword: false }));
case "urlWithPassword":
return buildStandardUrl(candidate, { ...options, includePassword: true });
case "jdbcUrl":
return buildJdbcUrl(candidate, { ...options, withCredentials: false });
return redactCopy(buildJdbcUrl(candidate, { ...options, withCredentials: false }));
case "jdbcUrlWithCredentials":
return buildJdbcUrl(candidate, { ...options, withCredentials: true });
case "hostPort":
@@ -497,3 +516,7 @@ export function buildConnectionUrlCopy(config: ConnectionUrlCopyConfig | undefin
return null;
}
}
function redactCopy(value: string | null): string | null {
return value === null ? null : redactConnectionStringSecrets(value);
}
@@ -0,0 +1,125 @@
/** Property grammars used by Microsoft JDBC 8.4+ and Teradata JDBC. */
export type JdbcPropertyStyle = "sqlserver" | "teradata";
export interface JdbcProperty {
key: string;
value: string;
raw: string;
quoted: boolean;
hasEquals: boolean;
start: number;
end: number;
}
interface JdbcPropertyParseOptions {
separators?: string;
allowBare?: boolean;
}
const SECRET_CONNECTION_PROPERTY = /^(?:password|pwd|pass|passcode|passphrase|token|secret|key|apikey|api_key|accessToken|access_token|logdata|new_password|ssltruststore_password|sslpassword|oauth_client_secret|client_secret|clientKeyPassword|keyStoreSecret|trustStorePassword)$/i;
export function isSecretConnectionProperty(key: string): boolean {
const rawKey = key.trim();
try {
// URL import decodes property names before interpreting credentials.
return SECRET_CONNECTION_PROPERTY.test(decodeURIComponent(rawKey).trim());
} catch {
return SECRET_CONNECTION_PROPERTY.test(rawKey);
}
}
export function quoteJdbcProperty(value: string, style: JdbcPropertyStyle): string {
// DBX imports legacy unquoted SQL Server %xx values as URI escapes.
// Brace literal percentages in generated URLs so they survive that import path.
if (/^[\w.~%-]+$/.test(value) && (style !== "sqlserver" || !value.includes("%"))) return value;
return style === "sqlserver" ? `{${value.replace(/}/g, "}}")}}` : `'${value.replace(/'/g, "''")}'`;
}
/** Preserve the JDBC grammar; callers decide whether to decode legacy unquoted values. */
export function parseJdbcProperties(source: string, style: JdbcPropertyStyle, options: JdbcPropertyParseOptions = {}): JdbcProperty[] | null {
const { separators = style === "sqlserver" ? ";" : ",", allowBare = false } = options;
const opening = style === "sqlserver" ? "{" : "'";
const closing = style === "sqlserver" ? "}" : "'";
const properties: JdbcProperty[] = [];
let index = 0;
while (index < source.length) {
while (index < source.length && (separators.includes(source[index]) || /\s/.test(source[index]))) index++;
if (index === source.length) break;
const start = index;
while (index < source.length && source[index] !== "=" && !separators.includes(source[index])) index++;
const key = source.slice(start, index).trim();
if (!key) return null;
if (source[index] !== "=") {
if (!allowBare) return null;
properties.push({ key, value: "", raw: source.slice(start, index), quoted: false, hasEquals: false, start, end: index });
if (index < source.length) index++;
continue;
}
index++;
while (index < source.length && /\s/.test(source[index])) index++;
let value = "";
const quoted = source[index] === opening;
if (quoted) {
index++;
let closed = false;
while (index < source.length) {
const character = source[index++];
if (character !== closing) {
value += character;
} else if (source[index] === closing) {
value += closing;
index++;
} else {
closed = true;
break;
}
}
if (!closed) return null;
while (index < source.length && /\s/.test(source[index])) index++;
if (index < source.length && !separators.includes(source[index])) return null;
} else {
const valueStart = index;
while (index < source.length && !separators.includes(source[index])) index++;
value = source.slice(valueStart, index).trim();
if (style === "sqlserver" && value.includes("{")) return null;
}
properties.push({ key, value, raw: source.slice(start, index), quoted, hasEquals: true, start, end: index });
if (index < source.length) index++;
}
return properties;
}
function redactParsedProperties(source: string, properties: JdbcProperty[]): string {
if (!properties.some(({ key, hasEquals }) => hasEquals && isSecretConnectionProperty(key))) return source;
// Preserve original separators: JDBC properties use ';' or ',', while mssql:// queries use '&'.
let redacted = "";
let cursor = 0;
for (const { key, hasEquals, start, end } of properties) {
redacted += source.slice(cursor, start) + (hasEquals && isSecretConnectionProperty(key) ? `${key}=***` : source.slice(start, end));
cursor = end;
}
return redacted + source.slice(cursor);
}
export function redactJdbcProperties(source: string, style: JdbcPropertyStyle): string {
const properties = parseJdbcProperties(source, style);
// An invalid quoted value has no trustworthy boundary. Never copy its tail.
return properties ? redactParsedProperties(source, properties) : "***";
}
/** mssql:// query strings can contain bare flags; JDBC property lists cannot. */
export function redactSqlServerQuery(source: string): string {
const properties = parseJdbcProperties(source, "sqlserver", { separators: ";&", allowBare: true });
if (properties) return redactParsedProperties(source, properties);
// Keep malformed but non-sensitive standard queries copyable. If a secret-looking
// assignment occurs beyond an unclosed brace, its boundary is unsafe to trust.
for (const [, key] of source.matchAll(/(?:^|[;&])([^=?&;]+)=/g)) {
if (isSecretConnectionProperty(key)) return "***";
}
return source;
}
/** DBX also accepts &/; separated form parameters; never split inside quoted values. */
export function normalizeJdbcProperties(source: string, style: JdbcPropertyStyle): JdbcProperty[] | null {
return parseJdbcProperties(source.trim().replace(/^[?&;,]+/, ""), style, { separators: style === "sqlserver" ? ";&" : ",;&" });
}