diff --git a/src/sql/postgres/types/Tag.rs b/src/sql/postgres/types/Tag.rs index 7911636b393a..4a88cc197cd1 100644 --- a/src/sql/postgres/types/Tag.rs +++ b/src/sql/postgres/types/Tag.rs @@ -206,6 +206,17 @@ impl Tag { 0 } + /// True when this type's binary `*send()` output is byte-identical to its + /// text output, so decoding the binary datum via the text path is already + /// correct. `jsonb` is intentionally absent (jsonb_send prepends a version + /// byte). + pub fn is_binary_format_textlike(self) -> bool { + matches!( + self, + Tag::text | Tag::varchar | Tag::bpchar | Tag::name | Tag::char | Tag::json | Tag::xml + ) + } + // `toJSTypedArrayType` / `toJS` / `fromJS` are extension-trait methods in // `bun_sql_jsc`. diff --git a/src/sql_jsc/postgres/DataCell.rs b/src/sql_jsc/postgres/DataCell.rs index 0eee5e1ca5c0..023667c8c5b8 100644 --- a/src/sql_jsc/postgres/DataCell.rs +++ b/src/sql_jsc/postgres/DataCell.rs @@ -102,6 +102,9 @@ fn try_slice(slice: &[u8], count: usize) -> &[u8] { const MAX_ARRAY_NESTING_DEPTH: usize = 100; +// Microseconds in a day. PostgreSQL's TIME/TIMETZ range is [00:00:00, 24:00:00]. +const USECS_PER_DAY: i64 = 86_400_000_000; + // PERF: `array_type` and `is_json_sub_array` are only used in value // position (branch selectors), never type position. Profile if it shows up on a hot path. fn parse_array( @@ -684,9 +687,20 @@ fn from_bytes_typed_array( } else { let mut out: Box<[u8]> = vec![0u8; out_bytes].into_boxed_slice(); for i in 0..array_len { - // Wire layout per element for the 4-byte types this path - // supports (int4/float4): [elem_size length prefix][elem_size value] - let src_off = 20 + i * element_stride + (element_stride - elem_size); + // Wire layout per element: [int4 length prefix][value]. For the + // fixed-size types this path supports (int4/float4) the declared + // length must equal elem_size; validate before trusting the stride. + let len_off = 20 + i * element_stride; + let elem_len = i32::from_ne_bytes( + bytes[len_off..len_off + 4] + .try_into() + .expect("infallible: size matches"), + ) + .swap_bytes(); + if elem_len != elem_size as i32 { + return Err(AnyPostgresError::InvalidBinaryData); + } + let src_off = len_off + 4; // `bytes.len() >= 20 + array_len*element_stride` was validated // above; `out` has `array_len*elem_size` bytes. The trait's // `from_unaligned_ne_bytes`/`write_unaligned_ne_bytes` are safe @@ -778,7 +792,9 @@ pub(crate) fn from_bytes( } } T::float8 => { - if binary && bytes.len() == 8 { + if binary { + // Binary float8 is exactly 8 bytes; reject a short/long datum + // instead of falling through to the text parser (which yields NaN). Ok(SQLDataCell::float8(parse_binary_float8(bytes)?)) } else { Ok(SQLDataCell::float8( @@ -787,7 +803,13 @@ pub(crate) fn from_bytes( } } T::float4 => { - if binary && bytes.len() == 4 { + if binary { + // Binary float4 is exactly 4 bytes. parse_binary_float4 goes + // through parse_binary_int4, which also accepts 1/2 bytes, so + // enforce the width here. + if bytes.len() != 4 { + return Err(AnyPostgresError::InvalidBinaryData); + } Ok(SQLDataCell::float8(parse_binary_float4(bytes)? as f64)) } else { Ok(SQLDataCell::float8( @@ -812,16 +834,23 @@ pub(crate) fn from_bytes( T::jsonb | T::json => Ok(SQLDataCell::json(bytes)), T::bool => { if binary { - Ok(SQLDataCell::bool_(!bytes.is_empty() && bytes[0] == 1)) + // Binary bool is exactly 1 byte valued 0 or 1. + if bytes.len() != 1 || bytes[0] > 1 { + return Err(AnyPostgresError::InvalidBinaryData); + } + Ok(SQLDataCell::bool_(bytes[0] == 1)) } else { Ok(SQLDataCell::bool_(!bytes.is_empty() && bytes[0] == b't')) } } tag @ (T::date | T::timestamp | T::timestamptz) => { - if bytes.is_empty() { - return Ok(SQLDataCell::null()); - } - if binary && bytes.len() == 8 { + if binary { + // Binary timestamp/timestamptz is an 8-byte int64 of + // microseconds. A 0-byte or wrong-width datum is rejected + // rather than surfaced as null or an Invalid Date. + if bytes.len() != 8 { + return Err(AnyPostgresError::InvalidBinaryData); + } match tag { T::timestamptz => Ok(SQLDataCell::date_with_tz( crate::postgres::types::date::from_binary(bytes), @@ -832,6 +861,9 @@ pub(crate) fn from_bytes( _ => unreachable!(), } } else { + if bytes.is_empty() { + return Ok(SQLDataCell::null()); + } if bun_core::strings::eql_case_insensitive_ascii(bytes, b"NULL", true) { return Ok(SQLDataCell::null()); } @@ -855,14 +887,17 @@ pub(crate) fn from_bytes( } } tag @ (T::time | T::timetz) => { - if bytes.is_empty() { - return Ok(SQLDataCell::null()); - } if binary { if tag == T::time && bytes.len() == 8 { // PostgreSQL sends time as microseconds since midnight in binary format let microseconds = i64::from_ne_bytes(bytes[0..8].try_into().expect("infallible: size matches")).swap_bytes(); + // Valid range is [0, 24h]; out-of-range microseconds would + // otherwise feed the C formatter and yield garbage strings. + if !(0..=USECS_PER_DAY).contains(µseconds) { + return Err(AnyPostgresError::InvalidTimeFormat); + } + // Use C++ helper for formatting let mut buffer = [0u8; 32]; let len = Postgres__formatTime(microseconds, &mut buffer, 32); @@ -873,6 +908,10 @@ pub(crate) fn from_bytes( let microseconds = i64::from_ne_bytes(bytes[0..8].try_into().expect("infallible: size matches")).swap_bytes(); let tz_offset_seconds = i32::from_ne_bytes(bytes[8..12].try_into().expect("infallible: size matches")).swap_bytes(); + if !(0..=USECS_PER_DAY).contains(µseconds) { + return Err(AnyPostgresError::InvalidTimeFormat); + } + // Use C++ helper for formatting with timezone let mut buffer = [0u8; 48]; let len = Postgres__formatTimeTz(microseconds, tz_offset_seconds, &mut buffer, 48); @@ -882,6 +921,9 @@ pub(crate) fn from_bytes( Err(AnyPostgresError::InvalidBinaryData) } } else { + if bytes.is_empty() { + return Ok(SQLDataCell::null()); + } // Text format - just return as string Ok(SQLDataCell::string(bytes)) } @@ -1022,6 +1064,16 @@ fn parse_binary_numeric<'a>( dscale ); + // The wire format is the 8-byte header followed by exactly `ndigits` + // base-10000 digits (2 bytes each). Reject a negative count, a negative + // display scale, and any trailing bytes before trusting the header. + if ndigits < 0 || dscale < 0 { + return Err(err!("InvalidBuffer")); + } + if input.len() != 8 + (ndigits as usize) * 2 { + return Err(err!("InvalidBuffer")); + } + // Handle special cases match sign { 0xC000 => return Ok(PGNummericString::Static(b"NaN")), @@ -1249,6 +1301,14 @@ impl<'a> Putter<'a> { // construct directly, no transmute needed. types::Tag(oid as types::short) }; + // Reject a binary format code on a type we have no binary decoder + // for so raw datum bytes cannot surface as a value. Text-family + // types are exempt: their `*send()` output is byte-identical to + // text (and a BINARY CURSOR FETCH sets format=1 on every column). + if field.binary && !tag.is_binary_format_supported() && !tag.is_binary_format_textlike() + { + return Err(AnyPostgresError::UnknownFormatCode); + } *cell = if let Some(data) = optional_bytes { from_bytes( (field.binary || self.binary) && tag.is_binary_format_supported(), diff --git a/test/js/sql/postgres-binary-datum-validation.test.ts b/test/js/sql/postgres-binary-datum-validation.test.ts new file mode 100644 index 000000000000..8025a8997858 --- /dev/null +++ b/test/js/sql/postgres-binary-datum-validation.test.ts @@ -0,0 +1,234 @@ +// Fault-injection test: requires a server that sends malformed binary-format +// datums, which a healthy Postgres never does on demand. DO NOT COPY THIS +// PATTERN for behavior a real server can produce. All wire-protocol frames come +// from test/js/sql/wire-frames.ts; do not inline Buffer.alloc frame building. +// +// A RowDescription can declare format=1 (binary) for a column, after which the +// server authors the datum bytes. Bun must validate each binary datum's length +// (and range) against the declared type before decoding, and reject a binary +// format code for a type it has no binary decoder for, instead of silently +// turning a wire-level violation into a plausible JS value. +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; + +// Big-endian integer encoders for assembling hostile *column payloads* (the +// datum bytes inside a DataRow); these are not wire frames. +const i16 = (n: number): Buffer => { + const b = Buffer.alloc(2); + b.writeInt16BE(n, 0); + return b; +}; +const u16 = (n: number): Buffer => { + const b = Buffer.alloc(2); + b.writeUInt16BE(n, 0); + return b; +}; +const i32 = (n: number): Buffer => { + const b = Buffer.alloc(4); + b.writeInt32BE(n, 0); + return b; +}; +const i64 = (n: bigint): Buffer => { + const b = Buffer.alloc(8); + b.writeBigInt64BE(n, 0); + return b; +}; + +async function runMockQuery(columnBytes: Buffer, typeOid: number): Promise { + const { port, server } = await listeningServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write( + Buffer.concat([ + pgRowDescription([{ name: "c", typeOid, format: 1 /* binary */ }]), + pgDataRow([columnBytes]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + socket.on("error", () => {}); + }); + + const sql = new SQL({ + url: `postgres://u@127.0.0.1:${port}/db`, + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + + try { + return await sql`select c`.simple(); + } finally { + await sql.close().catch(() => {}); + await new Promise(r => server.close(() => r())); + } +} + +const BOOL = 16; +const TEXT = 25; +const JSON_OID = 114; +const INT4_ARRAY = 1007; +const FLOAT4 = 700; +const FLOAT8 = 701; +const TIME = 1083; +const TIMESTAMP = 1114; +const NUMERIC = 1700; +const UUID = 2950; +const INT4 = 23; + +// Binary numeric header — PostgreSQL numeric_send(): Int16 ndigits, Int16 +// weight, uint16 sign, uint16 dscale, then ndigits Int16 base-10000 groups. +function numericHeader(ndigits: number, weight: number, sign: number, dscale: number): Buffer { + return Buffer.concat([i16(ndigits), i16(weight), u16(sign), u16(dscale)]); +} + +const malformed: { name: string; oid: number; col: Buffer; code: RegExp }[] = [ + { + name: "float8 with 4-byte datum", + oid: FLOAT8, + col: i32(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + // parse_binary_int4 accepts len 1/2/4, so without the explicit width + // guard this would silently yield a garbage float. + name: "float4 with 2-byte datum", + oid: FLOAT4, + col: i16(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "timestamp with 4-byte datum", + oid: TIMESTAMP, + col: i32(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "timestamp with 0-byte datum", + oid: TIMESTAMP, + col: Buffer.alloc(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "time with 0-byte datum", + oid: TIME, + col: Buffer.alloc(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "bool with 0-byte datum", + oid: BOOL, + col: Buffer.alloc(0), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "bool with out-of-range value 2", + oid: BOOL, + col: Buffer.from([2]), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + // 20-byte header declares one element, but its length prefix claims 8 + // bytes for a 4-byte int4 element. + name: "int4[] element length prefix != 4", + oid: INT4_ARRAY, + col: Buffer.concat([i32(1), i32(0), i32(INT4), i32(1), i32(1), i32(8), i64(0n)]), + code: /ERR_POSTGRES_INVALID_BINARY_DATA/, + }, + { + name: "time = 2^63-1 microseconds (out of range)", + oid: TIME, + col: i64(0x7fffffffffffffffn), + code: /ERR_POSTGRES_INVALID_TIME_FORMAT/, + }, + { + name: "time = -1 microseconds (negative)", + oid: TIME, + col: i64(-1n), + code: /ERR_POSTGRES_INVALID_TIME_FORMAT/, + }, + { + // ndigits=0 header followed by trailing junk bytes. + name: "numeric with trailing bytes after ndigits=0", + oid: NUMERIC, + col: Buffer.concat([numericHeader(0, 0, 0x0000, 0), Buffer.from([0xde, 0xad, 0xbe, 0xef])]), + code: /ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT/, + }, + { + // dscale read as a signed int16 is negative (0x8000 = -32768). + name: "numeric with negative dscale", + oid: NUMERIC, + col: Buffer.concat([numericHeader(1, 0, 0x0000, 0x8000), i16(1)]), + code: /ERR_POSTGRES_UNSUPPORTED_NUMERIC_FORMAT/, + }, + { + // 16 raw bytes with a binary format code on a type Bun cannot binary-decode. + name: "uuid sent with binary format code", + oid: UUID, + col: Buffer.alloc(16, 0xab), + code: /ERR_POSTGRES_UNKNOWN_FORMAT_CODE/, + }, +]; + +test.concurrent.each(malformed)("binary $name is rejected", async ({ oid, col, code }) => { + let err: any; + try { + await runMockQuery(col, oid); + } catch (e) { + err = e; + } + expect(err).toBeDefined(); + expect(err?.code ?? err?.message).toMatch(code); +}); + +test.concurrent("well-formed binary bool still parses", async () => { + const result: any = await runMockQuery(Buffer.from([1]), BOOL); + expect(result[0].c).toBe(true); +}); + +test.concurrent("well-formed binary float8 still parses", async () => { + const buf = Buffer.alloc(8); + buf.writeDoubleBE(1.5, 0); + const result: any = await runMockQuery(buf, FLOAT8); + expect(result[0].c).toBe(1.5); +}); + +test.concurrent("in-range binary time still parses", async () => { + // 01:02:03 = 3723 seconds = 3_723_000_000 microseconds. + const result: any = await runMockQuery(i64(3_723_000_000n), TIME); + expect(result[0].c).toBe("01:02:03"); +}); + +test.concurrent("well-formed binary numeric still parses", async () => { + const col = Buffer.concat([numericHeader(1, 0, 0x0000, 0), i16(1)]); + const result: any = await runMockQuery(col, NUMERIC); + expect(result[0].c).toBe("1"); +}); + +// A BINARY CURSOR FETCH over the simple protocol sends format=1 for every +// column. Types whose *send() output is byte-identical to text (text/varchar/ +// bpchar/name/char/json/xml) must decode via the text path, not be rejected. +test.concurrent("text column with binary format code is exempt from the guard", async () => { + const result: any = await runMockQuery(Buffer.from("hello"), TEXT); + expect(result[0].c).toBe("hello"); +}); + +test.concurrent("json column with binary format code is exempt from the guard", async () => { + const result: any = await runMockQuery(Buffer.from('{"a":1}'), JSON_OID); + expect(result[0].c).toEqual({ a: 1 }); +});