Patch brace-expansion advisory - #17
Conversation
Refresh the transitive ESLint lockfile entry to the release that closes GHSA-rgw5-rvv9-x895 so the audit-gated CLI workflows remain usable.
ReviewCorrect, minimal, and the premise checks out. Right call to isolate it from the command work. VerificationPremise first. On So the audit gate really is red on On this branch:
All three match the description. The fix does what the advisory implies, and I checkedThe advisory says "unbounded intermediate arrays," which suggested the patch adds a bound. It does, and Measured growth on the patched version:
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 noteExceeding the cap truncates silently rather than raising: 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. Scope
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. |
A denial of service in 120 charactersThis 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 isIf 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 The arithmeticHere 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:
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 thisAlgorithmic 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:
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 Why 5.0.8 was already supposed to be fixedRead the advisory title again:
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 What the real fix looks likeThe patched version exports its limits: A hard cap on how many results it will produce and how many total characters. I confirmed the cap engages rather than merely existing: 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: 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 hereHonest answer: not much, and it is worth being clear about why rather than pretending otherwise.
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 habitWhen 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 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. |
Summary
This is intentionally isolated from the CLI command work.
Verification