From 503b0b6c3c4491456ebf5aa62731f0d517681689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 17 Jul 2026 21:20:00 +0700 Subject: [PATCH 1/2] Fix sslrootcert connection string parameter forwarding PostgreSQL connection strings carrying the standard libpq SSL parameters (sslrootcert, sslcert, sslkey, sslpassword) failed with "unrecognized configuration parameter" errors because postgres.js forwards unknown URL query parameters to the server as runtime configuration parameters. Add createPostgresJSConnectionConfig to @prisma/studio-core/data/postgresjs, which consumes these parameters client-side: certificate files are read from disk and mapped to TLS options (honoring sslmode verification semantics, including the libpq rule that a root certificate upgrades sslmode=require to verify-ca and that sslrootcert=system forces full verification), and the parameters are stripped from the connection string handed to postgres(). The ppg-dev demo runtime now routes external database URLs through the helper. Fixes prisma/studio#1433 Co-Authored-By: Claude Fable 5 --- .changeset/fix-sslrootcert-forwarding.md | 7 + FEATURES.md | 5 + data/postgresjs/connection-options.test.ts | 273 +++++++++++++++++++++ data/postgresjs/connection-options.ts | 155 ++++++++++++ data/postgresjs/index.ts | 7 + demo/ppg-dev/runtime.ts | 16 +- 6 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-sslrootcert-forwarding.md create mode 100644 data/postgresjs/connection-options.test.ts create mode 100644 data/postgresjs/connection-options.ts diff --git a/.changeset/fix-sslrootcert-forwarding.md b/.changeset/fix-sslrootcert-forwarding.md new file mode 100644 index 00000000..de345bc8 --- /dev/null +++ b/.changeset/fix-sslrootcert-forwarding.md @@ -0,0 +1,7 @@ +--- +"@prisma/studio-core": patch +--- + +# Support libpq SSL parameters in Postgres connection strings + +Consume `sslrootcert`, `sslcert`, `sslkey`, `sslpassword`, and `sslmode` client-side when building the postgres.js client instead of forwarding them to the server, which rejected connections with `unrecognized configuration parameter "sslrootcert"`. The new `createPostgresJSConnectionConfig` helper in `@prisma/studio-core/data/postgresjs` translates a connection string into a stripped connection string plus TLS options (reading certificate files from disk) for `postgres()`. diff --git a/FEATURES.md b/FEATURES.md index 2acaa70e..c3f601bf 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,6 +5,11 @@ Studio connects to PostgreSQL, MySQL, and SQLite through a unified adapter contract, so the same UI works across supported engines. Each adapter handles introspection, querying, inserts, updates, and deletes while exposing capabilities that drive conditional UI behavior. +## PostgreSQL SSL Connection Parameters + +PostgreSQL connection strings can carry the standard libpq SSL parameters `sslrootcert`, `sslcert`, `sslkey`, `sslpassword`, and `sslmode`, so Studio hosts can connect to TLS-protected databases such as managed Postgres with custom CAs or mutual TLS. +The `createPostgresJSConnectionConfig` helper in `@prisma/studio-core/data/postgresjs` consumes these parameters client-side — reading certificate files from disk and mapping the mode to TLS verification behavior — and strips them from the connection string so postgres.js does not forward them to the server as runtime configuration parameters. + ## Live Introspection and Schema Discovery Studio introspects connected databases to build schemas, tables, columns, relationships, filter operators, and timezone metadata. diff --git a/data/postgresjs/connection-options.test.ts b/data/postgresjs/connection-options.test.ts new file mode 100644 index 00000000..571b14fe --- /dev/null +++ b/data/postgresjs/connection-options.test.ts @@ -0,0 +1,273 @@ +import postgres from "postgres"; +import { describe, expect, it, vi } from "vitest"; + +import { createPostgresJSConnectionConfig } from "./connection-options"; + +type TlsSslOptions = { + ca?: string; + cert?: string; + checkServerIdentity?: () => undefined; + key?: string; + passphrase?: string; + rejectUnauthorized?: boolean; +}; + +function createReadFileMock(files: Record) { + return vi.fn((path: string) => { + const contents = files[path]; + + if (contents === undefined) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + + return contents; + }); +} + +describe("createPostgresJSConnectionConfig", () => { + it("reproduces the postgres.js behavior of forwarding sslrootcert to the server without translation", () => { + // Root cause of prisma/studio#1433: postgres.js forwards unknown URL query + // parameters as server runtime parameters, so the server rejects the + // connection with `unrecognized configuration parameter "sslrootcert"`. + const client = postgres( + "postgres://user:pass@db.example.com:5432/mydb?sslrootcert=/certs/ca.pem", + ); + + expect( + (client.options as { connection: Record }).connection + .sslrootcert, + ).toBe("/certs/ca.pem"); + }); + + it("strips sslrootcert from the connection string and maps it to a client-side CA", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user:pass@db.example.com:5432/mydb?sslrootcert=%2Fcerts%2Fca.pem", + { readFile }, + ); + + expect(config.connectionString).toBe( + "postgres://user:pass@db.example.com:5432/mydb", + ); + expect(readFile).toHaveBeenCalledWith("/certs/ca.pem"); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toBe("CA-PEM"); + expect(ssl.rejectUnauthorized).toBe(true); + }); + + it("keeps the translated connection string free of forwarded ssl parameters when passed to postgres.js", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user:pass@db.example.com:5432/mydb?sslrootcert=/certs/ca.pem", + { readFile }, + ); + + const client = postgres(config.connectionString, config.options); + const connection = ( + client.options as { connection: Record } + ).connection; + + expect(connection.sslrootcert).toBeUndefined(); + expect(connection.sslcert).toBeUndefined(); + expect(connection.sslkey).toBeUndefined(); + expect(connection.sslpassword).toBeUndefined(); + }); + + it("maps sslcert, sslkey, and sslpassword to client certificate options", () => { + const readFile = createReadFileMock({ + "/certs/ca.pem": "CA-PEM", + "/certs/client-cert.pem": "CERT-PEM", + "/certs/client-key.pem": "KEY-PEM", + }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslrootcert=/certs/ca.pem&sslcert=/certs/client-cert.pem&sslkey=/certs/client-key.pem&sslpassword=secret", + { readFile }, + ); + + expect(config.connectionString).toBe("postgres://user@db.example.com/mydb"); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toBe("CA-PEM"); + expect(ssl.cert).toBe("CERT-PEM"); + expect(ssl.key).toBe("KEY-PEM"); + expect(ssl.passphrase).toBe("secret"); + }); + + it("preserves unrelated query parameters", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?application_name=studio&sslrootcert=/certs/ca.pem&connect_timeout=10", + { readFile }, + ); + + expect(config.connectionString).toBe( + "postgres://user@db.example.com/mydb?application_name=studio&connect_timeout=10", + ); + }); + + it("leaves connection strings without client-side ssl file parameters untouched", () => { + const readFile = createReadFileMock({}); + + const plain = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb", + { readFile }, + ); + + expect(plain.connectionString).toBe("postgres://user@db.example.com/mydb"); + expect(plain.options).toEqual({}); + + // postgres.js already consumes sslmode by itself; without file parameters + // there is nothing to translate. + const sslmodeOnly = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=require", + { readFile }, + ); + + expect(sslmodeOnly.connectionString).toBe( + "postgres://user@db.example.com/mydb?sslmode=require", + ); + expect(sslmodeOnly.options).toEqual({}); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("verifies the certificate chain but not the host name below sslmode=verify-full", () => { + // libpq compatibility: providing a root certificate upgrades + // sslmode=require to verify-ca semantics. + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=require&sslrootcert=/certs/ca.pem", + { readFile }, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity?.()).toBeUndefined(); + }); + + it("performs full verification for sslmode=verify-full", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=verify-full&sslrootcert=/certs/ca.pem", + { readFile }, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity).toBeUndefined(); + }); + + it("verifies the certificate chain without host name checks for sslmode=verify-ca", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=verify-ca&sslrootcert=/certs/ca.pem", + { readFile }, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity?.()).toBeUndefined(); + }); + + it("does not verify the server certificate for sslmode=require without a root certificate", () => { + const readFile = createReadFileMock({ + "/certs/client-cert.pem": "CERT-PEM", + "/certs/client-key.pem": "KEY-PEM", + }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=require&sslcert=/certs/client-cert.pem&sslkey=/certs/client-key.pem", + { readFile }, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.cert).toBe("CERT-PEM"); + expect(ssl.key).toBe("KEY-PEM"); + expect(ssl.rejectUnauthorized).toBe(false); + }); + + it("uses the system trust store with full verification for sslrootcert=system", () => { + const readFile = createReadFileMock({}); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslrootcert=system", + { readFile }, + ); + + expect(config.connectionString).toBe("postgres://user@db.example.com/mydb"); + expect(readFile).not.toHaveBeenCalled(); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toBeUndefined(); + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity).toBeUndefined(); + }); + + it("disables ssl entirely for sslmode=disable even when file parameters are present", () => { + const readFile = createReadFileMock({}); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=disable&sslrootcert=/certs/ca.pem", + { readFile }, + ); + + expect(config.connectionString).toBe("postgres://user@db.example.com/mydb"); + expect(config.options.ssl).toBe(false); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("uses the last occurrence when an ssl parameter is repeated", () => { + const readFile = createReadFileMock({ + "/certs/first.pem": "FIRST-PEM", + "/certs/second.pem": "SECOND-PEM", + }); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslrootcert=/certs/first.pem&sslrootcert=/certs/second.pem", + { readFile }, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toBe("SECOND-PEM"); + }); + + it("throws a descriptive error when an ssl file cannot be read", () => { + const readFile = createReadFileMock({}); + + expect(() => + createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslrootcert=/certs/missing.pem", + { readFile }, + ), + ).toThrowError( + /Failed to read the file referenced by the "sslrootcert" connection parameter \("\/certs\/missing\.pem"\)/, + ); + }); + + it("reads ssl files from disk by default", () => { + const config = createPostgresJSConnectionConfig( + `postgres://user@db.example.com/mydb?sslrootcert=${encodeURIComponent( + new URL("./connection-options.test.ts", import.meta.url).pathname, + )}`, + ); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toContain("createPostgresJSConnectionConfig"); + }); +}); diff --git a/data/postgresjs/connection-options.ts b/data/postgresjs/connection-options.ts new file mode 100644 index 00000000..a58eb12d --- /dev/null +++ b/data/postgresjs/connection-options.ts @@ -0,0 +1,155 @@ +import { readFileSync } from "node:fs"; + +/** + * The libpq SSL parameters that reference local client-side files or secrets. + * postgres.js does not understand these and would forward them to the server + * as runtime configuration parameters, which the server rejects with + * `unrecognized configuration parameter "sslrootcert"` (prisma/studio#1433). + */ +const SSL_FILE_PARAMETERS = [ + "sslcert", + "sslkey", + "sslpassword", + "sslrootcert", +] as const; + +type SslFileParameter = (typeof SSL_FILE_PARAMETERS)[number]; + +export interface PostgresJSSslOptions { + ca?: string; + cert?: string; + checkServerIdentity?: () => undefined; + key?: string; + passphrase?: string; + rejectUnauthorized?: boolean; +} + +export interface PostgresJSConnectionConfig { + /** + * The connection string with client-side SSL parameters removed so + * postgres.js does not forward them to the server. + */ + connectionString: string; + /** + * postgres.js client options derived from the consumed SSL parameters. + * Spread these into the options passed to `postgres()`. + */ + options: { ssl?: false | PostgresJSSslOptions | "verify-full" }; +} + +export interface PostgresJSConnectionConfigDependencies { + readFile?: (path: string) => string; +} + +/** + * Translates a PostgreSQL connection string into postgres.js client options, + * consuming the standard libpq SSL parameters (`sslrootcert`, `sslcert`, + * `sslkey`, `sslpassword`, and `sslmode` when needed) client-side instead of + * letting postgres.js forward them to the server as runtime configuration + * parameters. + * + * Connection strings without client-side SSL file parameters are returned + * unchanged; postgres.js already consumes `sslmode` on its own. + */ +export function createPostgresJSConnectionConfig( + connectionString: string, + dependencies: PostgresJSConnectionConfigDependencies = {}, +): PostgresJSConnectionConfig { + const readFile = + dependencies.readFile ?? ((path: string) => readFileSync(path, "utf8")); + + const questionMarkIndex = connectionString.indexOf("?"); + + if (questionMarkIndex === -1) { + return { connectionString, options: {} }; + } + + const base = connectionString.slice(0, questionMarkIndex); + const parameters = new URLSearchParams( + connectionString.slice(questionMarkIndex + 1), + ); + + const sslFileValues = new Map(); + + for (const parameter of SSL_FILE_PARAMETERS) { + // Later occurrences override earlier ones, matching libpq semantics. + const value = parameters.getAll(parameter).at(-1); + + if (value !== undefined) { + sslFileValues.set(parameter, value); + } + } + + if (sslFileValues.size === 0) { + return { connectionString, options: {} }; + } + + for (const parameter of SSL_FILE_PARAMETERS) { + parameters.delete(parameter); + } + + const sslMode = parameters.getAll("sslmode").at(-1) ?? null; + parameters.delete("sslmode"); + + const remainingQuery = parameters.toString(); + const strippedConnectionString = remainingQuery + ? `${base}?${remainingQuery}` + : base; + + if (sslMode === "disable") { + return { + connectionString: strippedConnectionString, + options: { ssl: false }, + }; + } + + const readSslFile = (parameter: SslFileParameter): string => { + const path = sslFileValues.get(parameter)!; + + try { + return readFile(path); + } catch (error: unknown) { + throw new Error( + `Failed to read the file referenced by the "${parameter}" connection parameter ("${path}")`, + { cause: error }, + ); + } + }; + + const ssl: PostgresJSSslOptions = {}; + + const sslRootCert = sslFileValues.get("sslrootcert"); + const useSystemTrustStore = sslRootCert === "system"; + + if (sslRootCert !== undefined && !useSystemTrustStore) { + ssl.ca = readSslFile("sslrootcert"); + } + + if (sslFileValues.has("sslcert")) { + ssl.cert = readSslFile("sslcert"); + } + + if (sslFileValues.has("sslkey")) { + ssl.key = readSslFile("sslkey"); + } + + if (sslFileValues.has("sslpassword")) { + // sslpassword is the passphrase for the client key, not a file path. + ssl.passphrase = sslFileValues.get("sslpassword"); + } + + if (sslMode === "verify-full" || useSystemTrustStore) { + // libpq forces verify-full when sslrootcert=system. + ssl.rejectUnauthorized = true; + } else if (sslMode === "verify-ca" || ssl.ca !== undefined) { + // Verify the certificate chain but not the host name. Providing a root + // certificate upgrades sslmode=require to verify-ca, matching libpq. + ssl.rejectUnauthorized = true; + ssl.checkServerIdentity = () => undefined; + } else { + // require/prefer/allow (or no sslmode): encrypt without verification. + ssl.rejectUnauthorized = false; + } + + return { connectionString: strippedConnectionString, options: { ssl } }; +} diff --git a/data/postgresjs/index.ts b/data/postgresjs/index.ts index 80e0d7cd..c0a8270f 100644 --- a/data/postgresjs/index.ts +++ b/data/postgresjs/index.ts @@ -9,6 +9,13 @@ import { import { getCancelQuery, getPIDQuery } from "../postgres-core/utility"; import type { Query, QueryResult } from "../query"; +export { + createPostgresJSConnectionConfig, + type PostgresJSConnectionConfig, + type PostgresJSConnectionConfigDependencies, + type PostgresJSSslOptions, +} from "./connection-options"; + const SQL_LINT_STATEMENT_TIMEOUT = "1000ms"; const SQL_LINT_LOCK_TIMEOUT = "100ms"; const SQL_LINT_IDLE_IN_TRANSACTION_TIMEOUT = "1000ms"; diff --git a/demo/ppg-dev/runtime.ts b/demo/ppg-dev/runtime.ts index 00ad7cfb..8b961ba6 100644 --- a/demo/ppg-dev/runtime.ts +++ b/demo/ppg-dev/runtime.ts @@ -2,7 +2,10 @@ import { startPrismaDevServer } from "@prisma/dev"; import type { Sql } from "postgres"; import postgres from "postgres"; -import { createPostgresJSExecutor } from "../../data/postgresjs"; +import { + createPostgresJSConnectionConfig, + createPostgresJSExecutor, +} from "../../data/postgresjs"; import { type DemoRuntimeOptions, hasExternalDatabaseUrl, @@ -51,8 +54,15 @@ export async function startDemoRuntime( const cleanupCallbacks: Array<() => Promise | void> = []; const createPostgresClient = dependencies.createPostgresClient ?? - ((connectionString, clientOptions) => - postgres(connectionString, clientOptions)); + ((connectionString, clientOptions) => { + const connectionConfig = + createPostgresJSConnectionConfig(connectionString); + + return postgres(connectionConfig.connectionString, { + ...clientOptions, + ...connectionConfig.options, + }); + }); const createExecutor = dependencies.createPostgresExecutor ?? createPostgresJSExecutor; const createSeededTimestamp = From 4b2b5fbce63a40a1a91dbcc73c19551a8a377b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Sat, 18 Jul 2026 16:22:32 +0700 Subject: [PATCH 2/2] Address review feedback on SSL connection parameter handling - Reject sslrootcert=system combined with any sslmode weaker than verify-full, matching libpq's "weak sslmode disallowed with system CA" behavior, instead of silently rewriting verification or letting sslmode=disable turn TLS off. - Validate sslmode against the known libpq set (disable, allow, prefer, require, verify-ca, verify-full) and throw a descriptive error on unrecognized values instead of silently dropping them. - Reject sslmode=allow/prefer combined with SSL file parameters: postgres.js can only honor plaintext-fallback negotiation for its built-in string modes, not with custom TLS options, so failing fast beats silently forcing TLS on. - Use fileURLToPath(import.meta.url) for the on-disk test fixture path. - Clarify in FEATURES.md that only SSL file parameters (plus an accompanying sslmode) are stripped; a standalone sslmode stays in the URL for postgres.js. Co-Authored-By: Claude Fable 5 --- FEATURES.md | 3 +- data/postgresjs/connection-options.test.ts | 77 +++++++++++++++++++++- data/postgresjs/connection-options.ts | 58 ++++++++++++++-- 3 files changed, 130 insertions(+), 8 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index c3f601bf..34a14850 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -8,7 +8,8 @@ Each adapter handles introspection, querying, inserts, updates, and deletes whil ## PostgreSQL SSL Connection Parameters PostgreSQL connection strings can carry the standard libpq SSL parameters `sslrootcert`, `sslcert`, `sslkey`, `sslpassword`, and `sslmode`, so Studio hosts can connect to TLS-protected databases such as managed Postgres with custom CAs or mutual TLS. -The `createPostgresJSConnectionConfig` helper in `@prisma/studio-core/data/postgresjs` consumes these parameters client-side — reading certificate files from disk and mapping the mode to TLS verification behavior — and strips them from the connection string so postgres.js does not forward them to the server as runtime configuration parameters. +The `createPostgresJSConnectionConfig` helper in `@prisma/studio-core/data/postgresjs` consumes these parameters client-side — reading certificate files from disk and mapping the mode to TLS verification behavior — and strips the SSL file parameters (`sslrootcert`, `sslcert`, `sslkey`, `sslpassword`), plus any accompanying `sslmode`, from the connection string so postgres.js does not forward them to the server as runtime configuration parameters. +A standalone `sslmode` without SSL file parameters is left in the connection string untouched, since postgres.js consumes it natively. ## Live Introspection and Schema Discovery diff --git a/data/postgresjs/connection-options.test.ts b/data/postgresjs/connection-options.test.ts index 571b14fe..b191b11b 100644 --- a/data/postgresjs/connection-options.test.ts +++ b/data/postgresjs/connection-options.test.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; + import postgres from "postgres"; import { describe, expect, it, vi } from "vitest"; @@ -217,6 +219,79 @@ describe("createPostgresJSConnectionConfig", () => { expect(ssl.checkServerIdentity).toBeUndefined(); }); + it("accepts sslmode=verify-full together with sslrootcert=system", () => { + const readFile = createReadFileMock({}); + + const config = createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=verify-full&sslrootcert=system", + { readFile }, + ); + + expect(config.connectionString).toBe("postgres://user@db.example.com/mydb"); + + const ssl = config.options.ssl as TlsSslOptions; + + expect(ssl.ca).toBeUndefined(); + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity).toBeUndefined(); + }); + + it.each(["disable", "allow", "prefer", "require", "verify-ca"] as const)( + "rejects sslrootcert=system combined with the weaker sslmode=%s", + (sslMode) => { + // libpq rejects this combination with "weak sslmode disallowed with + // system CA" instead of silently changing the verification behavior. + const readFile = createReadFileMock({}); + + expect(() => + createPostgresJSConnectionConfig( + `postgres://user@db.example.com/mydb?sslmode=${sslMode}&sslrootcert=system`, + { readFile }, + ), + ).toThrowError( + new RegExp( + `Weak "sslmode" connection parameter value "${sslMode}" is disallowed with "sslrootcert=system"`, + ), + ); + expect(readFile).not.toHaveBeenCalled(); + }, + ); + + it.each(["allow", "prefer"] as const)( + "rejects sslmode=%s combined with client-side ssl file parameters", + (sslMode) => { + // postgres.js cannot negotiate libpq's plaintext fallback while using + // custom TLS options, so the helper refuses to silently force TLS on. + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + expect(() => + createPostgresJSConnectionConfig( + `postgres://user@db.example.com/mydb?sslmode=${sslMode}&sslrootcert=/certs/ca.pem`, + { readFile }, + ), + ).toThrowError( + new RegExp( + `The "sslmode" connection parameter value "${sslMode}" cannot be combined with the client-side SSL file parameters`, + ), + ); + expect(readFile).not.toHaveBeenCalled(); + }, + ); + + it("throws a descriptive error for unrecognized sslmode values", () => { + const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" }); + + expect(() => + createPostgresJSConnectionConfig( + "postgres://user@db.example.com/mydb?sslmode=verify-fulll&sslrootcert=/certs/ca.pem", + { readFile }, + ), + ).toThrowError( + /Unsupported "sslmode" connection parameter value "verify-fulll"\. Expected one of: disable, allow, prefer, require, verify-ca, verify-full\./, + ); + expect(readFile).not.toHaveBeenCalled(); + }); + it("disables ssl entirely for sslmode=disable even when file parameters are present", () => { const readFile = createReadFileMock({}); @@ -262,7 +337,7 @@ describe("createPostgresJSConnectionConfig", () => { it("reads ssl files from disk by default", () => { const config = createPostgresJSConnectionConfig( `postgres://user@db.example.com/mydb?sslrootcert=${encodeURIComponent( - new URL("./connection-options.test.ts", import.meta.url).pathname, + fileURLToPath(import.meta.url), )}`, ); diff --git a/data/postgresjs/connection-options.ts b/data/postgresjs/connection-options.ts index a58eb12d..bf3c07e2 100644 --- a/data/postgresjs/connection-options.ts +++ b/data/postgresjs/connection-options.ts @@ -15,6 +15,24 @@ const SSL_FILE_PARAMETERS = [ type SslFileParameter = (typeof SSL_FILE_PARAMETERS)[number]; +/** + * The `sslmode` values accepted by libpq. + */ +const SSL_MODES = [ + "disable", + "allow", + "prefer", + "require", + "verify-ca", + "verify-full", +] as const; + +type SslMode = (typeof SSL_MODES)[number]; + +function isSslMode(value: string): value is SslMode { + return (SSL_MODES as readonly string[]).includes(value); +} + export interface PostgresJSSslOptions { ca?: string; cert?: string; @@ -88,9 +106,29 @@ export function createPostgresJSConnectionConfig( parameters.delete(parameter); } - const sslMode = parameters.getAll("sslmode").at(-1) ?? null; + const rawSslMode = parameters.getAll("sslmode").at(-1) ?? null; parameters.delete("sslmode"); + if (rawSslMode !== null && !isSslMode(rawSslMode)) { + throw new Error( + `Unsupported "sslmode" connection parameter value "${rawSslMode}". Expected one of: ${SSL_MODES.join( + ", ", + )}.`, + ); + } + + const sslMode: SslMode | null = rawSslMode; + + const useSystemTrustStore = sslFileValues.get("sslrootcert") === "system"; + + if (useSystemTrustStore && sslMode !== null && sslMode !== "verify-full") { + // libpq rejects this combination with "weak sslmode disallowed with + // system CA" instead of silently weakening or strengthening verification. + throw new Error( + `Weak "sslmode" connection parameter value "${sslMode}" is disallowed with "sslrootcert=system". Use sslmode=verify-full.`, + ); + } + const remainingQuery = parameters.toString(); const strippedConnectionString = remainingQuery ? `${base}?${remainingQuery}` @@ -103,6 +141,17 @@ export function createPostgresJSConnectionConfig( }; } + if (sslMode === "allow" || sslMode === "prefer") { + // libpq negotiates plaintext-first (allow) or TLS-with-plaintext-fallback + // (prefer). postgres.js only supports that negotiation for its built-in + // "allow"/"prefer" string modes, which cannot carry custom TLS options, + // so honoring the file parameters would silently force TLS on. Reject the + // combination instead of changing the negotiation behavior. + throw new Error( + `The "sslmode" connection parameter value "${sslMode}" cannot be combined with the client-side SSL file parameters (sslrootcert, sslcert, sslkey, sslpassword). Use sslmode=require, sslmode=verify-ca, or sslmode=verify-full.`, + ); + } + const readSslFile = (parameter: SslFileParameter): string => { const path = sslFileValues.get(parameter)!; @@ -118,10 +167,7 @@ export function createPostgresJSConnectionConfig( const ssl: PostgresJSSslOptions = {}; - const sslRootCert = sslFileValues.get("sslrootcert"); - const useSystemTrustStore = sslRootCert === "system"; - - if (sslRootCert !== undefined && !useSystemTrustStore) { + if (sslFileValues.has("sslrootcert") && !useSystemTrustStore) { ssl.ca = readSslFile("sslrootcert"); } @@ -147,7 +193,7 @@ export function createPostgresJSConnectionConfig( ssl.rejectUnauthorized = true; ssl.checkServerIdentity = () => undefined; } else { - // require/prefer/allow (or no sslmode): encrypt without verification. + // sslmode=require (or no sslmode): encrypt without verification. ssl.rejectUnauthorized = false; }