diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index dce703adc40a..3bdf0af56f07 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -3674,6 +3674,17 @@ impl<'a> Parser<'a> { self.input.math_fn_parse_failures += 1; } + /// See `ParserInput::token_list_parse_failures`. + #[inline] + pub fn token_list_parse_failures(&self) -> u64 { + self.input.token_list_parse_failures + } + + #[inline] + pub fn note_token_list_parse_failure(&mut self) { + self.input.token_list_parse_failures += 1; + } + pub fn is_exhausted(&mut self) -> bool { self.expect_exhausted().is_ok() } @@ -4218,6 +4229,15 @@ pub struct ParserInput<'a> { /// suffix once per backtracking alternative per nesting level. unclosed_block_at_eof: Option, math_fn_parse_failures: u64, + /// Monotonic count of raw token-list parse failures + /// (`TokenList::parse_into`). A token-list parse is context-free: it + /// fails or succeeds the same way every time it runs over the same + /// tokens at the same block-nesting depth. Backtracking callers sample + /// this before an alternative that buffers token lists internally; if it + /// grew, re-parsing the same range through another token-list-based + /// alternative is guaranteed to fail again, so they propagate the error + /// instead of retrying (which is exponential in the nesting depth). + token_list_parse_failures: u64, } /// See `ParserInput::unclosed_block_at_eof`. @@ -4245,6 +4265,7 @@ impl<'a> ParserInput<'a> { nesting_depth: 0, unclosed_block_at_eof: None, math_fn_parse_failures: 0, + token_list_parse_failures: 0, } } } diff --git a/src/css/properties/custom.rs b/src/css/properties/custom.rs index 7d9d5255bfa8..4251ecf3b610 100644 --- a/src/css/properties/custom.rs +++ b/src/css/properties/custom.rs @@ -546,6 +546,20 @@ impl TokenList { tokens: &mut Vec, options: &ParserOptions, depth: usize, + ) -> Result<()> { + let result = Self::parse_into_impl(input, tokens, options, depth); + if result.is_err() { + // See `ParserInput::token_list_parse_failures`. + input.note_token_list_parse_failure(); + } + result + } + + fn parse_into_impl( + input: &mut Parser, + tokens: &mut Vec, + options: &ParserOptions, + depth: usize, ) -> Result<()> { if depth > 500 { return Err(input.new_custom_error(ParserError::maximum_nesting_depth)); @@ -577,13 +591,30 @@ impl TokenList { tokens.push(TokenOrValue::Color(color)); last_is_delim = false; last_is_whitespace = false; - } else if let Ok(color) = - input.try_parse(|i| UnresolvedColor::parse(i, f, options, depth)) - { - tokens.push(TokenOrValue::UnresolvedColor(color)); - last_is_delim = false; - last_is_whitespace = false; - } else if strings::eql(*f, b"url") { + continue; + } + let failures_before = input.token_list_parse_failures(); + match input.try_parse(|i| UnresolvedColor::parse(i, f, options, depth)) { + Ok(color) => { + tokens.push(TokenOrValue::UnresolvedColor(color)); + last_is_delim = false; + last_is_whitespace = false; + continue; + } + Err(err) => { + // The attempt failed inside one of its token-list + // arguments (an rgb()/hsl() alpha or a light-dark() + // half). Those tokens fail the same way under every + // alternative below, so propagate instead of + // falling through: re-parsing the arguments once + // per alternative is exponential in the nesting + // depth when such functions are nested. + if input.token_list_parse_failures() != failures_before { + return Err(err); + } + } + } + if strings::eql(*f, b"url") { input.reset(&state); tokens.push(TokenOrValue::Url(ext::url_parse(input)?)); last_is_delim = false; @@ -975,6 +1006,23 @@ impl UnresolvedColor { }) }), b"light-dark" => return input.parse_nested_block(|input2| { + // light-dark() requires a top-level comma between its halves. + // Check with a raw scan before parsing: buffering the first + // half as a token list only to fail on the missing comma makes + // the caller re-parse the arguments as a plain function, which + // compounds exponentially when light-dark() calls are nested. + let scan_start = input2.state(); + let mut found_comma = false; + while let Ok(tok) = input2.next() { + if matches!(tok, Token::Comma) { + found_comma = true; + break; + } + } + input2.reset(&scan_start); + if !found_comma { + return Err(input2.new_error(BasicParseErrorKind::end_of_input)); + } // `?` drops `light` automatically on the error path. let light = input2.parse_until_before(Delimiters::COMMA, |i| { TokenListFns::parse(i, options, depth + 1) diff --git a/test/js/bun/css/token-list-backtracking.test.ts b/test/js/bun/css/token-list-backtracking.test.ts new file mode 100644 index 000000000000..936a635cd19d --- /dev/null +++ b/test/js/bun/css/token-list-backtracking.test.ts @@ -0,0 +1,126 @@ +import { cssInternals } from "bun:internal-for-testing"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Regression tests for exponential backtracking when parsing nested function +// values in raw token lists (fuzzer-found OOM/DoS). +// +// `TokenList::parse_into` tries `UnresolvedColor::parse` for rgb()/hsl()/ +// light-dark() and falls back to parsing the arguments as a plain function +// when that attempt fails. The attempt buffers token-list arguments (the +// rgb()/hsl() alpha, the light-dark() halves), so when it failed *after* +// consuming them — a missing light-dark() comma, or a bad token inside the +// alpha — the fallback re-parsed the same range. With such functions nested, +// every level re-buffered the remaining input once per alternative: +// O(2^depth) time and allocation churn. The earlier unclosed-block-at-EOF +// short-circuit only covered truncated inputs, not balanced ones. +// +// Now a token-list parse failure inside the attempt propagates instead of +// falling through (those tokens fail identically under every alternative), +// and light-dark() checks for its top-level comma with a raw scan before +// buffering anything. + +const { minifyTest, prefixTest, _test } = cssInternals; + +function spawnMinify(css: string) { + return Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const c = require("bun:internal-for-testing").cssInternals; + const css = ${JSON.stringify(css)}; + const rssBefore = process.memoryUsage.rss(); + let threw = false; + try { c.minifyTest(css, ""); } catch { threw = true; } + const deltaMB = (process.memoryUsage.rss() - rssBefore) / 1024 / 1024; + if (deltaMB > 256) throw new Error("memory grew by " + deltaMB.toFixed(0) + "MB"); + console.log("done threw=" + threw);`, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + stdout: "pipe", + stderr: "pipe", + // Backstop: the unfixed parser blocks inside a single native call for + // hours at this depth, so kill the child rather than hanging the runner. + timeout: 60_000, + killSignal: "SIGKILL", + }); +} + +async function expectBounded(css: string, expectedThrew: boolean) { + await using proc = spawnMinify(css); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode, stderr: stderr.includes("error") ? stderr : "" }).toEqual({ + stdout: `done threw=${expectedThrew}\n`, + exitCode: 0, + stderr: "", + }); +} + +const filler = Buffer.alloc(4000, "0 ").toString(); + +test.concurrent("deeply nested light-dark() without a top-level comma parses in bounded time", async () => { + // The attempt consumed the whole argument range before failing on the + // missing comma, then the fallback re-parsed it: 2^depth. Depth 16 already + // took ~15s before the fix; depth 96 did not finish. + const depth = 96; + const css = ".a{--x:" + "light-dark(".repeat(depth) + filler + ")".repeat(depth) + "}"; + await expectBounded(css, false); +}); + +test.concurrent("deeply nested rgb() with a bad-string token in the alpha parses in bounded time", async () => { + // The alpha token list fails on the unterminated string; that range fails + // identically when re-parsed as a plain function, once per nesting level. + const depth = 96; + const css = ".a{--x:" + "rgb(1 1 1/ ".repeat(depth) + filler + "' \n" + ")".repeat(depth) + "}"; + await expectBounded(css, true); +}); + +test.concurrent("deeply nested rgb() with an invalid var() in the alpha parses in bounded time", async () => { + // Same shape, but the doomed token is an inner var() with an invalid name + // rather than a tokenizer-level error token. + const depth = 96; + const css = ".a{--x:" + "rgb(1 1 1/ ".repeat(depth) + filler + "var(0)" + ")".repeat(depth) + "}"; + await expectBounded(css, true); +}); + +test("original fuzzer input parses in bounded time and memory", () => { + // Minimized fuzzer testcase: thousands of unclosed `{` blocks, unterminated + // strings, and a trailing run of `}`. + const input = Buffer.from( + Bun.gunzipSync( + Buffer.from( + "H4sIAAAAAAACA+3QoQqAMBSF4e5T3Ga6MquvYlKZIFMWpoiMvYuPajBYrc7/Kz+ceGIEACAvBxcAAPBT7TvV6L3E4k4pYehm25y1pC9N8tDd9m5ademCa2RTNZX5oAQAAAAAQBYuJSDTGYIfAAA=", + "base64", + ), + ), + ).toString("latin1"); + expect(() => minifyTest(input, "")).toThrow("Unexpected end of input"); + expect(() => _test(input, "", { chrome: 80 << 16 })).toThrow("Unexpected end of input"); + expect(() => prefixTest(input, "", { chrome: 80 << 16 })).toThrow("Unexpected end of input"); +}); + +test("valid and recovered color function values are unchanged", () => { + const cases: [string, string][] = [ + [".a{--x: light-dark(red, blue)}", ".a{--x:light-dark(red,#00f)}"], + // No top-level comma: still recovered as a plain function. + [".a{--x: light-dark(red blue)}", ".a{--x:light-dark(red blue)}"], + [".a{--x: light-dark(rgb(1 2 3), #fff)}", ".a{--x:light-dark(#010203,#fff)}"], + [".a{--x: light-dark(light-dark(red, blue), green)}", ".a{--x:light-dark(red,green)}"], + [".a{--x: light-dark(var(--l), var(--d))}", ".a{--x:light-dark(var(--l),var(--d))}"], + [".a{--x: rgb(1 1 1/var(--a))}", ".a{--x:rgb(1 1 1/var(--a))}"], + [".a{--x: hsl(120deg 50% 50%/var(--o, 0.5))}", ".a{--x:hsl(120 50% 50%/var(--o,.5))}"], + [".a{--x: rgb(var(--r) 0 0/1)}", ".a{--x:rgb(var(--r)0 0/1)}"], + [".a{--x: f(light-dark(red))}", ".a{--x:f(light-dark(red))}"], + [".a{--x: var(--a, light-dark(x y))}", ".a{--x:var(--a,light-dark(x y))}"], + [".a{--x: env(safe-area-inset-top, light-dark(a, b))}", ".a{--x:env(safe-area-inset-top,light-dark(a,b))}"], + ]; + for (const [css, expected] of cases) { + expect(minifyTest(css, "")).toBe(expected); + } +}); + +test("bad tokens inside color function arguments still fail the declaration", () => { + expect(() => minifyTest(".a{--x: rgb(1 1 1/ ] )}", "")).toThrow("Unexpected token"); + expect(() => minifyTest(".a{--x: light-dark(a, ] )}", "")).toThrow("Unexpected token"); + expect(() => minifyTest(".a{--x: light-dark( ] , b)}", "")).toThrow("Unexpected token"); +});