Skip to content
Merged
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: 21 additions & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -4218,6 +4229,15 @@ pub struct ParserInput<'a> {
/// suffix once per backtracking alternative per nesting level.
unclosed_block_at_eof: Option<UnclosedBlockAtEof>,
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`.
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
62 changes: 55 additions & 7 deletions src/css/properties/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,20 @@ impl TokenList {
tokens: &mut Vec<TokenOrValue>,
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<TokenOrValue>,
options: &ParserOptions,
depth: usize,
) -> Result<()> {
if depth > 500 {
return Err(input.new_custom_error(ParserError::maximum_nesting_depth));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
126 changes: 126 additions & 0 deletions test/js/bun/css/token-list-backtracking.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Comment thread
robobun marked this conversation as resolved.
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");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading