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
53 changes: 46 additions & 7 deletions .verb.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,38 @@ console.log(braces.expand('a{b}c'));
//=> ['a{b}c']
```

## Nesting and AST safety limits

The parser allows at most 128 nested brace/parenthesis containers, including
unmatched opening delimiters. Escaped or quoted delimiters do not count.
Before `compile`, `expand`, or `stringify` processes an AST, an iterative check
limits child-edge depth to 128 (the root is at depth zero) and visits to 65,536,
including repeated visits to shared nodes. This check also applies to direct
imports from `lib/`. Child cycles are rejected; acyclic shared child objects are
allowed. The parser's normal `parent` and `prev` backlinks are not child edges.
Expansion derives its work queues from the actual child traversal and does not
read `parent` metadata. External queues and cyclic or inconsistent parent links
cannot provide expansion input. Standalone parenthesis subtrees and shared
child objects therefore do not need matching parent links. Caller-provided
parent queues are intentionally ignored instead of being consumed or mutated.
This also handles reparented nodes from malformed patterns such as `{(a)`,
which previously could fail with a `TypeError` from a stale parent queue.
Expansion and compilation keep range arguments and children intact, so shared
range subtrees retain their meaning and range ASTs can be processed again. This is
not a promise that every AST property is immutable: work queues and other
normalization metadata may still be updated.

Exceeding a bound throws a `SyntaxError` with code `ERR_BRACES_COMPLEXITY`.
These fixed limits cannot be raised or disabled through options. Because text
and delimiter nodes add a child edge, 128 parsed containers can exceed the AST
depth limit when later compiled, expanded, or stringified; 127 containers leave
room for these terminal nodes.

These checks bound nesting and AST traversal, not arbitrary AST property
getters, array-valued text nodes, total expansion cardinality, or the execution
time of regular expressions generated from patterns. The existing `maxLength`
and `rangeLimit` options remain independent protections.

## Options

### options.maxLength
Expand Down Expand Up @@ -205,18 +237,25 @@ console.log(braces('a/{b,c}/d', { expand: true }));

**Description**: To prevent malicious patterns from being passed by users, an error is thrown when `braces.expand()` is used or `options.expand` is true and the generated range will exceed the `rangeLimit`.

You can customize `options.rangeLimit` or set it to `Inifinity` to disable this altogether.
You can customize `options.rangeLimit` or set it to `Infinity` or `false` to disable the size limit.

The limit counts each range inclusively, in either direction, including character
ranges. Explicit pattern steps take precedence over `options.step`; negative
steps use their absolute value and zero is normalized to one. Numeric range
endpoints must be safe integers, even when the size limit is disabled, because
larger numbers can stop making progress during generation. Invalid ranges and
steps retain their normal fallback or `strictRanges` errors. This limit applies
to individual ranges, not the combined cardinality of alternative products.

**Examples**

```js
// pattern exceeds the "rangeLimit", so it's optimized automatically
console.log(braces.expand('{1..1000}'));
//=> ['([1-9]|[1-9][0-9]{1,2}|1000)']
// A range larger than the configured limit is rejected before generation.
braces.expand('{1..1001}'); // throws RangeError (default limit: 1000)

// pattern does not exceed "rangeLimit", so it's NOT optimized
console.log(braces.expand('{1..100}'));
//=> ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '100']
// A range exactly at the limit is allowed.
console.log(braces.expand('{1..3}', { rangeLimit: 3 }));
//=> ['1', '2', '3']
```

### options.transform
Expand Down
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,38 @@ console.log(braces.expand('a{b}c'));
//=> ['a{b}c']
```

## Nesting and AST safety limits

The parser allows at most 128 nested brace/parenthesis containers, including
unmatched opening delimiters. Escaped or quoted delimiters do not count.
Before `compile`, `expand`, or `stringify` processes an AST, an iterative check
limits child-edge depth to 128 (the root is at depth zero) and visits to 65,536,
including repeated visits to shared nodes. This check also applies to direct
imports from `lib/`. Child cycles are rejected; acyclic shared child objects are
allowed. The parser's normal `parent` and `prev` backlinks are not child edges.
Expansion derives its work queues from the actual child traversal and does not
read `parent` metadata. External queues and cyclic or inconsistent parent links
cannot provide expansion input. Standalone parenthesis subtrees and shared
child objects therefore do not need matching parent links. Caller-provided
parent queues are intentionally ignored instead of being consumed or mutated.
This also handles reparented nodes from malformed patterns such as `{(a)`,
which previously could fail with a `TypeError` from a stale parent queue.
Expansion and compilation keep range arguments and children intact, so shared
range subtrees retain their meaning and range ASTs can be processed again. This is
not a promise that every AST property is immutable: work queues and other
normalization metadata may still be updated.

Exceeding a bound throws a `SyntaxError` with code `ERR_BRACES_COMPLEXITY`.
These fixed limits cannot be raised or disabled through options. Because text
and delimiter nodes add a child edge, 128 parsed containers can exceed the AST
depth limit when later compiled, expanded, or stringified; 127 containers leave
room for these terminal nodes.

These checks bound nesting and AST traversal, not arbitrary AST property
getters, array-valued text nodes, total expansion cardinality, or the execution
time of regular expressions generated from patterns. The existing `maxLength`
and `rangeLimit` options remain independent protections.

## Options

### options.maxLength
Expand Down Expand Up @@ -215,18 +247,25 @@ console.log(braces('a/{b,c}/d', { expand: true }));

**Description**: To prevent malicious patterns from being passed by users, an error is thrown when `braces.expand()` is used or `options.expand` is true and the generated range will exceed the `rangeLimit`.

You can customize `options.rangeLimit` or set it to `Inifinity` to disable this altogether.
You can customize `options.rangeLimit` or set it to `Infinity` or `false` to disable the size limit.

The limit counts each range inclusively, in either direction, including character
ranges. Explicit pattern steps take precedence over `options.step`; negative
steps use their absolute value and zero is normalized to one. Numeric range
endpoints must be safe integers, even when the size limit is disabled, because
larger numbers can stop making progress during generation. Invalid ranges and
steps retain their normal fallback or `strictRanges` errors. This limit applies
to individual ranges, not the combined cardinality of alternative products.

**Examples**

```js
// pattern exceeds the "rangeLimit", so it's optimized automatically
console.log(braces.expand('{1..1000}'));
//=> ['([1-9]|[1-9][0-9]{1,2}|1000)']
// A range larger than the configured limit is rejected before generation.
braces.expand('{1..1001}'); // throws RangeError (default limit: 1000)

// pattern does not exceed "rangeLimit", so it's NOT optimized
console.log(braces.expand('{1..100}'));
//=> ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '100']
// A range exactly at the limit is allowed.
console.log(braces.expand('{1..3}', { rangeLimit: 3 }));
//=> ['1', '2', '3']
```

### options.transform
Expand Down
5 changes: 4 additions & 1 deletion lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

const fill = require('fill-range');
const utils = require('./utils');
const { assertTree } = require('./depth-guard');

const compile = (ast, options = {}) => {
assertTree(ast);
const walk = (node, parent = {}) => {
const invalidBlock = utils.isInvalidBrace(parent);
const invalidNode = node.invalid === true && options.escapeInvalid === true;
Expand Down Expand Up @@ -37,7 +39,8 @@ const compile = (ast, options = {}) => {
}

if (node.nodes && node.ranges > 0) {
const args = utils.reduce(node.nodes);
// Range normalization must not consume a shared or reusable AST's markers.
const args = utils.reduce(node.nodes.map(child => ({ type: child.type, value: child.value })));
const range = fill(...args, { ...options, wrap: false, toRegex: true, strictZeros: true });

if (range.length !== 0) {
Expand Down
44 changes: 44 additions & 0 deletions lib/depth-guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use strict';

// Caller options must not disable the bounds protecting recursive walkers.
const MAX_DEPTH = 128;
const MAX_NODES = 65536;

const fail = () => {
const error = new SyntaxError('Brace expression exceeds the safe nesting or AST complexity limit');
error.code = 'ERR_BRACES_COMPLEXITY';
throw error;
};

exports.assertDepth = depth => {
if (depth > MAX_DEPTH) fail();
};

// Preflight iteratively, before processing or mutating a caller-supplied AST.
// Only child edges are followed: parser ASTs have intentional parent/prev links.
// Track the active path, rather than all nodes, to allow shared child objects.
exports.assertTree = ast => {
const stack = [{ node: ast, depth: 0, index: -1 }];
const ancestors = new Set();
let count = 0;

while (stack.length) {
const frame = stack[stack.length - 1];
const node = frame.node;

if (frame.index === -1) {
if (!node || typeof node !== 'object') fail();
if (frame.depth > MAX_DEPTH || ancestors.has(node) || ++count > MAX_NODES) fail();
if (node.nodes && !Array.isArray(node.nodes)) fail();
ancestors.add(node);
frame.index = 0;
}

if (node.nodes && frame.index < node.nodes.length) {
stack.push({ node: node.nodes[frame.index++], depth: frame.depth + 1, index: -1 });
} else {
ancestors.delete(node);
stack.pop();
}
}
};
40 changes: 19 additions & 21 deletions lib/expand.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const fill = require('fill-range');
const stringify = require('./stringify');
const utils = require('./utils');
const { assertTree } = require('./depth-guard');

const append = (queue = '', stash = '', enclose = false) => {
const result = [];
Expand Down Expand Up @@ -31,18 +32,12 @@ const append = (queue = '', stash = '', enclose = false) => {
};

const expand = (ast, options = {}) => {
assertTree(ast);
const rangeLimit = options.rangeLimit === undefined ? 1000 : options.rangeLimit;

const walk = (node, parent = {}) => {
const walk = (node, parentQueue) => {
node.queue = [];

let p = parent;
let q = parent.queue;

while (p.type !== 'brace' && p.type !== 'root' && p.parent) {
p = p.parent;
q = p.queue;
}
const q = parentQueue;

if (node.invalid || node.dollar) {
q.push(append(q.pop(), stringify(node, options)));
Expand All @@ -55,9 +50,15 @@ const expand = (ast, options = {}) => {
}

if (node.nodes && node.ranges > 0) {
const args = utils.reduce(node.nodes);

if (utils.exceedsLimit(...args, options.step, rangeLimit)) {
// reduce normalizes range markers in place. Keep that work local so a
// shared subtree retains its range arguments on every traversal.
const args = utils.reduce(node.nodes.map(child => ({ type: child.type, value: child.value })));

let step = args[2] || options.step || 1;
// fill-range accepts a function or an options object as its third argument.
if (typeof args[2] === 'function') step = 1;
else if (args[2] && typeof args[2] === 'object' && !Array.isArray(args[2])) step = args[2].step || 1;
if (utils.exceedsLimit(args[0], args[1], step, rangeLimit)) {
throw new RangeError('expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.');
}

Expand All @@ -67,18 +68,15 @@ const expand = (ast, options = {}) => {
}

q.push(append(q.pop(), range));
node.nodes = [];
return;
}

const enclose = utils.encloseBrace(node);
let queue = node.queue;
let block = node;

while (block.type !== 'brace' && block.type !== 'root' && block.parent) {
block = block.parent;
queue = block.queue;
}
// Queue ownership follows the actual child traversal. A caller's parent
// metadata must never supply an unvalidated external work queue.
const queue = node.type === 'brace' || node.type === 'root' || parentQueue === undefined
? node.queue
: parentQueue;

for (let i = 0; i < node.nodes.length; i++) {
const child = node.nodes[i];
Expand All @@ -100,7 +98,7 @@ const expand = (ast, options = {}) => {
}

if (child.nodes) {
walk(child, node);
walk(child, queue);
}
}

Expand Down
3 changes: 3 additions & 0 deletions lib/parse.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const stringify = require('./stringify');
const { assertDepth } = require('./depth-guard');

/**
* Constants
Expand Down Expand Up @@ -143,6 +144,7 @@ const parse = (input, options = {}) => {
*/

if (value === CHAR_LEFT_PARENTHESES) {
assertDepth(stack.length);
block = push({ type: 'paren', nodes: [] });
stack.push(block);
push({ type: 'text', value });
Expand Down Expand Up @@ -203,6 +205,7 @@ const parse = (input, options = {}) => {
*/

if (value === CHAR_LEFT_CURLY_BRACE) {
assertDepth(stack.length);
depth++;

const dollar = prev.value && prev.value.slice(-1) === '$' || block.dollar === true;
Expand Down
2 changes: 2 additions & 0 deletions lib/stringify.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use strict';

const utils = require('./utils');
const { assertTree } = require('./depth-guard');

module.exports = (ast, options = {}) => {
assertTree(ast);
const stringify = (node, parent = {}) => {
const invalidBlock = options.escapeInvalid && utils.isInvalidBrace(parent);
const invalidNode = node.invalid === true && options.escapeInvalid === true;
Expand Down
30 changes: 27 additions & 3 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,33 @@ exports.find = (node, type) => node.nodes.find(node => node.type === type);
*/

exports.exceedsLimit = (min, max, step = 1, limit) => {
if (limit === false) return false;
if (!exports.isInteger(min) || !exports.isInteger(max)) return false;
return ((Number(max) - Number(min)) / Number(step)) >= limit;
const validValue = value => typeof value === 'number' || (typeof value === 'string' && value !== '');
if (!validValue(min) || !validValue(max)) return false;

// Match fill-range's fallback for zero steps and object-valued options.step.
step = step || 1;
if (typeof step === 'object' && !Array.isArray(step) && !Number.isInteger(+step)) step = 1;
if (!Number.isInteger(+step)) return false;
step = Math.max(Math.abs(step), 1);

let a = +min;
let b = +max;
if (Number.isInteger(a) && Number.isInteger(b)) {
// A unit increment can stop making progress outside the safe integer
// domain. Disabling a size limit must not permit that nonterminating loop.
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) {
throw new RangeError('numeric range endpoints must be safe integers');
}
} else {
// fill-range counts letters by their first UTF-16 code unit. Numeric
// strings remain valid mixed endpoints, even when they have several digits.
if ((!Number.isInteger(a) && min.length > 1) || (!Number.isInteger(b) && max.length > 1)) return false;
a = String(min).charCodeAt(0);
b = String(max).charCodeAt(0);
}

if (limit === false || limit === Infinity) return false;
return Math.floor(Math.abs(b - a) / step) + 1 > limit;
};

/**
Expand Down
Loading