diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 9ae612954eeb..9fa54c252235 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -2721,6 +2721,10 @@ pub fn render_to_ansi<'a>( theme: Theme<'a>, ) -> Result>, crate::parser::ParserError> { use crate::parser::ParserError; + // `AnsiRenderer::init` reserves output space proportional to the input, so + // an input the parser cannot address has to be rejected before it is + // allocated for, not only when `Parser::init` sees it. + crate::parser::input_size(text)?; let mut renderer = AnsiRenderer::init(text, theme); match root::render_with_renderer(text, options, renderer.renderer()) { Ok(()) => {} diff --git a/src/md/parser.rs b/src/md/parser.rs index 97bfbdd946b7..a9ddceb87a15 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -129,8 +129,8 @@ impl Default for BlockHeader { } } -/// `Parser`'s error type: the union -/// of `{ OutOfMemory, JSError, JSTerminated }` with `{ StackOverflow }`. +/// `Parser`'s error type: the union of `{ OutOfMemory, JSError, JSTerminated }` +/// with the parser-specific `{ StackOverflow, InputTooLarge }`. // (`bun_jsc::JsError` covers the first three, but the md crate sits below // `bun_jsc` in the layering, so the variants stay flat here.) pub type Error = ParserError; @@ -141,6 +141,9 @@ pub enum ParserError { JSError, JSTerminated, StackOverflow, + /// The input is longer than `OFF::MAX` bytes, so its offsets cannot be + /// represented by the parser's `u32` offset type. + InputTooLarge, } bun_core::oom_from_alloc!(ParserError); @@ -151,6 +154,14 @@ impl From for bun_core::Error { } } +/// Every offset, mark and span boundary in the parser is an `OFF` (u32), so an +/// input of 2^32 bytes or more cannot be indexed. Callers that size anything +/// from the input length must reject it with this before allocating. +#[inline] +pub(crate) fn input_size(text: &[u8]) -> Result { + OFF::try_from(text.len()).map_err(|_| ParserError::InputTooLarge) +} + impl<'a> Parser<'a> { pub fn get_block_header_at(&mut self, off: usize) -> &mut BlockHeader { // SAFETY: `off` is produced by start_new_block / push_container_bytes which pad it @@ -174,8 +185,8 @@ impl<'a> Parser<'a> { self.get_block_header_at(off) } - fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Parser<'a> { - let size: OFF = OFF::try_from(text.len()).expect("int cast"); + fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Result, ParserError> { + let size = input_size(text)?; let mut p = Parser { text, size, @@ -218,7 +229,7 @@ impl<'a> Parser<'a> { stack_check: StackCheck::init(), }; p.build_mark_char_map(); - p + Ok(p) } // All owned buffers are `Vec<_>`, so `Drop` is automatic — no explicit impl. @@ -335,15 +346,9 @@ pub fn render_to_html( let mut html_renderer = HtmlRenderer::init(input, render_opts); - let mut parser = Parser::init(input, flags, html_renderer.renderer()); + let mut parser = Parser::init(input, flags, html_renderer.renderer())?; - // HtmlRenderer never returns JSError/JSTerminated, so OutOfMemory is the only possible error. - match parser.process_doc() { - Ok(()) => {} - Err(ParserError::OutOfMemory) => return Err(ParserError::OutOfMemory), - Err(ParserError::JSError) | Err(ParserError::JSTerminated) => unreachable!(), - Err(ParserError::StackOverflow) => return Err(ParserError::StackOverflow), - } + parser.process_doc()?; drop(parser); Ok(html_renderer.to_owned_slice()?) @@ -362,7 +367,7 @@ pub fn render_with_renderer<'a>( let _ = render_options; // Available for renderer implementations; parse layer does not use these. let input = helpers::skip_utf8_bom(text); - let mut p = Parser::init(input, flags, rend); + let mut p = Parser::init(input, flags, rend)?; p.process_doc() } diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs index 69d176546e58..089dba2210c9 100644 --- a/src/runtime/api/MarkdownObject.rs +++ b/src/runtime/api/MarkdownObject.rs @@ -1,7 +1,10 @@ //! `Bun.markdown` — html/ansi/react/render host fns over `bun_md`. use bun_core::StackCheck; -use bun_jsc::{ArrayBuffer, CallFrame, JSGlobalObject, JSValue, JsResult, MarkedArgumentBuffer}; +use bun_jsc::{ + ArrayBuffer, CallFrame, JSGlobalObject, JSValue, JsResult, MarkedArgumentBuffer, + RangeErrorOptions, +}; // Note: the `bun_md` crate's lib.rs is a // thin mod-decl shim, so alias the `root` module (which re-exports BlockType, // SpanType, TextType, SpanDetail, Renderer, helpers, types, ansi, …) as `md`. @@ -32,6 +35,33 @@ fn js_to_parser_err(e: bun_jsc::JsError) -> ParserError { } } +/// Throw the JS exception for a `ParserError` returned by `bun_md`. +/// `input_len` is the byte length of the rendered input, reported back by the +/// `InputTooLarge` range error. +#[cold] +fn parser_err_to_js( + global_this: &JSGlobalObject, + err: ParserError, + input_len: usize, +) -> bun_jsc::JsError { + match err { + // A renderer callback threw (or the VM is terminating); the exception + // is already pending on the VM. + ParserError::JSError => bun_jsc::JsError::Thrown, + ParserError::JSTerminated => bun_jsc::JsError::Terminated, + ParserError::OutOfMemory => global_this.throw_out_of_memory(), + ParserError::StackOverflow => global_this.throw_stack_overflow(), + ParserError::InputTooLarge => global_this.throw_range_error( + input_len as i64, + RangeErrorOptions { + max: md::types::OFF::MAX as i64, + field_name: b"input.byteLength", + ..Default::default() + }, + ), + } +} + struct PinnedView(ArrayBuffer); impl PinnedView { @@ -135,9 +165,7 @@ pub fn render_to_ansi(global_this: &JSGlobalObject, callframe: &CallFrame) -> Js // path is unreachable but handle it safely. return Err(global_this.throw_out_of_memory()); } - Err(ParserError::OutOfMemory) => return Err(global_this.throw_out_of_memory()), - Err(ParserError::StackOverflow) => return Err(global_this.throw_stack_overflow()), - Err(_) => return Err(global_this.throw_out_of_memory()), + Err(err) => return Err(parser_err_to_js(global_this, err, input.len())), }; create_utf8_for_js(global_this, &result) @@ -170,7 +198,7 @@ pub(crate) fn render_to_html( let result = match md::render_to_html_with_options(input, options) { Ok(r) => r, - Err(_) => return Err(global_this.throw_out_of_memory()), + Err(err) => return Err(parser_err_to_js(global_this, err, input.len())), }; create_utf8_for_js(global_this, &result) @@ -287,12 +315,7 @@ pub(crate) fn render(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsR // Run parser with the JS callback renderer if let Err(err) = md::render_with_renderer(input, options, js_renderer.renderer()) { - return match err { - ParserError::JSError => Err(bun_jsc::JsError::Thrown), - ParserError::JSTerminated => Err(bun_jsc::JsError::Terminated), - ParserError::OutOfMemory => Err(global_this.throw_out_of_memory()), - ParserError::StackOverflow => Err(global_this.throw_stack_overflow()), - }; + return Err(parser_err_to_js(global_this, err, input.len())); } // Return accumulated result @@ -392,12 +415,7 @@ fn render_ast( })?; if let Err(err) = md::render_with_renderer(input, options, renderer.renderer()) { - return match err { - ParserError::JSError => Err(bun_jsc::JsError::Thrown), - ParserError::JSTerminated => Err(bun_jsc::JsError::Terminated), - ParserError::OutOfMemory => Err(global_this.throw_out_of_memory()), - ParserError::StackOverflow => Err(global_this.throw_stack_overflow()), - }; + return Err(parser_err_to_js(global_this, err, input.len())); } Ok(renderer.get_result()) diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index cab00d155b87..221d716ab5d5 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1189,3 +1189,58 @@ describe("pathological autolink opener inputs", () => { ); }, 90_000); }); + +describe("inputs of 2^32 bytes or more", () => { + // The parser addresses its input with u32 offsets, so a 2^32-byte input + // cannot be represented and must be rejected with a catchable RangeError by + // every entry point. One subprocess covers all four: a crash must not take + // down the test runner, and one 4 GiB reservation (virtual only, never + // written) keeps this cheap. The SKIP branch covers runners that cannot + // reserve 4 GiB; under ASAN the allocator must be allowed to return null + // for that to surface as a catchable error rather than an abort. The + // explicit timeout is for the debug+ASAN lanes, where a spawned child is + // slow under load (same as the linear-time test above). + test("html, ansi, render and react reject a 2^32-byte input", async () => { + const script = ` + let big; + try { + big = new Uint8Array(2 ** 32); + } catch { + console.log(JSON.stringify("SKIP")); + process.exit(0); + } + const runs = [ + () => Bun.markdown.html(big), + () => Bun.markdown.ansi(big), + () => Bun.markdown.render(big, {}), + () => Bun.markdown.react(big, undefined, { reactVersion: 18 }), + ]; + const results = []; + for (const run of runs) { + try { + run(); + results.push("UNEXPECTED_SUCCESS"); + } catch (e) { + results.push([e.constructor.name, e.code, e.message].join(" | ")); + } + } + console.log(JSON.stringify(results)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const rangeError = + 'RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296'; + expect(["SKIP", [rangeError, rangeError, rangeError, rangeError]]).toContainEqual( + JSON.parse(stdout.trim() || '"NO_OUTPUT"'), + ); + expect(exitCode).toBe(0); + }, 30_000); +});