Conversation
The five LINK variants each paired an unbounded lazy label with an unbounded lazy destination, so text that opens a link and never closes one was retried for the whole remaining input at every start offset: 8 KB of "[a](/" repeated took 4.9s, and the cost grew by 8x per doubling. Bounding both runs makes the cost linear in the input. Highlighting of real markdown is unchanged: 200 README and CHANGELOG files (1.45 MB) take the same time as before, and 9360 generated link forms render identically. Assisted-by: Claude Opus 5 (high) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Build Size ReportChanges to minified artifacts in 5 files changedTotal change +88 B View Changes
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes
Each of the five
LINKvariants pairs an unbounded lazy label with an unbounded lazy destination:begin: /\[.+?\]\([./?&#].*?\)/On text that opens a link and never closes one, the engine grows the label one character at a time and, for every label length, grows the destination again — at every start offset. The cost is cubic.
hljs.highlight('[a](/'.repeat(n), { language: 'markdown' }), Node 26. Every point was taken in a fresh process, min of several runs, and only accepted when a fixed regex reference workload measured in that same process landed within 8% of its idle baseline:11.12.0 grows ×8 per doubling; with the bounds it grows ×2.
Bounding the label at 128 and the destination at 512 characters removes the cubic term. The remaining cost is proportional to the bound — holding the input at 16 KB and varying only the label bound gives 32 → 65 ms, 64 → 123 ms, 128 → 236 ms, 256 → 464 ms, 512 → 921 ms — so the numbers above can be traded for a tighter bound if you would rather have one.
Making the label deterministic instead (
\[[^\]\n]+\]) would be faster still, but it changes which substrings count as links — a label containing]stops matching — so I left that alone.What it costs: on small adversarial inputs the bound is a net loss, because it forces up to 128 label attempts where the unbounded form gave up sooner. 500 B: 1.0 ms → 2.6 ms; 1 KB: 6.3 ms → 8.8 ms. On real markdown there is no cost — 200 README and CHANGELOG files from npm, 1.45 MB in total, take 51–54 ms before and 44–48 ms after.
Behaviour: I generated 9360 link forms — labels with brackets, nesting, escapes and unicode; every URL scheme the variants name; empty labels; reference links; the same forms wrapped in lists, quotes, headings and code spans; and truncated forms that never close — and diffed the rendered markup. Zero differences. As a check that the comparison could fail at all, perturbing the output classes makes it report 8022.
Tests:
test/parser/markdown-link-backtracking.js, in the shape of the existingfunction-declaration-backtracking.jsfrom #4362. With this commit reverted it fails at 26,057 ms against a 4,000 ms budget.One thing worth flagging:
test/regex's "should not cause polynomial backtracking" passes for markdown both before and after this change. It looks for a single repeated character that can reach from one unbounded quantifier to the next, and this attack pumps a five-character sequence ([a](/), so the check cannot see it. #4529 lists 17 more regexes found by other means; this rule is not among them — that issue flagsmarkdown.js:25+26, which is the fenced-code rule.Checklist
test/markup/markdown/links.txtalready covers it; what is added is a timing regression test undertest/parser/, following the precedent from Security: Quadratic ReDoS in C/C++/Arduino FUNCTION_DECLARATION regex (v11.11.1) #4362