From b0667724866fd2ca9e7315d9f4d1411b595e2685 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:29:58 +0000 Subject: [PATCH 1/5] markdown: reject inputs of 2^32 bytes or more instead of panicking Parser::init stored the input length via OFF::try_from(text.len()).expect(), so Bun.markdown.html/ansi/render/react with a 4 GiB typed array crashed with 'panic: int cast: TryFromIntError(PosOverflow)'. Every offset in the parser is a u32, so such inputs cannot be represented at all. Add ParserError::InputTooLarge, return it from Parser::init, and surface it from the Bun.markdown host functions as a RangeError (ERR_OUT_OF_RANGE) naming input.byteLength and the 4294967295-byte limit. The four host functions now share one ParserError -> JS exception mapping, which also stops StackOverflow from being reported as out-of-memory by Bun.markdown.html. --- src/md/parser.rs | 26 ++++++++++---- src/runtime/api/MarkdownObject.rs | 52 +++++++++++++++++++--------- test/js/bun/md/md-edge-cases.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/src/md/parser.rs b/src/md/parser.rs index 97bfbdd946b7..9fb3dc971da1 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -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); @@ -174,8 +177,13 @@ 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> { + // Every offset, mark and span boundary in the parser is an `OFF` + // (u32). An input of 2^32 bytes or more cannot be indexed, so refuse + // it up front instead of panicking on the cast. + let Ok(size) = OFF::try_from(text.len()) else { + return Err(ParserError::InputTooLarge); + }; let mut p = Parser { text, size, @@ -218,7 +226,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,13 +343,17 @@ 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. + // HtmlRenderer never returns JSError/JSTerminated, and InputTooLarge is + // only produced by `Parser::init` (already propagated above), so + // OutOfMemory and StackOverflow are the only possible errors. match parser.process_doc() { Ok(()) => {} Err(ParserError::OutOfMemory) => return Err(ParserError::OutOfMemory), - Err(ParserError::JSError) | Err(ParserError::JSTerminated) => unreachable!(), + Err(ParserError::JSError) + | Err(ParserError::JSTerminated) + | Err(ParserError::InputTooLarge) => unreachable!(), Err(ParserError::StackOverflow) => return Err(ParserError::StackOverflow), } drop(parser); @@ -362,7 +374,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..6b9525e0f120 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1189,3 +1189,45 @@ 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. + // Run in a subprocess so a crash cannot take down the test runner; the + // buffer is virtual-only (never written), so RSS stays small. The SKIP + // branch covers runners that cannot reserve 4 GiB at all. + test.each([ + ["html", "Bun.markdown.html(big)"], + ["ansi", "Bun.markdown.ansi(big)"], + ["render", "Bun.markdown.render(big, {})"], + ["react", "Bun.markdown.react(big, undefined, { reactVersion: 18 })"], + ])("%s rejects a 2^32-byte input", async (_name, expr) => { + const script = ` + let big; + try { + big = new Uint8Array(2 ** 32); + } catch { + console.log("SKIP"); + process.exit(0); + } + try { + ${expr}; + console.log("UNEXPECTED_SUCCESS"); + } catch (e) { + console.log([e.constructor.name, e.code, e.message].join(" | ")); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect([ + 'RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296', + "SKIP", + ]).toContain(stdout.trim()); + expect(exitCode).toBe(0); + }); +}); From 5a01093aef2c568d74226e147165e3efbb825d59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:49:44 +0000 Subject: [PATCH 2/5] markdown: propagate process_doc errors instead of matching variants as unreachable --- src/md/parser.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/md/parser.rs b/src/md/parser.rs index 9fb3dc971da1..1675fe2a4970 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -345,17 +345,7 @@ pub fn render_to_html( let mut parser = Parser::init(input, flags, html_renderer.renderer())?; - // HtmlRenderer never returns JSError/JSTerminated, and InputTooLarge is - // only produced by `Parser::init` (already propagated above), so - // OutOfMemory and StackOverflow are the only possible errors. - match parser.process_doc() { - Ok(()) => {} - Err(ParserError::OutOfMemory) => return Err(ParserError::OutOfMemory), - Err(ParserError::JSError) - | Err(ParserError::JSTerminated) - | Err(ParserError::InputTooLarge) => unreachable!(), - Err(ParserError::StackOverflow) => return Err(ParserError::StackOverflow), - } + parser.process_doc()?; drop(parser); Ok(html_renderer.to_owned_slice()?) From 231ea419936264f12da95f0c30e76ec50763a667 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:08:09 +0000 Subject: [PATCH 3/5] markdown: reject oversized input before the ansi renderer reserves output space AnsiRenderer::init reserves 1.5x the input length for the output buffer, and ran before Parser::init's length check, so Bun.markdown.ansi could still abort inside Vec::reserve on a host that cannot reserve ~6 GiB. Hoist the check into md::render_to_ansi via a shared parser::input_size helper so nothing is sized from an input the parser is going to reject. --- src/md/ansi_renderer.rs | 4 ++++ src/md/parser.rs | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) 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 1675fe2a4970..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; @@ -154,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 @@ -178,12 +186,7 @@ impl<'a> Parser<'a> { } fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Result, ParserError> { - // Every offset, mark and span boundary in the parser is an `OFF` - // (u32). An input of 2^32 bytes or more cannot be indexed, so refuse - // it up front instead of panicking on the cast. - let Ok(size) = OFF::try_from(text.len()) else { - return Err(ParserError::InputTooLarge); - }; + let size = input_size(text)?; let mut p = Parser { text, size, From 7970229bb01a7e411e51d9cf5a56ee96ae5d00ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:30:01 +0000 Subject: [PATCH 4/5] markdown: cover all four oversized-input entry points from one subprocess Four sequential children, each reserving 4 GiB under a debug ASAN build, can push a loaded runner past the per-test budget. One child exercises html, ansi, render and react and reports all four results; same coverage, one spawn. --- test/js/bun/md/md-edge-cases.test.ts | 47 ++++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 6b9525e0f120..d5e5f611f374 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1192,30 +1192,36 @@ describe("pathological autolink opener inputs", () => { 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. - // Run in a subprocess so a crash cannot take down the test runner; the - // buffer is virtual-only (never written), so RSS stays small. The SKIP - // branch covers runners that cannot reserve 4 GiB at all. - test.each([ - ["html", "Bun.markdown.html(big)"], - ["ansi", "Bun.markdown.ansi(big)"], - ["render", "Bun.markdown.render(big, {})"], - ["react", "Bun.markdown.react(big, undefined, { reactVersion: 18 })"], - ])("%s rejects a 2^32-byte input", async (_name, expr) => { + // 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 at all. + 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("SKIP"); + console.log(JSON.stringify("SKIP")); process.exit(0); } - try { - ${expr}; - console.log("UNEXPECTED_SUCCESS"); - } catch (e) { - console.log([e.constructor.name, e.code, e.message].join(" | ")); + 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], @@ -1224,10 +1230,11 @@ describe("inputs of 2^32 bytes or more", () => { stderr: "inherit", }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect([ - 'RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296', - "SKIP", - ]).toContain(stdout.trim()); + 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); }); }); From dd9dae6fdc79afdd68aad578aa08056b5f195572 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:07:15 +0000 Subject: [PATCH 5/5] markdown: make the oversized-input test robust on loaded debug+ASAN runners The child reserves 4 GiB and runs under a debug+ASAN build: give the test the same explicit timeout its perf-test neighbors in this file use, and let the ASan allocator return null so an allocation failure inside the child surfaces as a catchable error (the SKIP branch) instead of an abort it cannot catch. --- test/js/bun/md/md-edge-cases.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index d5e5f611f374..221d716ab5d5 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1196,7 +1196,10 @@ describe("inputs of 2^32 bytes or more", () => { // 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 at all. + // 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; @@ -1225,7 +1228,10 @@ describe("inputs of 2^32 bytes or more", () => { `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], - env: bunEnv, + env: { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1"].filter(Boolean).join(":"), + }, stdout: "pipe", stderr: "inherit", }); @@ -1236,5 +1242,5 @@ describe("inputs of 2^32 bytes or more", () => { JSON.parse(stdout.trim() || '"NO_OUTPUT"'), ); expect(exitCode).toBe(0); - }); + }, 30_000); });