Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2329,6 +2329,20 @@ impl<AtRule> StyleSheet<AtRule> {
}
}

/// 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<ParserError>> {
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::*;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Comment thread
claude[bot] marked this conversation as resolved.
local_scope: LocalScope::default(),
symbols: SymbolList::default(),
Expand Down Expand Up @@ -3079,15 +3095,16 @@ 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,
range: bun_ast::Range {
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,
Expand Down
6 changes: 5 additions & 1 deletion src/css/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ impl<T: fmt::Display> Err<T> {
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(),
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)"),
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/css_jsc/color_js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
200 changes: 200 additions & 0 deletions test/js/bun/css/input-too-large.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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("");
Comment thread
claude[bot] marked this conversation as resolved.
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);
Comment thread
robobun marked this conversation as resolved.
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,
);
});
Loading