diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 9aec1fb989a9..25bb69fc4dfa 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -2329,6 +2329,20 @@ impl StyleSheet { } } +/// Longest input the parser accepts: positions are `i32`, and `len + 1` must fit. +pub const MAX_INPUT_LEN: usize = i32::MAX as usize - 1; + +/// 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, + loc: None, + }); + } + Ok(()) +} + // ── StyleSheet behavior (parse/minify/to_css) ──────────────────────────────── mod stylesheet_impl { use super::*; @@ -2563,6 +2577,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 +2678,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(), @@ -3079,6 +3095,8 @@ 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) }; + // 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), kind, @@ -3086,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/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/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 new file mode 100644 index 000000000000..ca77bc1084c3 --- /dev/null +++ b/test/js/bun/css/input-too-large.test.ts @@ -0,0 +1,200 @@ +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 input longer than MAX_INPUT_LEN up front with an ordinary error, +// before reading it. +// +// 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 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)"; +const CHILD_TIMEOUT = 30_000; + +// 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, + }); + 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 tests above already run 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).toBe(`error: ${MESSAGE} parsing\n`); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }, + 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. 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({ + 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, + ); + + // `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, + ); +});