Skip to content

Commit 3e1c902

Browse files
sorenbsclaude
andauthored
fix: consume libpq SSL connection string parameters instead of forwarding them (#1551)
* 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 #1433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cb4f6d5 commit 3e1c902

6 files changed

Lines changed: 582 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@prisma/studio-core": patch
3+
---
4+
5+
# Support libpq SSL parameters in Postgres connection strings
6+
7+
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()`.

FEATURES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
Studio connects to PostgreSQL, MySQL, and SQLite through a unified adapter contract, so the same UI works across supported engines.
66
Each adapter handles introspection, querying, inserts, updates, and deletes while exposing capabilities that drive conditional UI behavior.
77

8+
## PostgreSQL SSL Connection Parameters
9+
10+
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.
11+
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.
12+
A standalone `sslmode` without SSL file parameters is left in the connection string untouched, since postgres.js consumes it natively.
13+
814
## Live Introspection and Schema Discovery
915

1016
Studio introspects connected databases to build schemas, tables, columns, relationships, filter operators, and timezone metadata.
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
import { fileURLToPath } from "node:url";
2+
3+
import postgres from "postgres";
4+
import { describe, expect, it, vi } from "vitest";
5+
6+
import { createPostgresJSConnectionConfig } from "./connection-options";
7+
8+
type TlsSslOptions = {
9+
ca?: string;
10+
cert?: string;
11+
checkServerIdentity?: () => undefined;
12+
key?: string;
13+
passphrase?: string;
14+
rejectUnauthorized?: boolean;
15+
};
16+
17+
function createReadFileMock(files: Record<string, string>) {
18+
return vi.fn((path: string) => {
19+
const contents = files[path];
20+
21+
if (contents === undefined) {
22+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
23+
}
24+
25+
return contents;
26+
});
27+
}
28+
29+
describe("createPostgresJSConnectionConfig", () => {
30+
it("reproduces the postgres.js behavior of forwarding sslrootcert to the server without translation", () => {
31+
// Root cause of prisma/studio#1433: postgres.js forwards unknown URL query
32+
// parameters as server runtime parameters, so the server rejects the
33+
// connection with `unrecognized configuration parameter "sslrootcert"`.
34+
const client = postgres(
35+
"postgres://user:pass@db.example.com:5432/mydb?sslrootcert=/certs/ca.pem",
36+
);
37+
38+
expect(
39+
(client.options as { connection: Record<string, unknown> }).connection
40+
.sslrootcert,
41+
).toBe("/certs/ca.pem");
42+
});
43+
44+
it("strips sslrootcert from the connection string and maps it to a client-side CA", () => {
45+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
46+
47+
const config = createPostgresJSConnectionConfig(
48+
"postgres://user:pass@db.example.com:5432/mydb?sslrootcert=%2Fcerts%2Fca.pem",
49+
{ readFile },
50+
);
51+
52+
expect(config.connectionString).toBe(
53+
"postgres://user:pass@db.example.com:5432/mydb",
54+
);
55+
expect(readFile).toHaveBeenCalledWith("/certs/ca.pem");
56+
57+
const ssl = config.options.ssl as TlsSslOptions;
58+
59+
expect(ssl.ca).toBe("CA-PEM");
60+
expect(ssl.rejectUnauthorized).toBe(true);
61+
});
62+
63+
it("keeps the translated connection string free of forwarded ssl parameters when passed to postgres.js", () => {
64+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
65+
66+
const config = createPostgresJSConnectionConfig(
67+
"postgres://user:pass@db.example.com:5432/mydb?sslrootcert=/certs/ca.pem",
68+
{ readFile },
69+
);
70+
71+
const client = postgres(config.connectionString, config.options);
72+
const connection = (
73+
client.options as { connection: Record<string, unknown> }
74+
).connection;
75+
76+
expect(connection.sslrootcert).toBeUndefined();
77+
expect(connection.sslcert).toBeUndefined();
78+
expect(connection.sslkey).toBeUndefined();
79+
expect(connection.sslpassword).toBeUndefined();
80+
});
81+
82+
it("maps sslcert, sslkey, and sslpassword to client certificate options", () => {
83+
const readFile = createReadFileMock({
84+
"/certs/ca.pem": "CA-PEM",
85+
"/certs/client-cert.pem": "CERT-PEM",
86+
"/certs/client-key.pem": "KEY-PEM",
87+
});
88+
89+
const config = createPostgresJSConnectionConfig(
90+
"postgres://user@db.example.com/mydb?sslrootcert=/certs/ca.pem&sslcert=/certs/client-cert.pem&sslkey=/certs/client-key.pem&sslpassword=secret",
91+
{ readFile },
92+
);
93+
94+
expect(config.connectionString).toBe("postgres://user@db.example.com/mydb");
95+
96+
const ssl = config.options.ssl as TlsSslOptions;
97+
98+
expect(ssl.ca).toBe("CA-PEM");
99+
expect(ssl.cert).toBe("CERT-PEM");
100+
expect(ssl.key).toBe("KEY-PEM");
101+
expect(ssl.passphrase).toBe("secret");
102+
});
103+
104+
it("preserves unrelated query parameters", () => {
105+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
106+
107+
const config = createPostgresJSConnectionConfig(
108+
"postgres://user@db.example.com/mydb?application_name=studio&sslrootcert=/certs/ca.pem&connect_timeout=10",
109+
{ readFile },
110+
);
111+
112+
expect(config.connectionString).toBe(
113+
"postgres://user@db.example.com/mydb?application_name=studio&connect_timeout=10",
114+
);
115+
});
116+
117+
it("leaves connection strings without client-side ssl file parameters untouched", () => {
118+
const readFile = createReadFileMock({});
119+
120+
const plain = createPostgresJSConnectionConfig(
121+
"postgres://user@db.example.com/mydb",
122+
{ readFile },
123+
);
124+
125+
expect(plain.connectionString).toBe("postgres://user@db.example.com/mydb");
126+
expect(plain.options).toEqual({});
127+
128+
// postgres.js already consumes sslmode by itself; without file parameters
129+
// there is nothing to translate.
130+
const sslmodeOnly = createPostgresJSConnectionConfig(
131+
"postgres://user@db.example.com/mydb?sslmode=require",
132+
{ readFile },
133+
);
134+
135+
expect(sslmodeOnly.connectionString).toBe(
136+
"postgres://user@db.example.com/mydb?sslmode=require",
137+
);
138+
expect(sslmodeOnly.options).toEqual({});
139+
expect(readFile).not.toHaveBeenCalled();
140+
});
141+
142+
it("verifies the certificate chain but not the host name below sslmode=verify-full", () => {
143+
// libpq compatibility: providing a root certificate upgrades
144+
// sslmode=require to verify-ca semantics.
145+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
146+
147+
const config = createPostgresJSConnectionConfig(
148+
"postgres://user@db.example.com/mydb?sslmode=require&sslrootcert=/certs/ca.pem",
149+
{ readFile },
150+
);
151+
152+
const ssl = config.options.ssl as TlsSslOptions;
153+
154+
expect(ssl.rejectUnauthorized).toBe(true);
155+
expect(ssl.checkServerIdentity?.()).toBeUndefined();
156+
});
157+
158+
it("performs full verification for sslmode=verify-full", () => {
159+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
160+
161+
const config = createPostgresJSConnectionConfig(
162+
"postgres://user@db.example.com/mydb?sslmode=verify-full&sslrootcert=/certs/ca.pem",
163+
{ readFile },
164+
);
165+
166+
const ssl = config.options.ssl as TlsSslOptions;
167+
168+
expect(ssl.rejectUnauthorized).toBe(true);
169+
expect(ssl.checkServerIdentity).toBeUndefined();
170+
});
171+
172+
it("verifies the certificate chain without host name checks for sslmode=verify-ca", () => {
173+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
174+
175+
const config = createPostgresJSConnectionConfig(
176+
"postgres://user@db.example.com/mydb?sslmode=verify-ca&sslrootcert=/certs/ca.pem",
177+
{ readFile },
178+
);
179+
180+
const ssl = config.options.ssl as TlsSslOptions;
181+
182+
expect(ssl.rejectUnauthorized).toBe(true);
183+
expect(ssl.checkServerIdentity?.()).toBeUndefined();
184+
});
185+
186+
it("does not verify the server certificate for sslmode=require without a root certificate", () => {
187+
const readFile = createReadFileMock({
188+
"/certs/client-cert.pem": "CERT-PEM",
189+
"/certs/client-key.pem": "KEY-PEM",
190+
});
191+
192+
const config = createPostgresJSConnectionConfig(
193+
"postgres://user@db.example.com/mydb?sslmode=require&sslcert=/certs/client-cert.pem&sslkey=/certs/client-key.pem",
194+
{ readFile },
195+
);
196+
197+
const ssl = config.options.ssl as TlsSslOptions;
198+
199+
expect(ssl.cert).toBe("CERT-PEM");
200+
expect(ssl.key).toBe("KEY-PEM");
201+
expect(ssl.rejectUnauthorized).toBe(false);
202+
});
203+
204+
it("uses the system trust store with full verification for sslrootcert=system", () => {
205+
const readFile = createReadFileMock({});
206+
207+
const config = createPostgresJSConnectionConfig(
208+
"postgres://user@db.example.com/mydb?sslrootcert=system",
209+
{ readFile },
210+
);
211+
212+
expect(config.connectionString).toBe("postgres://user@db.example.com/mydb");
213+
expect(readFile).not.toHaveBeenCalled();
214+
215+
const ssl = config.options.ssl as TlsSslOptions;
216+
217+
expect(ssl.ca).toBeUndefined();
218+
expect(ssl.rejectUnauthorized).toBe(true);
219+
expect(ssl.checkServerIdentity).toBeUndefined();
220+
});
221+
222+
it("accepts sslmode=verify-full together with sslrootcert=system", () => {
223+
const readFile = createReadFileMock({});
224+
225+
const config = createPostgresJSConnectionConfig(
226+
"postgres://user@db.example.com/mydb?sslmode=verify-full&sslrootcert=system",
227+
{ readFile },
228+
);
229+
230+
expect(config.connectionString).toBe("postgres://user@db.example.com/mydb");
231+
232+
const ssl = config.options.ssl as TlsSslOptions;
233+
234+
expect(ssl.ca).toBeUndefined();
235+
expect(ssl.rejectUnauthorized).toBe(true);
236+
expect(ssl.checkServerIdentity).toBeUndefined();
237+
});
238+
239+
it.each(["disable", "allow", "prefer", "require", "verify-ca"] as const)(
240+
"rejects sslrootcert=system combined with the weaker sslmode=%s",
241+
(sslMode) => {
242+
// libpq rejects this combination with "weak sslmode disallowed with
243+
// system CA" instead of silently changing the verification behavior.
244+
const readFile = createReadFileMock({});
245+
246+
expect(() =>
247+
createPostgresJSConnectionConfig(
248+
`postgres://user@db.example.com/mydb?sslmode=${sslMode}&sslrootcert=system`,
249+
{ readFile },
250+
),
251+
).toThrowError(
252+
new RegExp(
253+
`Weak "sslmode" connection parameter value "${sslMode}" is disallowed with "sslrootcert=system"`,
254+
),
255+
);
256+
expect(readFile).not.toHaveBeenCalled();
257+
},
258+
);
259+
260+
it.each(["allow", "prefer"] as const)(
261+
"rejects sslmode=%s combined with client-side ssl file parameters",
262+
(sslMode) => {
263+
// postgres.js cannot negotiate libpq's plaintext fallback while using
264+
// custom TLS options, so the helper refuses to silently force TLS on.
265+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
266+
267+
expect(() =>
268+
createPostgresJSConnectionConfig(
269+
`postgres://user@db.example.com/mydb?sslmode=${sslMode}&sslrootcert=/certs/ca.pem`,
270+
{ readFile },
271+
),
272+
).toThrowError(
273+
new RegExp(
274+
`The "sslmode" connection parameter value "${sslMode}" cannot be combined with the client-side SSL file parameters`,
275+
),
276+
);
277+
expect(readFile).not.toHaveBeenCalled();
278+
},
279+
);
280+
281+
it("throws a descriptive error for unrecognized sslmode values", () => {
282+
const readFile = createReadFileMock({ "/certs/ca.pem": "CA-PEM" });
283+
284+
expect(() =>
285+
createPostgresJSConnectionConfig(
286+
"postgres://user@db.example.com/mydb?sslmode=verify-fulll&sslrootcert=/certs/ca.pem",
287+
{ readFile },
288+
),
289+
).toThrowError(
290+
/Unsupported "sslmode" connection parameter value "verify-fulll"\. Expected one of: disable, allow, prefer, require, verify-ca, verify-full\./,
291+
);
292+
expect(readFile).not.toHaveBeenCalled();
293+
});
294+
295+
it("disables ssl entirely for sslmode=disable even when file parameters are present", () => {
296+
const readFile = createReadFileMock({});
297+
298+
const config = createPostgresJSConnectionConfig(
299+
"postgres://user@db.example.com/mydb?sslmode=disable&sslrootcert=/certs/ca.pem",
300+
{ readFile },
301+
);
302+
303+
expect(config.connectionString).toBe("postgres://user@db.example.com/mydb");
304+
expect(config.options.ssl).toBe(false);
305+
expect(readFile).not.toHaveBeenCalled();
306+
});
307+
308+
it("uses the last occurrence when an ssl parameter is repeated", () => {
309+
const readFile = createReadFileMock({
310+
"/certs/first.pem": "FIRST-PEM",
311+
"/certs/second.pem": "SECOND-PEM",
312+
});
313+
314+
const config = createPostgresJSConnectionConfig(
315+
"postgres://user@db.example.com/mydb?sslrootcert=/certs/first.pem&sslrootcert=/certs/second.pem",
316+
{ readFile },
317+
);
318+
319+
const ssl = config.options.ssl as TlsSslOptions;
320+
321+
expect(ssl.ca).toBe("SECOND-PEM");
322+
});
323+
324+
it("throws a descriptive error when an ssl file cannot be read", () => {
325+
const readFile = createReadFileMock({});
326+
327+
expect(() =>
328+
createPostgresJSConnectionConfig(
329+
"postgres://user@db.example.com/mydb?sslrootcert=/certs/missing.pem",
330+
{ readFile },
331+
),
332+
).toThrowError(
333+
/Failed to read the file referenced by the "sslrootcert" connection parameter \("\/certs\/missing\.pem"\)/,
334+
);
335+
});
336+
337+
it("reads ssl files from disk by default", () => {
338+
const config = createPostgresJSConnectionConfig(
339+
`postgres://user@db.example.com/mydb?sslrootcert=${encodeURIComponent(
340+
fileURLToPath(import.meta.url),
341+
)}`,
342+
);
343+
344+
const ssl = config.options.ssl as TlsSslOptions;
345+
346+
expect(ssl.ca).toContain("createPostgresJSConnectionConfig");
347+
});
348+
});

0 commit comments

Comments
 (0)