Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/sql_jsc/postgres/PostgresRequest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,11 @@ pub(crate) fn on_data<Context: ReaderContext>(
);
return Ok(());
}
continue;
// sslmode=prefer: fall back to a plaintext startup. Return
// Ok(()) to drop any trailing bytes in this read: nothing
// sent before our StartupMessage is legitimate (CVE-2021-23222).
connection.start();
return Ok(());
}

connection.on(M::NoticeResponse, reader.reborrow())?;
Expand Down
2 changes: 1 addition & 1 deletion src/sql_jsc/postgres/PostgresSQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ impl PostgresSQLConnection {
);
}

fn start(&self) {
pub(crate) fn start(&self) {
self.setup_max_lifetime_timer_if_necessary();
self.reset_connection_timeout();
self.send_startup_message();
Expand Down
19 changes: 12 additions & 7 deletions test/js/sql/postgres-tls-ctx-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import { listeningServer, pgAuthenticationOk, pgReadyForQuery, pgSSLResponse } f
//
// This test doesn't need a real Postgres server: the tls_ctx is allocated up
// front in PostgresSQLConnection.call() as soon as sslmode != disable, before
// any TLS handshake. A minimal mock server refuses SSL ('N') but then
// immediately sends AuthenticationOk + ReadyForQuery so the client reaches
// `.connected`, letting close() -> disconnect() -> GC -> finalize() ->
// any TLS handshake. A minimal mock server refuses SSL ('N'), then answers the
// plaintext StartupMessage with AuthenticationOk + ReadyForQuery so the client
// reaches `.connected`, letting close() -> disconnect() -> GC -> finalize() ->
// deinit() exercise the teardown path.

async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise<number> {
Expand All @@ -40,11 +40,16 @@ async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise<number>
}

test("Postgres connections with sslmode != disable are finalized after close", async () => {
// 'N' (SSL refused) + AuthenticationOk + ReadyForQuery('I')
const handshake = Buffer.concat([pgSSLResponse("N"), pgAuthenticationOk(), pgReadyForQuery("I")]);

const { server, port } = await listeningServer(socket => {
socket.once("data", () => socket.write(handshake));
let sawSSLRequest = false;
socket.on("data", () => {
if (!sawSSLRequest) {
sawSSLRequest = true;
socket.write(pgSSLResponse("N"));
return;
}
socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery("I")]));
});
socket.on("error", () => {});
});

Expand Down
119 changes: 118 additions & 1 deletion test/js/sql/tls-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import { SQL, randomUUIDv7 } from "bun";
import { describe, expect, test } from "bun:test";
import { describeWithContainer, isDockerEnabled } from "harness";
import path from "node:path";
import { listeningServer, pgAuthenticationCleartextPassword, pgSSLRequest, pgSSLResponse } from "./wire-frames";
import {
listeningServer,
pgAuthenticationCleartextPassword,
pgAuthenticationOk,
pgCommandComplete,
pgDataRow,
pgErrorResponse,
pgReadyForQuery,
pgRowDescription,
pgSSLRequest,
pgSSLResponse,
} from "./wire-frames";

if (!isDockerEnabled()) {
test.skip("skipping TLS SQL tests - Docker is not available", () => {});
Expand Down Expand Up @@ -418,3 +429,109 @@ test("postgres client aborts the connection when the server declines TLS that wa
}
}
});

// Fault-injection test: requires a server that refuses / drops / sends malformed
// frames, which a healthy container will not do on demand. DO NOT COPY THIS
// PATTERN — anything a real server can produce belongs in describeWithContainer.
// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline
// Buffer.alloc frame construction here.
//
// Connect with ?sslmode=prefer to a plain-TCP Postgres mock that answers the
// SSLRequest with `sslRequestResponse`, then completes a plaintext startup and
// one simple Query. Returns the client-side outcome and the frames the server
// observed.
async function runSslmodePreferAgainstNonSslServer(sslRequestResponse: Buffer): Promise<{
wire: string[];
result: { kind: "ok"; rows: unknown } | { kind: "error"; code: unknown; message: string };
}> {
const wire: string[] = [];
const sockets = new Set<import("node:net").Socket>();

const { server, port } = await listeningServer(socket => {
sockets.add(socket);
socket.on("error", () => {});
let buf = Buffer.alloc(0);
let started = false;
socket.on("data", data => {
buf = Buffer.concat([buf, data]);
for (;;) {
if (!started) {
if (buf.length < 8) return;
const len = buf.readInt32BE(0);
if (buf.length < len) return;
if (len === 8 && buf.readInt32BE(4) === 80877103) {
wire.push("SSLRequest");
buf = buf.subarray(8);
socket.write(sslRequestResponse);
continue;
}
wire.push("StartupMessage");
started = true;
buf = buf.subarray(len);
socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()]));
continue;
}
if (buf.length < 5) return;
const len = buf.readInt32BE(1);
if (buf.length < 1 + len) return;
const type = String.fromCharCode(buf[0]);
buf = buf.subarray(1 + len);
if (type === "Q") {
wire.push("Query");
socket.write(
Buffer.concat([
pgRowDescription([{ name: "x", typeOid: 25 /* text */ }]),
pgDataRow([Buffer.from("1")]),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
]),
);
}
}
});
});

try {
await using sql = new SQL({
url: `postgres://u:pw@127.0.0.1:${port}/db?sslmode=prefer`,
adapter: "postgres",
max: 1,
connectionTimeout: 3,
});
const result = await sql`select 1`.simple().then(
rows => ({ kind: "ok" as const, rows }),
e => ({ kind: "error" as const, code: e?.code ?? String(e), message: String(e?.message ?? e) }),
);
return { wire, result };
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>(resolve => server.close(() => resolve()));
}
}

test("postgres sslmode=prefer falls back to a plaintext startup when the server declines TLS", async () => {
// libpq docs for sslmode=prefer: "first try an SSL connection; if that fails,
// try a non-SSL connection". When the server answers the SSLRequest with 'N',
// the client must send a plaintext StartupMessage on the same socket and
// continue in plaintext — not idle until connectionTimeout.
expect(await runSslmodePreferAgainstNonSslServer(pgSSLResponse("N"))).toEqual({
wire: ["SSLRequest", "StartupMessage", "Query"],
result: { kind: "ok", rows: [{ x: "1" }] },
});
});

test("postgres sslmode=prefer discards bytes that arrive alongside the 'N' SSLRequest answer", async () => {
// The server may only answer an SSLRequest with a single byte; any further
// bytes in the same read precede our StartupMessage and so cannot be a
// legitimate backend response. They must be discarded rather than dispatched
// (libpq CVE-2021-23222). The fallback plaintext startup then proceeds
// normally, so the injected ErrorResponse below must not surface.
expect(
await runSslmodePreferAgainstNonSslServer(
Buffer.concat([pgSSLResponse("N"), pgErrorResponse({ S: "FATAL", C: "XX000", M: "injected before startup" })]),
),
).toEqual({
wire: ["SSLRequest", "StartupMessage", "Query"],
result: { kind: "ok", rows: [{ x: "1" }] },
});
});
Loading