From 05b8f034fdbc58392a9c613963bf3ac8c5b84cd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:56:34 +0000 Subject: [PATCH] fix: guard against stack overflow on deeply nested input print_list, print_selector_list, print_block, print_rule and print_atrule recurse with the input's nesting depth and had no guard, so pathological input (thousands of nested rules, or deeply nested calc()/parentheses) crashed with an opaque, unhandled "Maximum call stack size exceeded" RangeError. Add an explicit recursion-depth guard, well below the real stack limit, so the printer throws a clear format-css-branded RangeError instead. Also wrap the initial parse() call: for extremely deep input the underlying parser can itself exceed the call stack before our own guard ever runs, so normalize that failure into the same kind of clear error. --- src/lib/index.ts | 290 ++++++++++++++++++++++++++++------------------- test/api.test.ts | 27 +++++ 2 files changed, 200 insertions(+), 117 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 515d26f..ad0415e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -53,6 +53,21 @@ const OPEN_BRACE = '{' const CLOSE_BRACE = '}' const COMMA = ',' +// Printing is recursive (nested values, selectors, rules and atrules all +// call back into each other). Pathological input - e.g. thousands of +// nested rules or parentheses - can exceed the JS call stack, which +// otherwise surfaces as an opaque "Maximum call stack size exceeded" +// RangeError. Guard against that with an explicit, well below the real +// stack limit, depth so callers get a clear error instead. +const MAX_NESTING_DEPTH = 250 +let recursion_depth = 0 + +function enter_recursion() { + if (++recursion_depth > MAX_NESTING_DEPTH) { + throw new RangeError(`format-css: input exceeds maximum nesting depth of ${MAX_NESTING_DEPTH}`) + } +} + export type FormatOptions = { /** Whether to minify the CSS or keep it formatted */ minify?: boolean @@ -121,31 +136,41 @@ function print_operator(node: Operator, optional_space = SPACE): string { } function print_list(nodes: CSSNode[], optional_space = SPACE): string { - let parts = [] - for (let node of nodes) { - if (is_function(node)) { - let fn = node.name.toLowerCase() - parts.push(fn, OPEN_PARENTHESES, print_list(node.children, optional_space), CLOSE_PARENTHESES) - } else if (is_dimension(node)) { - parts.push(node.value, node.unit?.toLowerCase()) - } else if (is_string(node)) { - parts.push(print_string(node.text)) - } else if (is_operator(node)) { - parts.push(print_operator(node, optional_space)) - } else if (is_parenthesis(node)) { - parts.push(OPEN_PARENTHESES, print_list(node.children, optional_space), CLOSE_PARENTHESES) - } else if (is_url(node) && node.value) { - parts.push(print_url(node)) - } else { - parts.push(node.text) - } + enter_recursion() + try { + let parts = [] + for (let node of nodes) { + if (is_function(node)) { + let fn = node.name.toLowerCase() + parts.push( + fn, + OPEN_PARENTHESES, + print_list(node.children, optional_space), + CLOSE_PARENTHESES, + ) + } else if (is_dimension(node)) { + parts.push(node.value, node.unit?.toLowerCase()) + } else if (is_string(node)) { + parts.push(print_string(node.text)) + } else if (is_operator(node)) { + parts.push(print_operator(node, optional_space)) + } else if (is_parenthesis(node)) { + parts.push(OPEN_PARENTHESES, print_list(node.children, optional_space), CLOSE_PARENTHESES) + } else if (is_url(node) && node.value) { + parts.push(print_url(node)) + } else { + parts.push(node.text) + } - if (!is_operator(node) && node.has_next && !is_operator(node.next_sibling)) { - parts.push(SPACE) + if (!is_operator(node) && node.has_next && !is_operator(node.next_sibling)) { + parts.push(SPACE) + } } - } - return parts.join(EMPTY_STRING) + return parts.join(EMPTY_STRING) + } finally { + recursion_depth-- + } } export function format_value( @@ -351,18 +376,23 @@ function print_selector_list( node: SelectorList | PseudoClassSelector | PseudoElementSelector, optional_space = SPACE, ): string { - let parts = [] - for (let child of node) { - if (is_selector_list(child)) { - parts.push(print_selector_list(child, optional_space)) - } else { - parts.push(print_selector_argument(child, optional_space)) - if (child.has_next) { - parts.push(COMMA, optional_space) + enter_recursion() + try { + let parts = [] + for (let child of node) { + if (is_selector_list(child)) { + parts.push(print_selector_list(child, optional_space)) + } else { + parts.push(print_selector_argument(child, optional_space)) + if (child.has_next) { + parts.push(COMMA, optional_space) + } } } + return parts.join(EMPTY_STRING) + } finally { + recursion_depth-- } - return parts.join(EMPTY_STRING) } export function format_selector( @@ -427,14 +457,25 @@ export function format( // First pass: collect all comments let comments: number[] = [] - let ast = parse(css, { - parse_atrule_preludes: false, - on_comment: minify - ? undefined - : ({ start, end }) => { - comments.push(start, end) - }, - }) + let ast: StyleSheet + try { + ast = parse(css, { + parse_atrule_preludes: false, + on_comment: minify + ? undefined + : ({ start, end }) => { + comments.push(start, end) + }, + }) + } catch (error) { + // Deeply nested input can exceed the JS call stack inside the parser + // itself, before our own recursion guard ever runs. Surface a clear, + // documented error instead of an opaque native RangeError. + if (error instanceof RangeError) { + throw new RangeError('format-css: input is too deeply nested to parse') + } + throw error + } let depth = 0 @@ -498,113 +539,128 @@ export function format( } function print_block(node: Block): string { - let lines = [] - depth++ - - if (!node.has_children) { - let comment = get_comment(node.start, node.end) - if (comment) { - lines.push(indent(depth) + comment) - depth-- - lines.push(indent(depth) + CLOSE_BRACE) - return lines.join(NEWLINE) - } - } - - let first_child = node.first_child - let comment_before_first = get_comment(node.start, first_child?.start) - if (comment_before_first) { - lines.push(indent(depth) + comment_before_first) - } - - let prev_end: number | undefined + enter_recursion() + try { + let lines = [] + depth++ - for (let child of node) { - if (prev_end !== undefined) { - let comment = get_comment(prev_end, child.start) + if (!node.has_children) { + let comment = get_comment(node.start, node.end) if (comment) { lines.push(indent(depth) + comment) + depth-- + lines.push(indent(depth) + CLOSE_BRACE) + return lines.join(NEWLINE) } } - if (is_declaration(child)) { - let is_last = !child.has_next || !is_declaration(child.next_sibling) - let declaration = format_declaration(child, { minify }) - let semi = is_last ? LAST_SEMICOLON : SEMICOLON - lines.push(indent(depth) + declaration + semi) - } else if (is_rule(child)) { - if (prev_end !== undefined && lines.length > 0) { - lines.push(EMPTY_STRING) + let first_child = node.first_child + let comment_before_first = get_comment(node.start, first_child?.start) + if (comment_before_first) { + lines.push(indent(depth) + comment_before_first) + } + + let prev_end: number | undefined + + for (let child of node) { + if (prev_end !== undefined) { + let comment = get_comment(prev_end, child.start) + if (comment) { + lines.push(indent(depth) + comment) + } } - lines.push(print_rule(child)) - } else if (is_atrule(child)) { - if (prev_end !== undefined && lines.length > 0) { - lines.push(EMPTY_STRING) + + if (is_declaration(child)) { + let is_last = !child.has_next || !is_declaration(child.next_sibling) + let declaration = format_declaration(child, { minify }) + let semi = is_last ? LAST_SEMICOLON : SEMICOLON + lines.push(indent(depth) + declaration + semi) + } else if (is_rule(child)) { + if (prev_end !== undefined && lines.length > 0) { + lines.push(EMPTY_STRING) + } + lines.push(print_rule(child)) + } else if (is_atrule(child)) { + if (prev_end !== undefined && lines.length > 0) { + lines.push(EMPTY_STRING) + } + lines.push(indent(depth) + print_atrule(child)) } - lines.push(indent(depth) + print_atrule(child)) + + prev_end = child.end } - prev_end = child.end - } + let comment_after_last = get_comment(prev_end, node.end) + if (comment_after_last) { + lines.push(indent(depth) + comment_after_last) + } - let comment_after_last = get_comment(prev_end, node.end) - if (comment_after_last) { - lines.push(indent(depth) + comment_after_last) + depth-- + lines.push(indent(depth) + CLOSE_BRACE) + return lines.join(NEWLINE) + } finally { + recursion_depth-- } - - depth-- - lines.push(indent(depth) + CLOSE_BRACE) - return lines.join(NEWLINE) } function print_rule(node: Rule): string { - let block_has_content = - node.block && (node.block.has_children || get_comment(node.block.start, node.block.end)) - let lines = [] + enter_recursion() + try { + let block_has_content = + node.block && (node.block.has_children || get_comment(node.block.start, node.block.end)) + let lines = [] + + if (node.has_prelude && is_selector_list(node.prelude)) { + let list = print_rule_selectors(node.prelude) - if (node.has_prelude && is_selector_list(node.prelude)) { - let list = print_rule_selectors(node.prelude) + let comment = get_comment(node.first_child?.end, node.block?.start) + if (comment) { + list += NEWLINE + indent(depth) + comment + } - let comment = get_comment(node.first_child?.end, node.block?.start) - if (comment) { - list += NEWLINE + indent(depth) + comment + list += OPTIONAL_SPACE + OPEN_BRACE + if (!block_has_content) { + list += CLOSE_BRACE + } + lines.push(list) } - list += OPTIONAL_SPACE + OPEN_BRACE - if (!block_has_content) { - list += CLOSE_BRACE + if (block_has_content) { + lines.push(print_block(node.block!)) } - lines.push(list) - } - if (block_has_content) { - lines.push(print_block(node.block!)) + return lines.join(NEWLINE) + } finally { + recursion_depth-- } - - return lines.join(NEWLINE) } function print_atrule(node: Atrule): string { - let name = '@' + node.name!.toLowerCase() - if (node.prelude) { - name += SPACE + format_atrule_prelude(node.prelude.text, { minify }) - } + enter_recursion() + try { + let name = '@' + node.name!.toLowerCase() + if (node.prelude) { + name += SPACE + format_atrule_prelude(node.prelude.text, { minify }) + } - let block_has_content = - node.has_block && (!node.block.is_empty || !!get_comment(node.block.start, node.block.end)) - if (node.has_block) { - name += OPTIONAL_SPACE + OPEN_BRACE - if (!block_has_content) { - name += CLOSE_BRACE + let block_has_content = + node.has_block && (!node.block.is_empty || !!get_comment(node.block.start, node.block.end)) + if (node.has_block) { + name += OPTIONAL_SPACE + OPEN_BRACE + if (!block_has_content) { + name += CLOSE_BRACE + } + } else { + name += SEMICOLON } - } else { - name += SEMICOLON - } - if (block_has_content) { - return name + NEWLINE + print_block(node.block!) + if (block_has_content) { + return name + NEWLINE + print_block(node.block!) + } + return name + } finally { + recursion_depth-- } - return name } function print_stylesheet(node: StyleSheet): string { diff --git a/test/api.test.ts b/test/api.test.ts index 6f4e2fd..97e7d8a 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -28,6 +28,33 @@ test('handles invalid input', () => { expect(actual).toEqual(expected) }) +describe('deeply nested input', () => { + test('throws a clear error instead of an unhandled stack overflow (nested rules)', () => { + let n = 20_000 + let css = 'a{'.repeat(n) + '}'.repeat(n) + expect(() => format(css)).toThrow(/format-css/) + }) + + test('throws a clear error instead of an unhandled stack overflow (nested parens)', () => { + let n = 50_000 + let css = `a{width:calc(${'('.repeat(n)}1${')'.repeat(n)})}` + expect(() => format(css)).toThrow(/format-css/) + }) + + test('formatting still works normally after a deeply nested input was rejected', () => { + let n = 20_000 + let css = 'a{'.repeat(n) + '}'.repeat(n) + expect(() => format(css)).toThrow(/format-css/) + expect(format('a { color: red; }')).toEqual('a {\n\tcolor: red;\n}') + }) + + test('moderately nested input still formats correctly', () => { + let n = 50 + let css = 'a{'.repeat(n) + 'color:red;' + '}'.repeat(n) + expect(() => format(css), 'should not throw for realistic nesting depths').not.toThrow() + }) +}) + test('Vadim Makeevs example works', () => { let actual = format(` @layer what {