Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 32 additions & 8 deletions apps/cli/src/command-internal/connect-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,35 @@ const SERVER_REFUSED_SSL = "The server does not support SSL connections";
// post-handshake `read ECONNRESET` is not phase-specific and stays verbatim.
const TLS_DISCONNECT_MESSAGE =
"Client network socket disconnected before secure TLS connection was established";
// node-postgres' message when the server answers the SSLRequest with anything but `S`/`N`.
const SSL_NEGOTIATION_ERROR = "There was an error establishing an SSL connection";
// Handshake failures that carry no TLS-specific code: Node's `EPROTO` for a malformed TLS record,
// and Bun's `ERR_SOCKET_CLOSED` when the server hangs up after accepting the SSLRequest.
const TLS_HANDSHAKE_ERRNOS = new Set(["EPROTO", "ERR_SOCKET_CLOSED"]);

const isTlsStageCause = (text: unknown, code: unknown): boolean =>
text === SERVER_REFUSED_SSL ||
text === TLS_DISCONNECT_MESSAGE ||
(typeof code === "string" &&
(TLS_ERROR_CODES.has(code) || code.startsWith("ERR_TLS") || code.startsWith("ERR_SSL")));

/**
* Whether a connect failure came from negotiating TLS (the server declined or botched the
* SSLRequest, or the handshake failed) rather than from dialing, authenticating, or a server
* error. The `sslmode=prefer` plaintext fallback retries on these.
*/
export function isTlsNegotiationFailure(error: unknown): boolean {
const cause = deepestConnectCause(error);
if (typeof cause !== "object" || cause === null) return false;
const message = Reflect.get(cause, "message");
const code = Reflect.get(cause, "code");
return (
isTlsStageCause(message, code) ||
message === SSL_NEGOTIATION_ERROR ||
(typeof code === "string" && TLS_HANDSHAKE_ERRNOS.has(code))
);
}

// A Postgres SQLSTATE is exactly five uppercase alphanumerics. Combined with the
// `severity` field this identifies a node-postgres `DatabaseError` (a server
// ErrorResponse), never a node system error (whose codes are longer `E…` names).
Expand Down Expand Up @@ -281,12 +310,7 @@ function connectCauseDetail(cause: unknown): string {
if (syscall === "connect" || (typeof code === "string" && DIAL_ERROR_CODES.has(code))) {
return `dial error (${text})`;
}
if (
text === SERVER_REFUSED_SSL ||
text === TLS_DISCONNECT_MESSAGE ||
(typeof code === "string" &&
(TLS_ERROR_CODES.has(code) || code.startsWith("ERR_TLS") || code.startsWith("ERR_SSL")))
) {
if (isTlsStageCause(text, code)) {
return `tls error (${text})`;
}
return text;
Expand Down Expand Up @@ -370,8 +394,8 @@ export function connectSuggestion(
) {
return SUGGEST_ENV_VAR;
}
// An unset `sslmode` negotiates TLS and fails rather than downgrading, so a server without
// TLS needs the caller to opt into plaintext explicitly.
// Reached when plaintext is not an allowed fallback (`sslmode=require` or stricter), so the
// caller must opt into plaintext explicitly.
if (text.includes(SERVER_REFUSED_SSL) || text.includes("server refused TLS connection")) {
return "This server does not accept TLS. Set `sslmode=disable` on the connection string to connect in plaintext.";
}
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/src/command-internal/connect-errors.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isDialFailure,
isIPv6ConnectivityError,
isIPv6ConnectivityErrorCause,
isTlsNegotiationFailure,
} from "./connect-errors.ts";

// The real `@effect/sql` wrapper produced by the connect probe
Expand Down Expand Up @@ -530,3 +531,30 @@ describe("isIPv6ConnectivityErrorCause", () => {
).toBe(true);
});
});

describe("isTlsNegotiationFailure (sslmode=prefer plaintext fallback gate)", () => {
it("is true for a declined SSLRequest, a botched reply, and TLS handshake failures", () => {
for (const cause of [
new Error("The server does not support SSL connections"),
new Error("There was an error establishing an SSL connection"),
new Error("Client network socket disconnected before secure TLS connection was established"),
Object.assign(new Error("wrong version number"), { code: "ERR_SSL_WRONG_VERSION_NUMBER" }),
Object.assign(new Error("write EPROTO wrong version number"), { code: "EPROTO" }),
Object.assign(new Error("Socket is closed"), { code: "ERR_SOCKET_CLOSED" }),
]) {
expect(isTlsNegotiationFailure(realSqlConnectError(cause))).toBe(true);
}
});

it("is false for dial, timeout, authentication and unclassified failures", () => {
for (const cause of [
dialError("ECONNREFUSED", "127.0.0.1", 5432),
new Error("Connection terminated unexpectedly"),
new Error("timeout expired"),
Object.assign(new Error("password authentication failed"), { code: "28P01" }),
]) {
expect(isTlsNegotiationFailure(realSqlConnectError(cause))).toBe(false);
}
expect(isTlsNegotiationFailure("boom")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => {
}).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])),
);

it.effect("keeps a loopback url on another port remote so it still requires TLS", () =>
it.effect("keeps a loopback url on another port remote so it still negotiates TLS", () =>
Effect.gen(function* () {
const resolved = yield* resolveOnStack(yield* withWorkdir(), stackUrl(STACK_SQL_PORT + 1));
expect(resolved.isLocal).toBe(false);
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/command-internal/db-connection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export interface PgConnInput {
readonly runtimeParams?: Readonly<Record<string, string>>;
/**
* libpq `sslmode`, parsed from a `--db-url` query string. Controls whether the driver layer
* negotiates TLS and verifies the server certificate. Absent → TLS without certificate
* verification (libpq's `prefer`/`require` default).
* negotiates TLS and verifies the server certificate. Absent → TLS first, then plaintext if
* that fails (libpq's `prefer` default).
*/
readonly sslmode?: string;
/**
Expand Down
Loading
Loading