Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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