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..34a14850 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,6 +5,12 @@ 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 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 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..b191b11b --- /dev/null +++ b/data/postgresjs/connection-options.test.ts @@ -0,0 +1,348 @@ +import { fileURLToPath } from "node:url"; + +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("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({}); + + 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( + fileURLToPath(import.meta.url), + )}`, + ); + + 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..bf3c07e2 --- /dev/null +++ b/data/postgresjs/connection-options.ts @@ -0,0 +1,201 @@ +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]; + +/** + * 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; + 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 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}` + : base; + + if (sslMode === "disable") { + return { + connectionString: strippedConnectionString, + options: { ssl: false }, + }; + } + + 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)!; + + 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 = {}; + + if (sslFileValues.has("sslrootcert") && !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 { + // sslmode=require (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 =