Files

107 lines
4.1 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
// 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.
//
// Regression: mysql_native_password.scramble() sliced nonce[0..8] and
// nonce[8..20] with no length check. A malicious server can send an
// AuthSwitchRequest whose plugin_data is shorter than 20 bytes, which flows
// straight into scramble() as the nonce — OOB read (panic under safety
// checks, silent heap over-read in release). With the fix the client rejects
// with ERR_MYSQL_MISSING_AUTH_DATA before touching the buffer.
import { SQL } from "bun";
import { expect, test } from "bun:test";
import {
listeningServer,
mysqlAuthSwitchRequest,
mysqlHandshakeV10,
mysqlRawPacket,
mysqlReadPackets,
} from "./wire-frames";
test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read", async () => {
let sawAuthSwitchResponse = false;
// Advertise caching_sha2_password in the initial handshake so the client has
// to follow the AuthSwitchRequest path to reach mysql_native_password.scramble()
// with the server-controlled plugin_data.
const greeting = mysqlHandshakeV10({ authPlugin: "caching_sha2_password" });
const { server, port } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let sentAuthSwitch = false;
socket.write(greeting);
socket.on("data", chunk => {
buffered = Buffer.concat([buffered, chunk]);
while (buffered.length >= 4) {
const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16);
if (buffered.length < 4 + len) break;
const seq = buffered[3];
buffered = buffered.subarray(4 + len);
if (!sentAuthSwitch) {
// Reply to HandshakeResponse41 with the short-nonce AuthSwitch: only 4
// bytes of plugin_data — well under the 20 bytes scramble() slices.
sentAuthSwitch = true;
socket.write(mysqlAuthSwitchRequest(seq + 1, "mysql_native_password", Buffer.alloc(4, 0x63)));
} else {
// Pre-fix release builds OOB-read garbage into the scramble and
// still send an AuthSwitchResponse; reaching here means the
// length check did not fire. Close so the client does not hang.
sawAuthSwitchResponse = true;
socket.end();
}
}
});
socket.on("error", () => {});
});
try {
// Non-empty password so scramble() proceeds past the empty-password early return.
await using sql = new SQL({ url: `mysql://root:[email protected]:${port}/db`, max: 1 });
const err = await sql`select 1`.then(
() => ({ code: "UNEXPECTED_SUCCESS" }),
e => ({ code: e?.code ?? String(e) }),
);
expect({ err, sawAuthSwitchResponse }).toEqual({
err: { code: "ERR_MYSQL_MISSING_AUTH_DATA" },
sawAuthSwitchResponse: false,
});
} finally {
await new Promise<void>(r => server.close(() => r()));
}
});
test("MySQL: an AuthSwitchRequest frame declaring a zero-length payload is rejected", async () => {
const greeting = mysqlHandshakeV10();
const { server, port } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let replied = false;
socket.write(greeting);
socket.on("data", chunk => {
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => {
if (!replied) {
replied = true;
socket.end(mysqlRawPacket(seq + 1, Buffer.from([0xfe]), 0));
}
});
});
socket.on("error", () => {});
});
try {
await using sql = new SQL({ url: `mysql://root:[email protected]:${port}/db`, max: 1 });
const err = await sql`select 1`.then(
() => ({ code: "UNEXPECTED_SUCCESS" }),
e => ({ code: e?.code ?? String(e) }),
);
expect(err).toEqual({ code: "ERR_MYSQL_INVALID_AUTH_SWITCH_REQUEST" });
} finally {
await new Promise<void>(r => server.close(() => r()));
}
});