Files
bun-src/test/js/sql/sql-mysql.auth.test.ts
2026-08-27 21:09:14 +00:00

257 lines
10 KiB
TypeScript

import { SQL } from "bun";
import { expect, test } from "bun:test";
import { describeWithContainer } from "harness";
import { createHash } from "node:crypto";
import {
listeningServer,
MYSQL_FAST_AUTH_SUCCESS,
MYSQL_MOCK_AUTH_DATA_PART_1,
MYSQL_MOCK_AUTH_DATA_PART_2,
mysqlAuthMoreData,
mysqlHandshakeV10,
mysqlOkPacket,
mysqlParseHandshakeResponse41,
mysqlReadPackets,
mysqlTextResultSet,
} from "./wire-frames";
describeWithContainer(
"mysql",
{
image: "mysql_native_password",
env: {},
args: [],
concurrent: true,
},
container => {
// Create getters that will be evaluated when the test runs
const getUrl = () => `mysql://root:bun@${container.host}:${container.port}/bun_sql_test`;
test("should be able to connect with mysql_native_password auth plugin", async () => {
console.log("Container info in test:", container);
await using sql = new SQL({
url: getUrl(),
max: 1,
});
const result = await sql`select 1 as x`;
expect(result).toEqual([{ x: 1 }]);
await sql.end();
});
test("should be able to switch auth plugin", async () => {
{
await using sql = new SQL({
url: getUrl(),
max: 1,
});
await sql`DROP USER IF EXISTS caching@'%';`.simple();
await sql`CREATE USER caching@'%' IDENTIFIED WITH caching_sha2_password BY 'bunbun';
GRANT ALL PRIVILEGES ON bun_sql_test.* TO caching@'%';
FLUSH PRIVILEGES;`.simple();
}
{
// Negative case: default (allowPublicKeyRetrieval unset) must refuse to fetch the server key.
// Must run before the successful login below so caching_sha2_password hasn't cached credentials yet.
await using denied = new SQL({
url: `mysql://caching:bunbun@${container.host}:${container.port}/bun_sql_test`,
max: 1,
});
const err = await denied`select 1 as x`.then(
() => null,
e => e,
);
expect(err).not.toBeNull();
expect(err?.code).toBe("ERR_MYSQL_PUBLIC_KEY_RETRIEVAL_NOT_ALLOWED");
}
await using sql = new SQL({
url: `mysql://caching:bunbun@${container.host}:${container.port}/bun_sql_test`,
allowPublicKeyRetrieval: true,
});
const result = await sql`select 1 as x`;
expect(result).toEqual([{ x: 1 }]);
await sql.end();
});
// A passworded caching_sha2_password user's second and later connections take the
// fast path (AuthMoreData 0x03 then the concluding OK) once the server accepts the
// client's scramble; any prior successful full auth is what warms the server cache.
test("caching_sha2_password fast auth (warm server-side auth cache)", async () => {
{
await using admin = new SQL({ url: getUrl(), max: 1 });
await admin`DROP USER IF EXISTS fastauth@'%';`.simple();
await admin`CREATE USER fastauth@'%' IDENTIFIED WITH caching_sha2_password BY 'bunbun';
GRANT ALL PRIVILEGES ON bun_sql_test.* TO fastauth@'%';`.simple();
}
const userUrl = `mysql://fastauth:bunbun@${container.host}:${container.port}/bun_sql_test`;
// Connection #1: cold cache -> full authentication (RSA public-key
// exchange). Its success is what warms the auth cache for `fastauth`.
{
await using cold = new SQL({ url: userUrl, max: 1, allowPublicKeyRetrieval: true });
expect(await cold`select 1 as x`).toEqual([{ x: 1 }]);
}
// Connection #2: warm cache -> the server should take the fast path. Until the
// 21-byte-nonce bug (#26195 / #28161) also lands, it degrades to full auth via
// allowPublicKeyRetrieval. The scripted tests below carry the fast-auth proof.
await using fast = new SQL({ url: userUrl, max: 1, allowPublicKeyRetrieval: true });
expect(await fast`select 'REAL-ROW' as v`).toEqual([{ v: "REAL-ROW" }]);
});
},
);
// The caching_sha2_password "Fast path succeeds" exchange, byte-scripted so the scramble
// bytes can be read back off the wire and both TCP framings of AuthMoreData(0x03) + OK forced:
// https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html
const COM_QUERY = 0x03;
const MYSQL_TYPE_VAR_STRING = 0xfd;
test.each(["split", "coalesced"] as const)(
"caching_sha2_password fast-auth success: the trailing OK belongs to auth, not the first query (%s framing)",
async framing => {
const commands: number[] = [];
const { server, port } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let authed = false;
socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" }));
socket.on("data", chunk => {
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => {
if (!authed) {
// HandshakeResponse41 -> warm auth cache: fast_auth_success then OK.
authed = true;
const fastAuthSuccess = mysqlAuthMoreData(seq + 1, Buffer.from([MYSQL_FAST_AUTH_SUCCESS]));
const authOk = mysqlOkPacket(seq + 2);
if (framing === "coalesced") {
socket.write(Buffer.concat([fastAuthSuccess, authOk]));
} else {
socket.write(fastAuthSuccess);
setImmediate(() => socket.write(authOk));
}
return;
}
commands.push(payload[0]);
if (payload[0] === COM_QUERY) {
socket.write(mysqlTextResultSet(1, [{ name: "v", type: MYSQL_TYPE_VAR_STRING }], [["REAL-ROW"]]));
} else {
// COM_QUIT from `await using sql` below: a real server just closes.
socket.end();
}
});
});
socket.on("error", () => {});
});
try {
await using sql = new SQL({ url: `mysql://root:[email protected]:${port}/db`, max: 1 });
// .simple() -> COM_QUERY / text protocol, which is exactly the result set
// the scripted server answers with. Settle to a value so a rejection shows
// up in the toEqual diff below instead of failing the test opaquely.
const result = await sql`SELECT 'REAL-ROW' AS v`.simple().then(
rows => ({ rows }),
(e: { code?: string }) => ({ code: e?.code ?? String(e) }),
);
// `commands` proves the client only sends COM_QUERY once authentication
// has actually completed.
expect({ result, commands }).toEqual({
result: { rows: [{ v: "REAL-ROW" }] },
commands: [COM_QUERY],
});
} finally {
server.close();
}
},
);
// The scramble is XOR(SHA256(pw), SHA256(SHA256(SHA256(pw)) || nonce)) with the double
// hash hashed FIRST: MySQL's Generate_scramble, mysql2, go-sql-driver, and Connector/J all
// agree. mysql_native_password concatenates the other way around, which is NOT correct here.
test("caching_sha2_password scramble hashes the double-SHA256 before the nonce", async () => {
const password = "pw";
const scrambleSent = Promise.withResolvers<Buffer>();
const { server, port } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let authed = false;
socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" }));
socket.on("data", chunk => {
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => {
if (!authed) {
authed = true;
try {
scrambleSent.resolve(mysqlParseHandshakeResponse41(payload).authResponse);
} catch (e) {
scrambleSent.reject(e);
}
// Accept the auth so the query below completes and `await using sql` can
// tear down over the normal COM_QUIT path; the scramble is the subject.
socket.write(mysqlOkPacket(seq + 1));
} else if (payload[0] === COM_QUERY) {
socket.write(mysqlTextResultSet(1, [{ name: "v", type: MYSQL_TYPE_VAR_STRING }], [["REAL-ROW"]]));
} else {
socket.end();
}
});
});
socket.on("error", () => {});
});
try {
await using sql = new SQL({ url: `mysql://root:${password}@127.0.0.1:${port}/db`, max: 1 });
const [sent, rows] = await Promise.all([scrambleSent.promise, sql`SELECT 'REAL-ROW' AS v`.simple()]);
const sha256 = (b: Buffer) => createHash("sha256").update(b).digest();
const digest1 = sha256(Buffer.from(password));
const digest2 = sha256(digest1);
const expected = (nonce: Buffer) => {
const digest3 = sha256(Buffer.concat([digest2, nonce]));
return Buffer.from(digest1.map((byte, i) => byte ^ digest3[i])).toString("hex");
};
// The spec nonce is 20 bytes (part1 + the first 12 bytes of part2). Bun currently
// also keeps part2's trailing filler byte (#26195, fixed separately in #28161), so
// accept either nonce length: this test pins only the concatenation order.
const nonce20 = Buffer.concat([MYSQL_MOCK_AUTH_DATA_PART_1, MYSQL_MOCK_AUTH_DATA_PART_2.subarray(0, 12)]);
const nonce21 = Buffer.concat([MYSQL_MOCK_AUTH_DATA_PART_1, MYSQL_MOCK_AUTH_DATA_PART_2]);
expect([expected(nonce20), expected(nonce21)]).toContain(sent.toString("hex"));
expect(rows).toEqual([{ v: "REAL-ROW" }]);
} finally {
server.close();
}
});
// AuthMoreData 0x04 (perform_full_authentication) over plain TCP makes the client
// fetch the server's RSA public key, which is refused unless allowPublicKeyRetrieval
// is set. Every MySQL 8 first connection for a user looks like this by default, so
// the refusal must name the cause and the remedies, not just say "Connection closed".
test("public key retrieval refusal names allowPublicKeyRetrieval and TLS as remedies", async () => {
const PERFORM_FULL_AUTH = 0x04;
const { server, port } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let authed = false;
socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" }));
socket.on("data", chunk => {
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => {
if (!authed) {
// HandshakeResponse41 -> demand full authentication.
authed = true;
socket.write(mysqlAuthMoreData(seq + 1, Buffer.from([PERFORM_FULL_AUTH])));
}
});
});
socket.on("error", () => {});
});
try {
await using sql = new SQL({ url: `mysql://root:[email protected]:${port}/db`, max: 1 });
const err = await sql.connect().then(
() => null,
e => e,
);
expect(err?.code).toBe("ERR_MYSQL_PUBLIC_KEY_RETRIEVAL_NOT_ALLOWED");
expect(err?.message).toContain("allowPublicKeyRetrieval: true");
expect(err?.message).toContain("TLS");
} finally {
server.close();
}
});