From 1aa8b21a878699ea054a7e1103acfff7e77e7b46 Mon Sep 17 00:00:00 2001 From: AiLi <3195572062@qq.com> Date: Thu, 24 Sep 2026 12:19:02 +0800 Subject: [PATCH] fix(connection): safely copy and redact connection credentials --- .../src/components/sidebar/TreeItem.vue | 11 +- .../connectionStringRedaction.spec.ts | 25 ++++ .../connection/connectionUrlBuilder.spec.ts | 72 +++++++++ .../connection/jdbcProperties.spec.ts | 138 ++++++++++++++++++ .../connection/connectionStringRedaction.ts | 12 ++ .../src/lib/connection/connectionUrl.ts | 23 ++- .../lib/connection/connectionUrlBuilder.ts | 103 ++++++++----- .../src/lib/connection/jdbcProperties.ts | 125 ++++++++++++++++ 8 files changed, 450 insertions(+), 59 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/connection/connectionStringRedaction.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/connection/jdbcProperties.spec.ts create mode 100644 apps/desktop/src/lib/connection/connectionStringRedaction.ts create mode 100644 apps/desktop/src/lib/connection/jdbcProperties.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 412fa1fe97..8760f1fbf1 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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 { diff --git a/apps/desktop/src/lib/__tests__/connection/connectionStringRedaction.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionStringRedaction.spec.ts new file mode 100644 index 0000000000..cc037d9db4 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionStringRedaction.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts index 6edc13fa43..9cc910d020 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts @@ -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 { return { @@ -15,6 +16,9 @@ function config(overrides: Partial): 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"); diff --git a/apps/desktop/src/lib/__tests__/connection/jdbcProperties.spec.ts b/apps/desktop/src/lib/__tests__/connection/jdbcProperties.spec.ts new file mode 100644 index 0000000000..f6ea8162a1 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/jdbcProperties.spec.ts @@ -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?***"); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionStringRedaction.ts b/apps/desktop/src/lib/connection/connectionStringRedaction.ts new file mode 100644 index 0000000000..0f8b13db23 --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionStringRedaction.ts @@ -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)); +} diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index 82592dace3..b6c36ba6f6 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -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(); 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, }; diff --git a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts index bd9cd1dc61..b44115c539 100644 --- a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts +++ b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts @@ -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); +} diff --git a/apps/desktop/src/lib/connection/jdbcProperties.ts b/apps/desktop/src/lib/connection/jdbcProperties.ts new file mode 100644 index 0000000000..7fcfc729d1 --- /dev/null +++ b/apps/desktop/src/lib/connection/jdbcProperties.ts @@ -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" ? ";&" : ",;&" }); +}