Skip to content

Patch brace-expansion advisory - #17

Merged
raghubetina merged 1 commit into
mainfrom
codex/brace-expansion-5-0-9
Aug 4, 2026
Merged

Patch brace-expansion advisory#17
raghubetina merged 1 commit into
mainfrom
codex/brace-expansion-5-0-9

Conversation

@raghubetina

Copy link
Copy Markdown
Contributor

Summary

  • refresh the transitive ESLint lockfile entry from brace-expansion 5.0.8 to 5.0.9
  • close GHSA-rgw5-rvv9-x895, which now fails the audit-gated quality job on otherwise unrelated CLI pull requests
  • keep package.json, dependency ranges, and the published package contents unchanged

This is intentionally isolated from the CLI command work.

Verification

  • PATH="/Users/sandbox2/.asdf/shims:$PATH" npm ci --ignore-scripts
  • npm audit (0 vulnerabilities)
  • PATH="/Users/sandbox2/.asdf/shims:$PATH" npm run check
    • typecheck
    • ESLint
    • Prettier
    • 132 tests
    • package allowlist
    • freshly packed executable smoke

Refresh the transitive ESLint lockfile entry to the release that closes GHSA-rgw5-rvv9-x895 so the audit-gated CLI workflows remain usable.
@raghubetina

Copy link
Copy Markdown
Contributor Author

Review

Correct, minimal, and the premise checks out. Right call to isolate it from the command work.

Verification

Premise first. On origin/main:

brace-expansion  4.0.0 - 5.0.8
Severity: high
brace-expansion: DoS via unbounded intermediate arrays, bypassing the
CVE-2026-14257 mitigation - https://github.com/advisories/GHSA-rgw5-rvv9-x895

1 high severity vulnerability

So the audit gate really is red on main, and the vulnerable range ends at 5.0.8, making 5.0.9 the minimum version that clears it. Not a version further than needed.

On this branch:

Check Result
npm ci --ignore-scripts clean
npm audit found 0 vulnerabilities
npm run check 132 tests / 132 pass

All three match the description.

The fix does what the advisory implies, and I checked

The advisory says "unbounded intermediate arrays," which suggested the patch adds a bound. It does, and 5.0.9 exposes the limits as named exports:

EXPANSION_MAX:        100000
EXPANSION_MAX_LENGTH: 4000000

Measured growth on the patched version:

Groups Pattern length Results Time
8 40 chars 256 0.2ms
12 60 chars 4,096 1.3ms
16 80 chars 65,536 17.2ms
18 90 chars 100,000 43.2ms
24 120 chars 100,000 capped

The 18-group row is the interesting one. Unbounded it would be 2^18, or 262,144. It returns exactly 100,000, so the cap is engaged rather than merely present. At 24 groups the unbounded count would be 16.7 million and it still returns 100,000.

One behavioral note

Exceeding the cap truncates silently rather than raising:

24 groups (2^24 = 16.7M unbounded) -> 100000 results (no throw)

So a caller handing in a pathological pattern gets a partial expansion with no signal that anything was dropped. For the path this dependency is on, that is the right trade: ESLint expanding its own ignore patterns would rather match incompletely than hang.

Worth knowing rather than acting on. brace-expansion is a dev dependency here, reached through ESLint, and the patterns come from this repository's own configuration rather than from anything a user supplies. There is no route from CLI input to this expander, so the truncation cannot affect published behavior.

Scope

package.json untouched, ranges untouched, one lockfile entry changed, "dev": true preserved on the entry. Published package contents cannot be affected by a dev-only transitive bump, and the packed executable smoke in npm run check confirms the artifact still builds and runs.

Isolating this from the command work was the right instinct. It means the CLI stack's own runs stop carrying an unrelated red gate, which is worth more than the three lines suggest given how much of that stack is landing at once.

@raghubetina

Copy link
Copy Markdown
Contributor Author

A denial of service in 120 characters

This PR bumps one package by one patch version. The advisory it closes is worth understanding, because the attack it describes needs no network flood, no botnet, and no privileged access. It needs a short string.

What brace expansion is

If you have used a shell, you have used this:

$ echo file.{txt,md}
file.txt file.md

$ mkdir -p project/{src,test}/{unit,integration}

One pattern, several results. The brace-expansion package does this in JavaScript, and it is everywhere: ESLint expands ignore patterns with it, and so does most glob-matching tooling.

The arithmetic

Here is the whole vulnerability, and it is just multiplication.

Each brace group multiplies the number of results by the number of alternatives inside it. Two groups of two gives four. Three gives eight. Every additional five characters of input doubles the output.

I measured it on the patched version:

Pattern Length Results
{a,b} × 8 40 chars 256
{a,b} × 12 60 chars 4,096
{a,b} × 16 80 chars 65,536

That last one: 80 characters of input produced 65,536 strings, and took 17 milliseconds.

Extend the pattern a little further and the numbers stop being amusing. Thirty groups is 150 characters and 2^30, about a billion results. Your process is not slow at that point, it is gone, having tried to allocate a billion strings.

This is why the advisory is rated high despite requiring no cleverness. A single request field of 150 characters takes down the process handling it.

The name for this

Algorithmic complexity attack. Rather than sending a lot of data, you send a small input that costs a disproportionate amount to process.

The family is worth recognising because the members look unrelated until you see the shape:

  • ReDoS. A regex like (a+)+b against "aaaaaaaaaaaaaaaaaaaaaaaaaaaa" backtracks exponentially. Twenty-eight characters, hours of CPU.
  • Zip bombs. A 42 KB archive that expands to petabytes.
  • Billion laughs. An XML document defining nested entities that expand multiplicatively.
  • Hash flooding. Keys chosen to collide in a hash table, turning O(1) lookups into O(n).

Every one is small input, enormous work. And every one lives in the part of your stack you were not thinking about: a regex in a validator, a decompression call, an XML parser, a Hash.

Why 5.0.8 was already supposed to be fixed

Read the advisory title again:

DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation

So there was an earlier advisory, and a fix shipped for it, and this one is about getting around that fix.

That pattern is common enough to expect. The first fix usually bounds the thing the reporter demonstrated. Then somebody finds a different route to the same explosion, often by moving the growth into an intermediate step the bound does not cover. Here the name says exactly that: the intermediate arrays were unbounded even though something else was checked.

The practical consequence: an incomplete fix is not unusual, and "we already patched that" is not the same as "that class of bug is closed." I saw the same phrasing on a postcss advisory this week, described as an incomplete fix of an earlier one.

What the real fix looks like

The patched version exports its limits:

EXPANSION_MAX:        100000
EXPANSION_MAX_LENGTH: 4000000

A hard cap on how many results it will produce and how many total characters. I confirmed the cap engages rather than merely existing:

18 groups (2^18 = 262,144 unbounded) -> 100,000 results
24 groups (2^24 =  16.7M unbounded) -> 100,000 results

Note what this fix is not. It does not make the expansion cleverer or find a lazy representation. It refuses to do more than a fixed amount of work.

That is almost always the right answer for this family, and it is worth internalising because the instinct is usually to optimise. You cannot optimise your way out of exponential growth; you can only decline to participate. Bound the output, bound the input size, bound the time, or bound the recursion depth. Pick whichever you can express and enforce it.

There is a design decision buried in it too. Exceeding the cap truncates rather than raising:

24 groups -> 100000 results (no throw)

For ESLint expanding its own ignore patterns, degrading the match beats crashing the linter. If you write a bound like this in your own code, that choice deserves a moment: silent truncation is friendlier and it hides the problem, while raising is louder and can turn a slow path into an outage. Which you want depends on whether a partial answer is useful.

Whether this one mattered here

Honest answer: not much, and it is worth being clear about why rather than pretending otherwise.

brace-expansion arrives as a dev dependency through ESLint. The patterns it expands come from this repository's own configuration files. There is no path from anything a CLI user types to this expander, so nobody could have exploited it.

Two reasons to fix it anyway.

The narrow one is that dependency graphs move. Something that is dev-only today gets pulled into a runtime path tomorrow by an unrelated change, and nobody re-audits the old advisories when that happens.

The broader one is what a permanently red gate does. Once "the audit always fails, ignore it" is true, it stays true on the day it matters. Three lines to keep the gate meaningful is cheap.

The habit

When you write code where output size depends on input in a non-linear way, you have taken on this risk. The tell is nested iteration whose bounds both come from the input, recursion whose depth is input-controlled, or any for loop inside a for loop over the same parsed structure.

At that point, ask what the largest input you will accept is, and what it costs. If you cannot answer, put a bound in before somebody else finds the number for you.

@raghubetina
raghubetina merged commit 6d33d3c into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant