Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 4 additions & 6 deletions src/sql_jsc/mysql/MySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,12 +816,10 @@ impl MySQLConnection {

match response.status {
Auth::caching_sha2_password::FastAuthStatus::SUCCESS => {
debug!("success auth");
self.set_status(ConnectionState::Connected);

self.flags.insert(ConnectionFlags::IS_READY_FOR_QUERY);
self.queue.mark_as_ready_for_query();
self.advance();
// fast_auth_success only acknowledges the cached scramble; the
// server always follows it with the OK/ERR packet that concludes
// auth, so stay in Authenticating and let the arms above consume it.
debug!("fast auth success, awaiting OK");
}
Auth::caching_sha2_password::FastAuthStatus::CONTINUE_AUTH => {
bun_core::scoped_log!(MySQLConnection, "continue auth");
Expand Down
118 changes: 118 additions & 0 deletions test/js/sql/sql-mysql.auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { SQL } from "bun";
import { expect, test } from "bun:test";
import { describeWithContainer } from "harness";
import {
listeningServer,
MYSQL_FAST_AUTH_SUCCESS,
mysqlAuthMoreData,
mysqlHandshakeV10,
mysqlOkPacket,
mysqlReadPackets,
mysqlTextResultSet,
} from "./wire-frames";

describeWithContainer(
"mysql",
Expand Down Expand Up @@ -59,5 +68,114 @@ describeWithContainer(
expect(result).toEqual([{ x: 1 }]);
await sql.end();
});

// MySQL 8's steady state for a passworded caching_sha2_password user: any
// successful full authentication warms the server's per-user auth cache, and
// every later connection takes the fast path (AuthMoreData 0x03
// fast_auth_success followed by the OK packet that concludes authentication).
// The client used to enter the command phase on the 0x03 marker alone, so the
// trailing OK was handed to the command handler and the second connection
// failed with ERR_MYSQL_UNEXPECTED_PACKET (or, if a query was already in
// flight, silently became that query's empty result).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 takes the fast path.
// allowPublicKeyRetrieval stays enabled only so that a concurrent test's
// FLUSH PRIVILEGES (which drops the whole auth cache) degrades this to
// full auth instead of flaking; the fast path is the overwhelmingly
// common outcome and is what the unfixed client fails on.
await using fast = new SQL({ url: userUrl, max: 1, allowPublicKeyRetrieval: true });
expect(await fast`select 'REAL-ROW' as v`).toEqual([{ v: "REAL-ROW" }]);
});
},
);

// ---------------------------------------------------------------------------
// caching_sha2_password fast authentication, byte-scripted.
//
// Covers the same exchange as the container test above, but against a scripted
// server so that (a) it runs without Docker and (b) both TCP framings of the
// AuthMoreData(0x03) + OK pair can be forced: a real server coalesces or splits
// them at its own discretion. All frames come from wire-frames.ts.
//
// https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html
// "Fast path succeeds": the server responds to HandshakeResponse41 with an
// AuthMoreData containing fast_auth_success (0x03), then sends an OK_Packet to
// conclude authentication. Bun used to enter the command phase on the 0x03
// marker alone; the trailing OK then either killed the connection
// (ERR_MYSQL_UNEXPECTED_PACKET, no query in flight yet) or was mis-attributed
// to the first query, which resolved with [] instead of its real rows.
// ---------------------------------------------------------------------------

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:pw@127.0.0.1:${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 did not send COM_QUERY until authentication
// actually completed (before the fix it never got to send one at all).
expect({ result, commands }).toEqual({
result: { rows: [{ v: "REAL-ROW" }] },
commands: [COM_QUERY],
});
} finally {
server.close();
}
},
);
40 changes: 37 additions & 3 deletions test/js/sql/wire-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,22 @@ export function mysqlHandshakeV10(
return mysqlRawPacket(0, payload);
}

// MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(0x00) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings)
export function mysqlOkPacket(seq: number): Buffer {
return mysqlRawPacket(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]));
// MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(header) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings)
// The header is 0x00, except for the CLIENT_DEPRECATE_EOF result-set terminator, which is an OK packet with a 0xFE header.
export function mysqlOkPacket(seq: number, header: 0x00 | 0xfe = 0x00): Buffer {
return mysqlRawPacket(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]));
}

// MySQL Protocol::AuthMoreData — page_protocol_connection_phase_packets_protocol_auth_more_data.html:
// Int<1>(0x01) String<EOF>(plugin-specific payload)
export function mysqlAuthMoreData(seq: number, data: Buffer): Buffer {
return mysqlRawPacket(seq, Buffer.concat([Buffer.from([0x01]), data]));
}

// caching_sha2_password fast_auth_success marker carried in an AuthMoreData payload —
// page_caching_sha2_authentication_exchanges.html (its sibling, 0x04, is perform_full_authentication).
export const MYSQL_FAST_AUTH_SUCCESS = 0x03;

// MySQL Protocol::AuthSwitchRequest — page_protocol_connection_phase_packets_protocol_auth_switch_request.html:
// Int<1>(0xfe) NulString(plugin_name) String<EOF>(plugin_provided_data)
export function mysqlAuthSwitchRequest(seq: number, pluginName: string, pluginData: Buffer): Buffer {
Expand Down Expand Up @@ -315,6 +326,29 @@ export function mysqlColumnDefinition(
);
}

// MySQL text-protocol resultset row — page_protocol_com_query_response_text_resultset_row.html:
// one string<lenenc> per column. (SQL NULL is the single byte 0xfb; not needed by any mock yet.)
export function mysqlTextResultSetRow(seq: number, cols: (string | Buffer)[]): Buffer {
return mysqlRawPacket(seq, Buffer.concat(cols.map(c => mysqlLenencStr(c))));
}

// MySQL Textual Resultset — page_protocol_com_query_response_text_resultset.html, in the
// CLIENT_DEPRECATE_EOF framing mysqlHandshakeV10's default capabilities negotiate:
// lenenc(column_count) packet, one ColumnDefinition41 per column, one row packet per row,
// then an OK packet with the 0xFE header as the terminator (no intermediate EOF).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
export function mysqlTextResultSet(
startSeq: number,
columns: { name: string; type: number }[],
rows: string[][],
): Buffer {
let seq = startSeq;
const parts: Buffer[] = [mysqlRawPacket(seq++, mysqlLenencInt(columns.length))];
for (const column of columns) parts.push(mysqlColumnDefinition(seq++, column));
for (const row of rows) parts.push(mysqlTextResultSetRow(seq++, row));
parts.push(mysqlOkPacket(seq, 0xfe));
return Buffer.concat(parts);
}

// MySQL COM_STMT_PREPARE_OK — page_protocol_com_stmt_prepare.html#sect_protocol_com_stmt_prepare_response_ok:
// Int<1>(0x00) Int<4>(statement_id) Int<2>(num_columns) Int<2>(num_params) Int<1>(0x00) Int<2>(warning_count)
export function mysqlStmtPrepareOk(seq: number, stmtId: number, numColumns: number, numParams: number): Buffer {
Expand Down
Loading