fix(connection): safely copy and redact connection credentials
This commit is contained in:
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" ? ";&" : ",;&" });
|
||||
}
|
||||
Reference in new issue
Block a user