Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-sslrootcert-forwarding.md
Original file line number Diff line number Diff line change
@@ -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()`.
5 changes: 5 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Live Introspection and Schema Discovery

Studio introspects connected databases to build schemas, tables, columns, relationships, filter operators, and timezone metadata.
Expand Down
273 changes: 273 additions & 0 deletions data/postgresjs/connection-options.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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<string, unknown> }).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<string, unknown> }
).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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading