From a795ee83ecd011131b2d618fe754ac5a8e124dae Mon Sep 17 00:00:00 2001 From: Wilter Quesada Date: Sat, 10 Oct 2026 21:31:34 -0400 Subject: [PATCH] Bound parser and supplied AST depth before recursive operations --- lib/assert-depth.js | 14 ++++++++++++++ lib/compile.js | 1 + lib/expand.js | 1 + lib/parse.js | 6 ++++++ lib/stringify.js | 1 + test/security-depth.js | 28 ++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+) create mode 100644 lib/assert-depth.js create mode 100644 test/security-depth.js diff --git a/lib/assert-depth.js b/lib/assert-depth.js new file mode 100644 index 0000000..a68d165 --- /dev/null +++ b/lib/assert-depth.js @@ -0,0 +1,14 @@ +'use strict'; +module.exports = ast => { + const pending = [[ast, 0, false]]; + const seen = new WeakSet(); + while (pending.length) { + const [node, depth, exit] = pending.pop(); + if (exit) { seen.delete(node); continue; } + if (depth > 128) throw new SyntaxError('Brace AST exceeds safe depth of 128'); + if (seen.has(node)) throw new SyntaxError('Brace AST must be an acyclic tree'); + seen.add(node); + pending.push([node, depth, true]); + if (node.nodes) for (const child of node.nodes) pending.push([child, depth + 1, false]); + } +}; diff --git a/lib/compile.js b/lib/compile.js index dce69be..c84ab0d 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -4,6 +4,7 @@ const fill = require('fill-range'); const utils = require('./utils'); const compile = (ast, options = {}) => { + require('./assert-depth')(ast); const walk = (node, parent = {}) => { const invalidBlock = utils.isInvalidBrace(parent); const invalidNode = node.invalid === true && options.escapeInvalid === true; diff --git a/lib/expand.js b/lib/expand.js index 35b2c41..919d669 100644 --- a/lib/expand.js +++ b/lib/expand.js @@ -31,6 +31,7 @@ const append = (queue = '', stash = '', enclose = false) => { }; const expand = (ast, options = {}) => { + require('./assert-depth')(ast); const rangeLimit = options.rangeLimit === undefined ? 1000 : options.rangeLimit; const walk = (node, parent = {}) => { diff --git a/lib/parse.js b/lib/parse.js index 4e57de6..fc869f9 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -143,6 +143,9 @@ const parse = (input, options = {}) => { */ if (value === CHAR_LEFT_PARENTHESES) { + if (stack.length > 128) { + throw new SyntaxError('Brace AST exceeds safe depth of 128'); + } block = push({ type: 'paren', nodes: [] }); stack.push(block); push({ type: 'text', value }); @@ -203,6 +206,9 @@ const parse = (input, options = {}) => { */ if (value === CHAR_LEFT_CURLY_BRACE) { + if (stack.length > 128) { + throw new SyntaxError('Brace AST exceeds safe depth of 128'); + } depth++; const dollar = prev.value && prev.value.slice(-1) === '$' || block.dollar === true; diff --git a/lib/stringify.js b/lib/stringify.js index 8bcf872..2d5be10 100644 --- a/lib/stringify.js +++ b/lib/stringify.js @@ -3,6 +3,7 @@ const utils = require('./utils'); module.exports = (ast, options = {}) => { + require('./assert-depth')(ast); const stringify = (node, parent = {}) => { const invalidBlock = options.escapeInvalid && utils.isInvalidBrace(parent); const invalidNode = node.invalid === true && options.escapeInvalid === true; diff --git a/test/security-depth.js b/test/security-depth.js new file mode 100644 index 0000000..4efb396 --- /dev/null +++ b/test/security-depth.js @@ -0,0 +1,28 @@ +'use strict'; +require('mocha'); +const assert = require('assert'); +const braces = require('..'); + +describe('bounded parser and supplied AST traversal', () => { + for (const operation of ['compile', 'expand', 'stringify']) { + it(`rejects deep and cyclic ASTs in ${operation}`, () => { + let ast = { type: 'text', value: 'x' }; + for (let i = 0; i < 12000; i++) ast = { type: 'root', nodes: [ast] }; + assert.throws(() => braces[operation](ast), SyntaxError); + const cycle = { type: 'root', nodes: [] }; + cycle.nodes.push(cycle); + assert.throws(() => braces[operation](cycle), SyntaxError); + }); + } + it('rejects deeply nested parsed patterns', () => { + for (const pair of [['{', '}'], ['(', ')']]) { + assert.throws(() => braces.parse(pair[0].repeat(4000) + 'x' + pair[1].repeat(4000)), SyntaxError); + } + }); + it('preserves ordinary ranges, nesting and shared acyclic nodes', () => { + assert.deepStrictEqual(braces.expand('a/{1..3}/{x,y}'), ['a/1/x', 'a/1/y', 'a/2/x', 'a/2/y', 'a/3/x', 'a/3/y']); + const leaf = { type: 'text', value: 'a' }; + assert.strictEqual(braces.stringify({ type: 'root', nodes: [leaf, leaf] }), 'aa'); + assert.doesNotThrow(() => braces.compile('{'.repeat(64) + 'x,y' + '}'.repeat(64))); + }); +});