From 09ed21deb112623517b63b4596b217b1d65a3d46 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:57:09 +0000 Subject: [PATCH 1/2] sql(mysql): reject out-of-range binary TIME fields instead of wrapping Time::from_binary reads the 4-byte days field as a raw u32 and the TIME arm of decode_binary_value computed total_hours = hours + days*24 in u32 with no range check. MySQL TIME is bounded to +/-838:59:59 (days <= 34), so a hostile or buggy server/proxy sending days=178956971 made days*24 wrap past 2^32 to 8 and Bun returned the string "08:05:06" with no error. Reject days>34 / hours>23 / minutes>59 / seconds>59 with InvalidBinaryValue, matching the range validation the DATETIME path already does. --- .../mysql/protocol/DecodeBinaryValue.rs | 6 + .../sql/sql-mysql-time-binary-range.test.ts | 128 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/js/sql/sql-mysql-time-binary-range.test.ts diff --git a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs index f0db2ba1f0c5..ad4ad00be81f 100644 --- a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs +++ b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs @@ -131,6 +131,12 @@ pub fn decode_binary_value( l @ (8 | 12) => { let data = reader.read(l as usize)?; let time = Time::from_data(&data)?; + // MySQL TIME is capped at +/-838:59:59 (days<=34). days is a raw + // wire u32, so without this guard a hostile server can wrap + // days*24 below and yield a silently wrong string. + if time.days > 34 || time.hours > 23 || time.minutes > 59 || time.seconds > 59 { + return Err(crate::Error::InvalidBinaryValue); + } let total_hours: u32 = time.hours as u32 + time.days * 24; // -838:59:59 to 838:59:59 is valid (it only store seconds) diff --git a/test/js/sql/sql-mysql-time-binary-range.test.ts b/test/js/sql/sql-mysql-time-binary-range.test.ts new file mode 100644 index 000000000000..f6c150254280 --- /dev/null +++ b/test/js/sql/sql-mysql-time-binary-range.test.ts @@ -0,0 +1,128 @@ +// 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: the binary TIME decoder read the 4-byte `days` field as a raw u32 +// with no range check and computed total_hours = hours + days*24 in u32. MySQL +// TIME is bounded to +/-838:59:59 (days <= 34), so a hostile or buggy server +// sending days=178956971 made days*24 wrap past 2^32 to 8 and Bun returned the +// string "08:05:06" with no error. With the fix the decoder rejects any TIME +// field outside its documented range with ERR_MYSQL_INVALID_BINARY_VALUE. + +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import { + listeningServer, + mysqlColumnDefinition, + mysqlHandshakeV10, + mysqlLenencInt, + mysqlOkPacket, + mysqlRawPacket, + mysqlReadPackets, + mysqlStmtPrepareOk, +} from "./wire-frames"; + +const COM_STMT_PREPARE = 0x16; +const COM_STMT_EXECUTE = 0x17; +const COM_STMT_CLOSE = 0x19; +const COM_QUIT = 0x01; +const MYSQL_TYPE_TIME = 0x0b; + +// MySQL binary-protocol TIME cell (page_protocol_binary_resultset.html): +// Int<1>(length = 8 | 12) Int<1>(is_negative) Int<4>(days) Int<1>(hours) +// Int<1>(minutes) Int<1>(seconds) [Int<4>(microseconds)] +// mysqlRawPacket is the low-level escape hatch because this is the malformed +// payload under test; every other frame goes through a typed helper. +function binaryTimeRow(seq: number, t: { days: number; hours: number; minutes: number; seconds: number }): Buffer { + const cell = Buffer.alloc(9); + cell[0] = 8; + cell[1] = 0; // is_negative + cell.writeUInt32LE(t.days >>> 0, 2); + cell[6] = t.hours; + cell[7] = t.minutes; + cell[8] = t.seconds; + // Binary Resultset Row: Int<1>(0x00 header) null_bitmap[(cols+7+2)/8] values + return mysqlRawPacket(seq, Buffer.concat([Buffer.from([0x00, 0x00]), cell])); +} + +async function serveOneBinaryTime(t: { days: number; hours: number; minutes: number; seconds: number }) { + let sawExecute = false; + const { server, port } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let authed = false; + socket.write(mysqlHandshakeV10()); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!authed) { + authed = true; + socket.write(mysqlOkPacket(seq + 1)); + return; + } + const cmd = payload[0]; + if (cmd === COM_STMT_PREPARE) { + socket.write( + Buffer.concat([ + mysqlStmtPrepareOk(1, 1, 1, 1), + mysqlColumnDefinition(2, { name: "p", type: MYSQL_TYPE_TIME }), + mysqlColumnDefinition(3, { name: "t", type: MYSQL_TYPE_TIME }), + ]), + ); + } else if (cmd === COM_STMT_EXECUTE) { + sawExecute = true; + socket.write( + Buffer.concat([ + mysqlRawPacket(1, mysqlLenencInt(1)), + mysqlColumnDefinition(2, { name: "t", type: MYSQL_TYPE_TIME }), + binaryTimeRow(3, t), + mysqlOkPacket(4, 0xfe), + ]), + ); + } else if (cmd !== COM_STMT_CLOSE && cmd !== COM_QUIT) { + socket.write(mysqlOkPacket(1)); + } + }); + }); + socket.on("error", () => {}); + }); + return { server, port, sawExecute: () => sawExecute }; +} + +test.each([ + // 178956971 * 24 = 2^32 + 8: pre-fix this wrapped to total_hours=8 -> "08:05:06". + ["days wraps u32*24", { days: 178956971, hours: 0, minutes: 5, seconds: 6 }], + ["days = u32::MAX", { days: 0xffff_ffff, hours: 0, minutes: 5, seconds: 6 }], + ["days just over the 34-day bound", { days: 35, hours: 0, minutes: 0, seconds: 0 }], + ["hours > 23", { days: 0, hours: 24, minutes: 0, seconds: 0 }], + ["minutes > 59", { days: 0, hours: 0, minutes: 60, seconds: 0 }], + ["seconds > 59", { days: 0, hours: 0, minutes: 0, seconds: 60 }], +] as const)("MySQL: binary TIME with %s is rejected, not silently wrapped", async (_label, t) => { + const { server, port, sawExecute } = await serveOneBinaryTime(t); + try { + await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); + const outcome = await sql`select ${1} as t`.values().then( + rows => ({ ok: true as const, rows }), + e => ({ ok: false as const, code: e?.code ?? String(e) }), + ); + + // Pre-fix: outcome is { ok: true, rows: [["08:05:06"]] } (or similar garbage). + expect({ outcome, sawExecute: sawExecute() }).toEqual({ + outcome: { ok: false, code: "ERR_MYSQL_INVALID_BINARY_VALUE" }, + sawExecute: true, + }); + } finally { + await new Promise(r => server.close(() => r())); + } +}); + +test("MySQL: binary TIME at the documented maximum (838:59:59) still decodes", async () => { + const { server, port, sawExecute } = await serveOneBinaryTime({ days: 34, hours: 22, minutes: 59, seconds: 59 }); + try { + await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); + const rows = await sql`select ${1} as t`.values(); + expect({ rows, sawExecute: sawExecute() }).toEqual({ rows: [["838:59:59"]], sawExecute: true }); + } finally { + await new Promise(r => server.close(() => r())); + } +}); From 4526a955beed0a6a372fd159848f33e12c0f5579 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:02:34 +0000 Subject: [PATCH 2/2] sql(mysql): also reject binary TIME total_hours > 838 The per-field guard allowed days=34 hours=23 (839 hours), one past the documented +/-838:59:59 cap. days<=34 already bounds days*24 to 816 so the u32 sum is overflow-free; add a total_hours > 838 check after it. --- src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs | 9 ++++++--- test/js/sql/sql-mysql-time-binary-range.test.ts | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs index ad4ad00be81f..12fbd22455d6 100644 --- a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs +++ b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs @@ -131,14 +131,17 @@ pub fn decode_binary_value( l @ (8 | 12) => { let data = reader.read(l as usize)?; let time = Time::from_data(&data)?; - // MySQL TIME is capped at +/-838:59:59 (days<=34). days is a raw - // wire u32, so without this guard a hostile server can wrap - // days*24 below and yield a silently wrong string. + // MySQL TIME is capped at +/-838:59:59. days is a raw wire u32; + // bounding it to 34 keeps days*24 <= 816 so the u32 sum below + // cannot wrap, then total_hours > 838 rejects the remainder. if time.days > 34 || time.hours > 23 || time.minutes > 59 || time.seconds > 59 { return Err(crate::Error::InvalidBinaryValue); } let total_hours: u32 = time.hours as u32 + time.days * 24; + if total_hours > 838 { + return Err(crate::Error::InvalidBinaryValue); + } // -838:59:59 to 838:59:59 is valid (it only store seconds) // it should be represented as HH:MM:SS or HHH:MM:SS if total_hours > 99 let mut buffer = [0u8; 32]; diff --git a/test/js/sql/sql-mysql-time-binary-range.test.ts b/test/js/sql/sql-mysql-time-binary-range.test.ts index f6c150254280..b7514396cfd7 100644 --- a/test/js/sql/sql-mysql-time-binary-range.test.ts +++ b/test/js/sql/sql-mysql-time-binary-range.test.ts @@ -94,6 +94,7 @@ test.each([ ["days wraps u32*24", { days: 178956971, hours: 0, minutes: 5, seconds: 6 }], ["days = u32::MAX", { days: 0xffff_ffff, hours: 0, minutes: 5, seconds: 6 }], ["days just over the 34-day bound", { days: 35, hours: 0, minutes: 0, seconds: 0 }], + ["days=34 hours=23 (839h, past the 838h cap)", { days: 34, hours: 23, minutes: 0, seconds: 0 }], ["hours > 23", { days: 0, hours: 24, minutes: 0, seconds: 0 }], ["minutes > 59", { days: 0, hours: 0, minutes: 60, seconds: 0 }], ["seconds > 59", { days: 0, hours: 0, minutes: 0, seconds: 60 }],