From 446c2f70540263a3ed8ee62b0c2add614da93011 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:43:20 +0000 Subject: [PATCH 1/7] css: reject stylesheets of 2 GiB or more instead of aborting on an int cast bun_css stores byte offsets into the input as i32 (bun_ast::Loc in import records, CSS-module symbols and composes) and the line/column numbers derived from them as i32 in diagnostics, each with an expect("int cast"), so a stylesheet of 2 GiB or more aborted the process with "panic: int cast: TryFromIntError(PosOverflow)" once the tokenizer passed byte 2^31. Bound the input length once at the two parse entry points and report it as a parse error; every one of those values is at most len + 1, so the bound keeps all of them in range. An error about the stylesheet as a whole has no position, so add_to_logger now attaches a file-only location for it instead of none. --- src/css/css_parser.rs | 23 +++++++ src/css/error.rs | 6 +- test/js/bun/css/input-too-large.test.ts | 91 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/css/input-too-large.test.ts diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 9aec1fb989a9..139154545cd4 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2329,6 +2329,27 @@ impl StyleSheet { } } +/// Longest input `parse_with` / `StyleAttribute::parse` accept. +/// +/// Byte offsets into the input are stored as `i32` (`bun_ast::Loc` in import +/// records, CSS-module symbols, `composes` and `PropertyUsage`), and the line +/// and column numbers derived from them are stored as `i32` too +/// (`bun_ast::Location` in errors and warnings). The largest such value is +/// `len + 1`: the column of, or the 1-based line count at, the end of input. +/// Columns count UTF-16 units, never more than one per byte. Bounding the +/// length once here is what makes every one of those conversions infallible. +pub(crate) const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; + +fn check_input_len(code: &[u8]) -> Maybe<(), Err> { + if code.len() > MAX_INPUT_LEN { + return Err(Err { + kind: ParserError::input_too_large, + loc: None, + }); + } + Ok(()) +} + // ── StyleSheet behavior (parse/minify/to_css) ──────────────────────────────── mod stylesheet_impl { use super::*; @@ -2563,6 +2584,7 @@ mod stylesheet_impl { // returned `StyleSheet`. // TODO(refactor): re-thread the lifetime through `CssRuleList<'bump, R>` // and drop the `'static` bound on `arena`. + check_input_len(code)?; let mut composes = ComposesMap::default(); let mut parser_extra = ParserExtra { local_scope: LocalScope::default(), @@ -2663,6 +2685,7 @@ mod stylesheet_impl { // TODO: 'bump lifetime threading — `DeclarationBlock<'static>` in // `StyleAttribute` vs `Parser<'a>` here; `arena: &'static Bump` // matches the crate-wide erasure (see `parse_with`). + check_input_len(code)?; let mut parser_extra = ParserExtra { local_scope: LocalScope::default(), symbols: SymbolList::default(), diff --git a/src/css/error.rs b/src/css/error.rs index 70d16fa48773..f676cafa0dbe 100644 --- a/src/css/error.rs +++ b/src/css/error.rs @@ -74,7 +74,8 @@ impl Err { data: bun_ast::Data { location: match &self.loc { Some(loc) => Some(loc.to_location(source)?), - None => None, + // Errors about the stylesheet as a whole still name the file. + None => bun_ast::Location::init_or_null(Some(source), bun_ast::Range::NONE), }, text: text.into(), }, @@ -296,6 +297,8 @@ pub enum ParserError { expected: Str, received: Str, }, + /// The input is longer than [`crate::css_parser::MAX_INPUT_LEN`]. + input_too_large, } impl fmt::Display for ParserError { @@ -326,6 +329,7 @@ impl fmt::Display for ParserError { Self::unexpected_value { expected, received } => { write!(f, "Expected {}, received {}", bs(*expected), bs(*received)) } + Self::input_too_large => f.write_str("CSS file is too large to parse (2 GiB maximum)"), } } } diff --git a/test/js/bun/css/input-too-large.test.ts b/test/js/bun/css/input-too-large.test.ts new file mode 100644 index 000000000000..71e587be336d --- /dev/null +++ b/test/js/bun/css/input-too-large.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { closeSync, openSync, statSync, writeSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; + +// Every byte offset the CSS parser hands to the rest of bun (import records, +// CSS module symbols, `composes`) and every line/column in its diagnostics is +// an i32, so once the tokenizer got past byte 2**31 of a stylesheet the process +// aborted with `panic: int cast: TryFromIntError(PosOverflow)`. The parser now +// refuses such input up front with an ordinary build error, before reading it. +// +// Each stylesheet here is one comment covering its first 2 GiB followed by a +// `composes` declaration, a cast site that both the bundler and +// `bun build --no-bundle` reach (a url() only becomes an import record when +// bundling). The comment body is all zero bytes, so it costs nothing to +// produce: untouched pages of a Uint8Array in one case, a hole in a sparse +// file in the other. Handing it to bun still costs the child 2 GiB of memory, +// hence the memory gate (the same one fs-oom.test.ts uses for its 2 GiB reads) +// and the generous timeouts. The tests are deliberately not concurrent, so at +// most one 2 GiB child exists at a time. +const TAIL = "*/.a{composes:b}\n"; +const MESSAGE = "CSS file is too large to parse (2 GiB maximum)"; + +describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => { + test("Bun.build reports an error naming the file", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const tail = new TextEncoder().encode(${JSON.stringify(TAIL)}); + const bytes = new Uint8Array(2 ** 31 + tail.length); + bytes.set([0x2f, 0x2a]); // "/*" + bytes.set(tail, 2 ** 31); + const result = await Bun.build({ + entrypoints: ["/app/big.css"], + files: { "/app/big.css": bytes }, + throw: false, + }); + console.log(JSON.stringify({ + success: result.success, + logs: result.logs.map(log => ({ level: log.level, message: log.message, file: log.position?.file })), + })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + success: false, + logs: [{ level: "error", message: MESSAGE, file: "/app/big.css" }], + }); + expect(exitCode).toBe(0); + }, 30_000); + + // Windows only makes a file sparse on request, so seeking past 2 GiB there + // would really write 2 GiB of zeros. The bound is the same code on every + // platform and the test above already runs there. + test.skipIf(isWindows)( + "bun build --no-bundle reports an error", + async () => { + using dir = tempDir("css-too-large", {}); + const css = join(String(dir), "big.css"); + const tail = Buffer.from(TAIL); + const fd = openSync(css, "w"); + try { + writeSync(fd, Buffer.from("/*"), 0, 2, 0); + writeSync(fd, tail, 0, tail.length, 2 ** 31); + } finally { + closeSync(fd); + } + expect(statSync(css).size).toBe(2 ** 31 + tail.length); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--no-bundle", css], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(`error: ${MESSAGE}`); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }, + 30_000, + ); +}); From 9d638b72c40ecc539c9d5f77ebbf903d8220073e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:01:53 +0000 Subject: [PATCH 2/7] css: shorten the MAX_INPUT_LEN doc comment --- src/css/css_parser.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 139154545cd4..29e6ecb9b736 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2329,15 +2329,9 @@ impl StyleSheet { } } -/// Longest input `parse_with` / `StyleAttribute::parse` accept. -/// -/// Byte offsets into the input are stored as `i32` (`bun_ast::Loc` in import -/// records, CSS-module symbols, `composes` and `PropertyUsage`), and the line -/// and column numbers derived from them are stored as `i32` too -/// (`bun_ast::Location` in errors and warnings). The largest such value is -/// `len + 1`: the column of, or the 1-based line count at, the end of input. -/// Columns count UTF-16 units, never more than one per byte. Bounding the -/// length once here is what makes every one of those conversions infallible. +/// Longest input `parse_with` / `StyleAttribute::parse` accept. Offsets, lines +/// and columns are `i32` (`bun_ast::Loc`, `bun_ast::Location`); the largest +/// one is `len + 1`, at the end of input. pub(crate) const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; fn check_input_len(code: &[u8]) -> Maybe<(), Err> { From 98e9dc3a072725995a827e622b2bcd8e8781a7c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:14:16 +0000 Subject: [PATCH 3/7] css: one-line doc comment for MAX_INPUT_LEN --- src/css/css_parser.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 29e6ecb9b736..077401c48547 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2329,9 +2329,7 @@ impl StyleSheet { } } -/// Longest input `parse_with` / `StyleAttribute::parse` accept. Offsets, lines -/// and columns are `i32` (`bun_ast::Loc`, `bun_ast::Location`); the largest -/// one is `len + 1`, at the end of input. +/// Longest input the parser accepts: positions are `i32`, and `len + 1` must fit. pub(crate) const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; fn check_input_len(code: &[u8]) -> Maybe<(), Err> { From 07467b0b7e8f886eadb820601ff728bfb5790627 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:02:26 +0000 Subject: [PATCH 4/7] css: store the source span of a url() import record, test the bound at both entry points --- src/css/css_parser.rs | 6 +- test/js/bun/css/input-too-large.test.ts | 167 +++++++++++++++++------- 2 files changed, 125 insertions(+), 48 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 077401c48547..0ec78e83e2cb 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -3094,6 +3094,9 @@ impl<'a> Parser<'a> { // every `ImportRecord` produced by this parse; the lifetime // is erased to 'static (see PORTING.md §Lifetimes). let url_static: &'static [u8] = unsafe { src_str(url) }; + // The source span of the token, as for a JS import record. The + // unescaped `url` can be longer than its source (NUL -> U+FFFD). + let end_position = self.position(); import_records.push(ImportRecord { path: ast::fs::path_init(url_static), kind, @@ -3101,8 +3104,7 @@ impl<'a> Parser<'a> { loc: bun_ast::Loc { start: i32::try_from(start_position).expect("int cast"), }, - // TODO: technically this is not correct because the url could be escaped - len: i32::try_from(url.len()).expect("int cast"), + len: i32::try_from(end_position - start_position).expect("int cast"), }, tag: Default::default(), loader: None, diff --git a/test/js/bun/css/input-too-large.test.ts b/test/js/bun/css/input-too-large.test.ts index 71e587be336d..5f6e8cfb8829 100644 --- a/test/js/bun/css/input-too-large.test.ts +++ b/test/js/bun/css/input-too-large.test.ts @@ -8,58 +8,96 @@ import { join } from "node:path"; // CSS module symbols, `composes`) and every line/column in its diagnostics is // an i32, so once the tokenizer got past byte 2**31 of a stylesheet the process // aborted with `panic: int cast: TryFromIntError(PosOverflow)`. The parser now -// refuses such input up front with an ordinary build error, before reading it. +// refuses input longer than MAX_INPUT_LEN up front with an ordinary error, +// before reading it. // -// Each stylesheet here is one comment covering its first 2 GiB followed by a -// `composes` declaration, a cast site that both the bundler and -// `bun build --no-bundle` reach (a url() only becomes an import record when -// bundling). The comment body is all zero bytes, so it costs nothing to -// produce: untouched pages of a Uint8Array in one case, a hole in a sparse -// file in the other. Handing it to bun still costs the child 2 GiB of memory, -// hence the memory gate (the same one fs-oom.test.ts uses for its 2 GiB reads) -// and the generous timeouts. The tests are deliberately not concurrent, so at -// most one 2 GiB child exists at a time. +// Each stylesheet here is one comment covering almost all of it followed by a +// `composes` declaration, a cast site that every entry point reaches (a url() +// only becomes an import record when bundling). The comment body is all zero +// bytes or spaces, so it is cheap to produce: untouched pages of a Uint8Array, +// a hole in a sparse file, or one Buffer.alloc. Handing it to bun still costs +// the child 2 to 4.5 GiB of memory, hence the memory gate (the same one +// fs-oom.test.ts uses for its 2 GiB reads) and the timeout: a child takes 3 to +// 8 s in a debug build. The tests are deliberately not concurrent, so at most +// one such child exists at a time. +const MAX_INPUT_LEN = 2 ** 31 - 2; const TAIL = "*/.a{composes:b}\n"; const MESSAGE = "CSS file is too large to parse (2 GiB maximum)"; +const CHILD_TIMEOUT = 30_000; -describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => { - test("Bun.build reports an error naming the file", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const tail = new TextEncoder().encode(${JSON.stringify(TAIL)}); - const bytes = new Uint8Array(2 ** 31 + tail.length); - bytes.set([0x2f, 0x2a]); // "/*" - bytes.set(tail, 2 ** 31); - const result = await Bun.build({ - entrypoints: ["/app/big.css"], - files: { "/app/big.css": bytes }, - throw: false, - }); - console.log(JSON.stringify({ - success: result.success, - logs: result.logs.map(log => ({ level: log.level, message: log.message, file: log.position?.file })), - })); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ - success: false, - logs: [{ level: "error", message: MESSAGE, file: "/app/big.css" }], +// Inside a container os.totalmem() reports the host's RAM; +// process.constrainedMemory() reports the cgroup limit there. +const memory = Math.min(os.totalmem(), process.constrainedMemory() || Infinity); + +// Builds an in-memory stylesheet of `length` bytes with `Bun.build` and prints +// the outcome. The comment closes `TAIL.length` bytes before the end. +function bunBuildScript(length: number): string { + return ` + const tail = new TextEncoder().encode(${JSON.stringify(TAIL)}); + const bytes = new Uint8Array(${length}); + bytes.set([0x2f, 0x2a]); // "/*" + bytes.set(tail, bytes.length - tail.length); + const result = await Bun.build({ + entrypoints: ["/app/big.css"], + files: { "/app/big.css": bytes }, + throw: false, }); - expect(exitCode).toBe(0); - }, 30_000); + console.log(JSON.stringify({ + success: result.success, + logs: result.logs.map(log => ({ level: log.level, message: log.message, file: log.position?.file })), + })); + `; +} + +describe.skipIf(memory < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => { + // The `composes` sits past byte 2**31, where its offset no longer fits an + // i32. This is the input that aborted before. + test( + "Bun.build reports an error naming the file", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", bunBuildScript(2 ** 31 + TAIL.length)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + success: false, + logs: [{ level: "error", message: MESSAGE, file: "/app/big.css" }], + }); + expect(exitCode).toBe(0); + }, + CHILD_TIMEOUT, + ); + + // One byte past the limit is rejected too, even though every offset in it + // still fits an i32. (At the limit the stylesheet parses, but a 2 GiB + // tokenize takes minutes in a debug build, so that side is not tested.) + test( + "Bun.build rejects MAX_INPUT_LEN + 1 bytes", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", bunBuildScript(MAX_INPUT_LEN + 1)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + success: false, + logs: [{ level: "error", message: MESSAGE, file: "/app/big.css" }], + }); + expect(exitCode).toBe(0); + }, + CHILD_TIMEOUT, + ); // Windows only makes a file sparse on request, so seeking past 2 GiB there // would really write 2 GiB of zeros. The bound is the same code on every - // platform and the test above already runs there. + // platform and the tests above already run there. test.skipIf(isWindows)( "bun build --no-bundle reports an error", async () => { @@ -82,10 +120,47 @@ describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", ( stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain(`error: ${MESSAGE}`); + expect(stderr).toBe(`error: ${MESSAGE} parsing\n`); expect(stdout).toBe(""); expect(exitCode).toBe(1); }, - 30_000, + CHILD_TIMEOUT, + ); + + // A style attribute goes through `StyleAttribute::parse`, the other entry + // point with the bound. A JS string holds at most 2**31 - 1 code units, so + // the tail is Latin-1 text that grows when encoded as UTF-8: that puts the + // `composes` past byte 2**31 and its offset out of i32 range. + test( + "StyleAttribute::parse reports an error", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { cssInternals } = require("bun:internal-for-testing"); + const length = 2 ** 31 - 1; + const buf = Buffer.alloc(length, 0x20); + const tail = Buffer.from("/*" + "\\u00e9".repeat(40) + "*/composes:b", "latin1"); + tail.copy(buf, length - tail.length); + try { + cssInternals.attrTest(buf.toString("latin1"), "", false); + console.log("parsed"); + } catch (error) { + console.log(error.message); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe(`parsing failed: ${MESSAGE}\n`); + expect(exitCode).toBe(0); + }, + CHILD_TIMEOUT, ); }); From cb12478296447fb699b4543bf6645b9ab5a5d214 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:13:19 +0000 Subject: [PATCH 5/7] css: one-line comment on the import record span --- src/css/css_parser.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 0ec78e83e2cb..6f482ccc584d 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -3094,8 +3094,7 @@ impl<'a> Parser<'a> { // every `ImportRecord` produced by this parse; the lifetime // is erased to 'static (see PORTING.md §Lifetimes). let url_static: &'static [u8] = unsafe { src_str(url) }; - // The source span of the token, as for a JS import record. The - // unescaped `url` can be longer than its source (NUL -> U+FFFD). + // Source span, not `url.len()`: the unescaped url can be longer than its source. let end_position = self.position(); import_records.push(ImportRecord { path: ast::fs::path_init(url_static), From e0c560a196127a998613dacecb716529d5ce592a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:58:36 +0000 Subject: [PATCH 6/7] css test: gate the attribute case on 16 GiB, its transcode peaks at 8.3 GiB --- test/js/bun/css/input-too-large.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/js/bun/css/input-too-large.test.ts b/test/js/bun/css/input-too-large.test.ts index 5f6e8cfb8829..284dc23ee7ec 100644 --- a/test/js/bun/css/input-too-large.test.ts +++ b/test/js/bun/css/input-too-large.test.ts @@ -16,10 +16,11 @@ import { join } from "node:path"; // only becomes an import record when bundling). The comment body is all zero // bytes or spaces, so it is cheap to produce: untouched pages of a Uint8Array, // a hole in a sparse file, or one Buffer.alloc. Handing it to bun still costs -// the child 2 to 4.5 GiB of memory, hence the memory gate (the same one -// fs-oom.test.ts uses for its 2 GiB reads) and the timeout: a child takes 3 to -// 8 s in a debug build. The tests are deliberately not concurrent, so at most -// one such child exists at a time. +// the child 2 GiB of memory (8.3 GiB peak for the attribute case, which +// transcodes), hence the memory gates (10 GiB is the one fs-oom.test.ts uses +// for its 2 GiB reads) and the timeout: a child takes 3 to 11 s in a debug +// build. The tests are deliberately not concurrent, so at most one such child +// exists at a time. const MAX_INPUT_LEN = 2 ** 31 - 2; const TAIL = "*/.a{composes:b}\n"; const MESSAGE = "CSS file is too large to parse (2 GiB maximum)"; @@ -130,8 +131,10 @@ describe.skipIf(memory < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => { // A style attribute goes through `StyleAttribute::parse`, the other entry // point with the bound. A JS string holds at most 2**31 - 1 code units, so // the tail is Latin-1 text that grows when encoded as UTF-8: that puts the - // `composes` past byte 2**31 and its offset out of i32 range. - test( + // `composes` past byte 2**31 and its offset out of i32 range. The transcode + // holds the 2 GiB Buffer, the string and two UTF-8 buffers at once: 8.3 GiB + // peak (VmHWM) in a debug build. + test.skipIf(memory < 16 * 1024 ** 3)( "StyleAttribute::parse reports an error", async () => { await using proc = Bun.spawn({ From 6a971b3a9607027b8e6ebdd268d0c244af006d11 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:42:23 +0000 Subject: [PATCH 7/7] css: bound the Bun.color parser input too --- src/css/css_parser.rs | 5 ++-- src/css_jsc/color_js.rs | 5 ++++ test/js/bun/css/input-too-large.test.ts | 31 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 6f482ccc584d..25bb69fc4dfa 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2330,9 +2330,10 @@ impl StyleSheet { } /// Longest input the parser accepts: positions are `i32`, and `len + 1` must fit. -pub(crate) const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; +pub const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; -fn check_input_len(code: &[u8]) -> Maybe<(), Err> { +/// Every entry point that builds a [`Parser`] over user bytes calls this first. +pub fn check_input_len(code: &[u8]) -> Maybe<(), Err> { if code.len() > MAX_INPUT_LEN { return Err(Err { kind: ParserError::input_too_large, diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 86ccdb49e781..611061f7edac 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -323,6 +323,11 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult } input = args[0].to_utf8(global)?; + if css::css_parser::check_input_len(input.slice()).is_err() { + return Err(global.throw(format_args!( + "color() input is too large to parse (2 GiB maximum)" + ))); + } // MimallocArena::new() calls mi_heap_new(), so defer creation to the // paths that actually allocate. diff --git a/test/js/bun/css/input-too-large.test.ts b/test/js/bun/css/input-too-large.test.ts index 284dc23ee7ec..ca77bc1084c3 100644 --- a/test/js/bun/css/input-too-large.test.ts +++ b/test/js/bun/css/input-too-large.test.ts @@ -166,4 +166,35 @@ describe.skipIf(memory < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => { }, CHILD_TIMEOUT, ); + + // `Bun.color` builds its own parser over the UTF-8 of the string. The longest + // JS string, 2**31 - 1 ASCII chars, is MAX_INPUT_LEN + 1 bytes. Before the + // bound this input parsed as an invalid color and returned null. + test( + "Bun.color throws", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const input = Buffer.alloc(2 ** 31 - 1, 0x20).toString("latin1"); + try { + console.log(JSON.stringify(Bun.color(input, "css"))); + } catch (error) { + console.log(error.message); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("color() input is too large to parse (2 GiB maximum)\n"); + expect(exitCode).toBe(0); + }, + CHILD_TIMEOUT, + ); });