285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
// 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.
|
|
//
|
|
// A server (or MITM on the default non-TLS path) that answers every client
|
|
// auth packet with another AuthSwitchRequest (0xFE) drove the client into an
|
|
// unbounded auth ping-pong at 100% CPU, and connectionTimeout never fired
|
|
// because every arriving handshake packet re-armed the connect-phase timer.
|
|
// Bun now caps the number of AuthSwitchRequests honoured per handshake, refuses
|
|
// a switch back to the plugin already in use (mysql2 errors on a repeated auth
|
|
// switch), and treats connectionTimeout as an absolute connect→ready deadline.
|
|
|
|
import { SQL } from "bun";
|
|
import { expect, test } from "bun:test";
|
|
import { generateKeyPairSync } from "node:crypto";
|
|
import {
|
|
listeningServer,
|
|
mysqlAuthMoreData,
|
|
mysqlAuthSwitchRequest,
|
|
mysqlHandshakeV10,
|
|
mysqlOkPacket,
|
|
mysqlReadPackets,
|
|
} from "./wire-frames";
|
|
|
|
/**
|
|
* Scripted server: send `greeting`, answer the HandshakeResponse41 with
|
|
* `switches[0]`, then answer each subsequent client packet with the next entry
|
|
* in `switches` (cycling the last one). Destroys the socket once `limit`
|
|
* AuthSwitchResponses have been observed. Resolves to the client's error and
|
|
* the number of AuthSwitchResponses it sent.
|
|
*/
|
|
async function switchLoop(opts: { handshakePlugin: string; switches: string[]; limit: number }) {
|
|
let responses = 0;
|
|
const sockets = new Set<import("node:net").Socket>();
|
|
const { server, port } = await listeningServer(socket => {
|
|
sockets.add(socket);
|
|
let buffered = Buffer.alloc(0);
|
|
let sawHandshakeResponse = false;
|
|
socket.write(mysqlHandshakeV10({ authPlugin: opts.handshakePlugin }));
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("data", chunk => {
|
|
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => {
|
|
if (!sawHandshakeResponse) {
|
|
sawHandshakeResponse = true;
|
|
socket.write(mysqlAuthSwitchRequest(seq + 1, opts.switches[0], Buffer.alloc(20, 0x61)));
|
|
return;
|
|
}
|
|
responses++;
|
|
if (responses >= opts.limit) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
const plugin = opts.switches[Math.min(responses, opts.switches.length - 1)];
|
|
socket.write(mysqlAuthSwitchRequest(seq + 1, plugin, Buffer.alloc(20, 0x62)));
|
|
});
|
|
});
|
|
});
|
|
|
|
const db = new SQL({
|
|
adapter: "mysql",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
username: "u",
|
|
password: "pw",
|
|
database: "d",
|
|
tls: false,
|
|
max: 1,
|
|
connectionTimeout: 30,
|
|
});
|
|
let err: any;
|
|
try {
|
|
await db.unsafe("SELECT 1");
|
|
err = { code: "UNEXPECTED_SUCCESS" };
|
|
} catch (e: any) {
|
|
err = { code: e?.code ?? String(e) };
|
|
} finally {
|
|
await db.close({ timeout: 0 });
|
|
for (const s of sockets) s.destroy();
|
|
await new Promise<void>(r => server.close(() => r()));
|
|
}
|
|
return { err, responses };
|
|
}
|
|
|
|
test("MySQL: a second AuthSwitchRequest to the same plugin is rejected", async () => {
|
|
// A first switch to the handshake-advertised plugin is legitimate (Azure
|
|
// Database for MySQL and proxy front-ends do this to deliver a fresh nonce)
|
|
// and must be honoured; a second switch to that same plugin is the
|
|
// ping-pong attack signature and is rejected. Before the fix `responses`
|
|
// hit the limit.
|
|
const { err, responses } = await switchLoop({
|
|
handshakePlugin: "mysql_native_password",
|
|
switches: ["mysql_native_password"],
|
|
limit: 50,
|
|
});
|
|
expect({ err, responses }).toEqual({
|
|
err: { code: "ERR_MYSQL_UNEXPECTED_PACKET" },
|
|
responses: 1,
|
|
});
|
|
});
|
|
|
|
test("MySQL: at most two AuthSwitchRequests are honoured per handshake", async () => {
|
|
// Alternate between two different plugins so the same-plugin check never
|
|
// trips; the count cap must still cut the loop off after the second answer.
|
|
// Before the fix `responses` hit the limit.
|
|
const { err, responses } = await switchLoop({
|
|
handshakePlugin: "caching_sha2_password",
|
|
switches: ["mysql_native_password", "caching_sha2_password", "mysql_native_password"],
|
|
limit: 50,
|
|
});
|
|
expect({ err, responses }).toEqual({
|
|
err: { code: "ERR_MYSQL_UNEXPECTED_PACKET" },
|
|
responses: 2,
|
|
});
|
|
});
|
|
|
|
test("MySQL: caching_sha2 perform_full_authentication is honoured at most once", async () => {
|
|
// Over plain TCP with allowPublicKeyRetrieval, CONTINUE_AUTH (0x04) sends a
|
|
// PublicKeyRequest, the server's key reply elicits an encrypted-password
|
|
// write, and a second CONTINUE_AUTH used to restart that exchange
|
|
// indefinitely. Now the second CONTINUE_AUTH is rejected.
|
|
const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 1024 });
|
|
const pem = Buffer.from(publicKey.export({ type: "spki", format: "pem" }) as string);
|
|
|
|
let passwords = 0;
|
|
const sockets = new Set<import("node:net").Socket>();
|
|
const { server, port } = await listeningServer(socket => {
|
|
sockets.add(socket);
|
|
let buffered = Buffer.alloc(0);
|
|
let phase = 0;
|
|
socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" }));
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("data", chunk => {
|
|
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => {
|
|
if (phase === 0) {
|
|
// HandshakeResponse41 → ask for full auth.
|
|
phase = 1;
|
|
socket.write(mysqlAuthMoreData(seq + 1, Buffer.from([0x04])));
|
|
} else if (payload.length === 1 && payload[0] === 0x02) {
|
|
// PublicKeyRequest → send the RSA key.
|
|
socket.write(mysqlAuthMoreData(seq + 1, pem));
|
|
} else {
|
|
// Encrypted-password packet → ask for full auth again.
|
|
passwords++;
|
|
if (passwords >= 20) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
socket.write(mysqlAuthMoreData(seq + 1, Buffer.from([0x04])));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const db = new SQL({
|
|
adapter: "mysql",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
username: "u",
|
|
password: "pw",
|
|
database: "d",
|
|
tls: false,
|
|
max: 1,
|
|
connectionTimeout: 30,
|
|
allowPublicKeyRetrieval: true,
|
|
});
|
|
let err: any;
|
|
try {
|
|
await db.unsafe("SELECT 1");
|
|
err = { code: "UNEXPECTED_SUCCESS" };
|
|
} catch (e: any) {
|
|
err = { code: e?.code ?? String(e) };
|
|
} finally {
|
|
await db.close({ timeout: 0 });
|
|
for (const s of sockets) s.destroy();
|
|
await new Promise<void>(r => server.close(() => r()));
|
|
}
|
|
expect({ err, passwords }).toEqual({
|
|
err: { code: "ERR_MYSQL_UNEXPECTED_PACKET" },
|
|
passwords: 1,
|
|
});
|
|
});
|
|
|
|
// Boundary: the normal one-switch happy path must still work, both when the
|
|
// switch targets a different plugin and when it targets the plugin the
|
|
// greeting already advertised (Azure / ProxySQL fresh-nonce pattern).
|
|
test.each([
|
|
{ name: "to a different plugin", handshake: "caching_sha2_password", switchTo: "mysql_native_password" },
|
|
{ name: "to the greeting's plugin", handshake: "mysql_native_password", switchTo: "mysql_native_password" },
|
|
])("MySQL: a single AuthSwitchRequest $name then OK still connects", async ({ handshake, switchTo }) => {
|
|
let responses = 0;
|
|
const { server, port } = await listeningServer(socket => {
|
|
let buffered = Buffer.alloc(0);
|
|
let phase = 0;
|
|
socket.write(mysqlHandshakeV10({ authPlugin: handshake }));
|
|
socket.on("error", () => {});
|
|
socket.on("data", chunk => {
|
|
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => {
|
|
if (phase === 0) {
|
|
phase = 1;
|
|
socket.write(mysqlAuthSwitchRequest(seq + 1, switchTo, Buffer.alloc(20, 0x62)));
|
|
} else if (phase === 1) {
|
|
phase = 2;
|
|
responses++;
|
|
socket.write(mysqlOkPacket(seq + 1));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
try {
|
|
await using db = new SQL({
|
|
adapter: "mysql",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
username: "u",
|
|
password: "pw",
|
|
database: "d",
|
|
tls: false,
|
|
max: 1,
|
|
});
|
|
await expect(db.connect()).resolves.toBeDefined();
|
|
expect(responses).toBe(1);
|
|
} finally {
|
|
await new Promise<void>(r => server.close(() => r()));
|
|
}
|
|
});
|
|
|
|
test("MySQL: connectionTimeout bounds the whole handshake, not per packet", async () => {
|
|
// Server that trickles the greeting one byte at a time, each well under
|
|
// connectionTimeout. Before the fix each byte re-armed the connect timer so
|
|
// the client waited indefinitely; now it fails once the overall deadline
|
|
// passes.
|
|
const greeting = mysqlHandshakeV10();
|
|
const sockets = new Set<import("node:net").Socket>();
|
|
const { server, port } = await listeningServer(socket => {
|
|
sockets.add(socket);
|
|
let i = 0;
|
|
const id = setInterval(() => {
|
|
if (socket.destroyed || i >= greeting.length) {
|
|
clearInterval(id);
|
|
return;
|
|
}
|
|
socket.write(greeting.subarray(i, i + 1));
|
|
i++;
|
|
}, 200);
|
|
socket.on("error", () => {});
|
|
socket.on("close", () => {
|
|
clearInterval(id);
|
|
sockets.delete(socket);
|
|
});
|
|
});
|
|
|
|
const t0 = performance.now();
|
|
const db = new SQL({
|
|
adapter: "mysql",
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
username: "u",
|
|
password: "pw",
|
|
database: "d",
|
|
tls: false,
|
|
max: 1,
|
|
connectionTimeout: 1,
|
|
});
|
|
try {
|
|
const err = await db.unsafe("SELECT 1").then(
|
|
() => ({ code: "UNEXPECTED_SUCCESS" }),
|
|
(e: any) => ({ code: e?.code ?? String(e) }),
|
|
);
|
|
const elapsed = performance.now() - t0;
|
|
// The greeting is ~80 bytes @ 200ms/byte ≈ 16s; the 1s connectionTimeout
|
|
// must win well before then. Allow generous slack for debug/ASAN.
|
|
expect(err).toEqual({ code: "ERR_MYSQL_CONNECTION_TIMEOUT" });
|
|
expect(elapsed).toBeLessThan(8000);
|
|
} finally {
|
|
await db.close({ timeout: 0 });
|
|
for (const s of sockets) s.destroy();
|
|
await new Promise<void>(r => server.close(() => r()));
|
|
}
|
|
});
|