Skip to content

fix: resolve link hrefs in a single pass (O(n) instead of O(n^2)) - #118

Open
BrianWillows wants to merge 1 commit into
developit:mainfrom
BrianWillows:fix/link-resolution-quadratic
Open

fix: resolve link hrefs in a single pass (O(n) instead of O(n^2))#118
BrianWillows wants to merge 1 commit into
developit:mainfrom
BrianWillows:fix/link-resolution-quadratic

Conversation

@BrianWillows

Copy link
Copy Markdown

Summary

parse() resolves each link's href by rewriting the entire output string
on every closing ]:

out = out.replace('<a>', `<a href="${...}">`);

out grows with the input, and String.replace rescans it from the start each
time, so a document with N links is O(n²). Because Markdown is untrusted
input by definition, this is a CPU-exhaustion / DoS vector (CWE-407): a single
link-heavy field can block the event loop for seconds.

Measured on the current code, input '[a](b)'.repeat(n):

links input size before after
4,000 24 KB 91 ms ~4 ms
8,000 48 KB 391 ms ~5 ms
16,000 96 KB 2450 ms ~13 ms
128,000 768 KB (~min) ~131 ms

Fix

Instead of rewriting out on every ], record the offset of each <a> marker
as output is appended, and fill in the hrefs in a single left-to-right pass at
the end. Offsets stay valid because the parser only ever appends to out. This
keeps the exact same "first unresolved <a> wins" semantics while making
parsing linear.

Verification

  • Existing test suite passes unchanged (npm test, 33 passing).
  • Differential test over 3000+ fuzzed inputs — random token soup, structured
    links, nested/adjacent/reference links, images, and literal <a> in the
    source — output is byte-for-byte identical before and after.
  • Timing is now linear (see table); 128k links parse in ~131 ms.

Notes

Found and fixed with AI assistance (Claude). Happy to adjust naming/comments or
add a regression test if you'd like.

parse() rewrote the whole output string via out.replace('<a>', ...) on
every closing ']', rescanning all prior output each time -> O(n^2) on
link-heavy input. Since markdown is untrusted input, this is a CWE-407
DoS: ~16k links (~96KB) blocked the event loop for ~2.4s, growing
quadratically.

Record each '<a>' marker's offset as output is appended and fill hrefs in
one final left-to-right pass. Same 'first unresolved marker wins'
semantics (verified byte-for-byte identical on 3000+ fuzzed inputs incl.
nested/adjacent/reference links, images and literal <a> in source);
existing tests pass. Parsing is now linear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants