Repository navigation
[markdown] Enforce 999-character limit in _parseReferenceLinkLabel - #2765
Conversation
Stop scanning and return `null` from `LinkSyntax._parseReferenceLinkLabel` as
soon as the number of raw characters inside `[...]` (`parser.pos - start`)
exceeds 999.
----
### Why this change is necessary
When `LinkSyntax.close` finishes matching a `[...]` bracket pair and the
immediately following character is `[` (a potential full reference link
`[text][label]`), it calls `_parseReferenceLinkLabel(parser)` to scan from the
opening `[` to the matching `]`.
CommonMark 0.31.2 section 4.7 ("Link reference definitions") specifies:
> A link label begins with a left bracket (`[`) and ends with the first right
> bracket (`]`) that is not backslash-escaped. Between these brackets there
> must be at least one character that is not a space, tab, or line ending. A
> link label can have at most 999 characters inside the square brackets.
While `LinkParser.parseLabel` (used for `[label]: /url` definitions at the block
level) already checked the 999-character limit,
`LinkSyntax._parseReferenceLinkLabel` (used for `[text][label]` inline
references) had only a `TODO` comment and no length bound at all. This caused
two problems:
1. **Spec non-compliance**: Full reference links `[text][label]` with more than
999 raw characters inside `[label]` were still accepted. For example, a
label containing more than 999 characters due to long whitespace runs could
collapse via `normalizeLinkLabel` to match a short `[a b]: /url` definition,
evaluating to a link instead of plain text, and custom `linkResolver` /
`linkBuilder` callbacks were invoked with labels exceeding 999 characters.
2. **Unbounded scan and string work before rewind**: On an input with a long or
unclosed reference label such as `'[a][${'a' * n}]'` or `'[a][${'a' * n}'`,
`_parseReferenceLinkLabel` scanned all O(n) characters into `buffer`. When a
closing `]` was present, it allocated an O(n) `label` string, ran
`_entirelyWhitespacePattern.hasMatch(label)`, and passed `label` to
`_tryCreateReferenceLink` / `_resolveReferenceLink` (which ran
`normalizeLinkLabel` and `.replaceAll` across all O(n) characters), only for
`InlineParser._linkOrImage` to rewind `parser.pos` to the opening `[` of the
label and re-parse the same n characters as normal inline text.
### How this change works
In `_parseReferenceLinkLabel`, `start` records `parser.pos` at the first
character after the opening `[`. At the top of each `while (true)` iteration
(before checking `char == $rbracket`), `parser.pos - start` is the exact number
of raw source characters already consumed inside `[...]`. Checking
`parser.pos - start` rather than `buffer.length` is necessary because
`_parseReferenceLinkLabel` strips the backslash when writing `\\` or `\]` to
`buffer`, whereas CommonMark counts raw characters inside `[...]` (including
both `\` and the escaped character when `char == $backslash` advances
`parser.pos` twice).
- If a label has at most 999 raw characters inside `[...]` (including a
2-character escape at offsets 997 and 998, as in `'${'a' * 997}\\['`), its
closing `]` is at `parser.pos - start <= 999`, which passes
`parser.pos - start > 999` and breaks on `char == $rbracket`.
- As soon as 1,000 raw characters have been consumed inside `[...]` (or 1,001
when a 2-character `\` escape starts at offset 998 or 999, as in
`'${'a' * 998}\\['`), the next iteration begins with
`parser.pos - start >= 1000` and immediately returns `null` before checking
`char == $rbracket`, bounding every reference label scan and `buffer`
allocation to O(1).
There was a problem hiding this comment.
Code Review
This pull request implements the CommonMark specification limit on link labels, ensuring that reference links with labels exceeding 999 characters are no longer resolved. The changes include updating LinkSyntax to enforce this length limit, removing an obsolete TODO comment, adding a regression test to verify the behavior, and updating the changelog. I have no feedback to provide as the implementation is correct and includes appropriate test coverage.
PR HealthBreaking changes ✔️
This check can be disabled by tagging the PR with Coverage ✔️
This check for test coverage is informational (issues shown here will not fail the PR). This check can be disabled by tagging the PR with Changelog Entry ✔️
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with License Headers ✔️
All source files should start with a license header. Unrelated files missing license headers
This check can be disabled by tagging the PR with API leaks ✔️The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
This check can be disabled by tagging the PR with Unused Dependencies ✔️
For details on how to fix these, see dependency_validator. This check can be disabled by tagging the PR with |
Revisions updated by `dart tools/rev_sdk_deps.dart`. core (https://github.com/dart-lang/core/compare/24e09c4..36f57a9): 36f57a99 Tue Nov 3 14:26:17 2026 +0100 Sigurd Meldgaard [os_detect] Remove bin/os_detect.dart (dart-lang/core#1015) 86bacfca Tue Nov 3 05:15:19 2026 -0500 Justin White [characters] Fix RangeError on an unpaired tail surrogate at range start (dart-lang/core#1033) dartdoc (https://github.com/dart-lang/dartdoc/compare/db94437..2f6847b): 2f6847bd Thu Oct 29 11:47:04 2026 +0200 Sergey G. Grekhov Add tests for invalid doc comments placement (dart-lang/dartdoc#4301) 17f2f0a3 Thu Oct 29 10:16:36 2026 +0100 Sarah Zakarias Compare base elements and unify properties in doc-reference-target-differs (dart-lang/dartdoc#4307) dartdoc is held back at 2f6847b, before dart-lang/dartdoc#4312, because its mock SDK version changes conflict in the google3 3-way merge with the hotfix in cl/1005963642. ecosystem (https://github.com/dart-lang/ecosystem/compare/f8faabf..54e1673): 54e1673 Wed Nov 4 09:46:54 2026 +0100 Sigurd Meldgaard fix(firehose): resolve dartExecutable via package:cli_util (dart-lang/ecosystem#469) d754065 Tue Nov 3 10:09:45 2026 -0800 Kevin Moore refactor(corpus): use path.join and seal closed type hierarchies (dart-lang/ecosystem#476) http (https://github.com/dart-lang/http/compare/8c0d66a..1f869d0): 1f869d0 Tue Nov 3 15:51:52 2026 -0800 dependabot[bot] chore(deps): bump the github-actions group with 8 updates (dart-lang/http#2052) 6d7f402 Tue Nov 3 14:12:04 2026 -0800 Brian Quinlan feat(cupertino_http): support custom trust eval (dart-lang/http#2038) 73f8c36 Tue Nov 3 10:15:16 2026 -0800 Brian Quinlan chore(cupertino_http): ready 3.1.1 release (dart-lang/http#2056) native (https://github.com/dart-lang/native/compare/b442038..1838637): 183863758 Wed Nov 4 09:01:27 2026 -0300 Daco Harkes [hooks_runner] Add `PKG_CONFIG_PATH` to environment variables allowlist (dart-lang/native#3779) c9e8118d4 Wed Nov 4 08:16:20 2026 +1100 Liam Appelbe [jni] Fix release mode segfault (dart-lang/native#3777) 5999cf7e4 Wed Nov 4 08:16:16 2026 +1100 Liam Appelbe [objective_c] Prepare to publish (dart-lang/native#3778) d294d9942 Tue Nov 3 15:15:49 2026 +0100 Michael Goderbauer [hooks_runner] Write recorded_uses.json under directory lock in link() (dart-lang/native#3770) 33aa5ef04 Tue Nov 3 11:42:38 2026 +1100 Liam Appelbe [objective_c] Cheaper and safer object/block validity checks (dart-lang/native#3776) protobuf (https://github.com/dart-lang/protobuf/compare/1d52301..19191c8): 19191c8 Wed Nov 4 17:14:33 2026 +0100 Sigurd Meldgaard Use dart.tool.dart2wasm in json.dart so DDC uses json_web.dart (google/protobuf.dart#1108) tools (https://github.com/dart-lang/tools/compare/1579f8d..a342744): a3427446f Wed Nov 4 15:11:35 2026 +0100 Jonas Finnemann Jensen Update CODEOWNERS for pkgs/markdown (dart-lang/tools#2795) 08c9afdc0 Wed Nov 4 14:40:44 2026 +0100 Jonas Finnemann Jensen [markdown] Check URL boundary before matching in AutolinkExtensionSyntax (dart-lang/tools#2786) 0fc5edcae Wed Nov 4 14:35:48 2026 +0100 Jonas Finnemann Jensen [markdown] Skip unclosed inline HTML tags before matching RegExp (dart-lang/tools#2788) b1f6f9956 Wed Nov 4 14:31:55 2026 +0100 Jonas Finnemann Jensen [markdown] Defer shortcut reference link label extraction in LinkSyntax.close (dart-lang/tools#2785) ab135e072 Wed Nov 4 14:30:37 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic trailing-paren scan in AutolinkExtensionSyntax (dart-lang/tools#2787) f329bf3ea Wed Nov 4 17:09:34 2026 +0530 Ananya Kaul [code_builder] Separate chained cascades after nullable casts (dart-lang/tools#2704) 0a57e3446 Wed Nov 4 05:28:40 2026 +0530 Aman Maurya fix(coverage): allow empty coverage files from skipped test suites (`#2667`) (dart-lang/tools#2668) e95958f6a Tue Nov 3 21:53:57 2026 +0000 Keerti Parthasarathy [unified_analytics] Add root_tool and leaf_tool to every event; key consent on root_tool (dart-lang/tools#2784) 5da06a508 Tue Nov 3 13:11:04 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '(' in parenthesized inline link titles (dart-lang/tools#2770) f3a5c9277 Tue Nov 3 13:09:32 2026 +0100 Jonas Finnemann Jensen [markdown] Cap parenthesis nesting in bare inline link destinations (dart-lang/tools#2769) 820ce5fb7 Tue Nov 3 13:04:35 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '(' in parenthesized link reference titles (dart-lang/tools#2771) 775c5d080 Tue Nov 3 13:03:43 2026 +0100 Jonas Finnemann Jensen [markdown] Require balanced parentheses before bare inline link titles (dart-lang/tools#2768) 5ebd52bc5 Tue Nov 3 13:03:08 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '<' in bracketed inline link destinations (dart-lang/tools#2767) feb7ea928 Tue Nov 3 13:02:15 2026 +0100 Jonas Finnemann Jensen [markdown] Fix 999-character limit in LinkParser.parseLabel (dart-lang/tools#2766) 31888a5da Tue Nov 3 13:01:43 2026 +0100 Jonas Finnemann Jensen [markdown] Enforce 999-character limit in _parseReferenceLinkLabel (dart-lang/tools#2765) e016c5d70 Tue Nov 3 09:26:32 2026 +0100 dart-ecosystem-bot[bot] [boring] Pin the prebuilt binaries of boring-binaries-20261101-f869fdca423c (dart-lang/tools#2778) 8c7935573 Mon Nov 2 10:15:24 2026 -0800 Kevin Moore [html] Move WPT selector fixtures to third_party/wpt-selectors (dart-lang/tools#2772) web (https://github.com/dart-lang/web/compare/45166fd..f388bf5): f388bf5 Sun Nov 1 06:57:22 2026 +0000 dependabot[bot] Bump the github-actions group with 4 updates (dart-lang/web#611) 658ce2f Sat Oct 31 12:47:20 2026 +0100 Moritz Migrate to ecosystem health publish check and setup-dart publish workflow (dart-lang/web#610) Change-Id: Ifcae02c4712b4b27a5a8ab1c57d8dcf13a6700cf Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/574281 Reviewed-by: Brian Quinlan <[email protected]> Commit-Queue: Nate Bosch <[email protected]>
Stop scanning and return
nullfromLinkSyntax._parseReferenceLinkLabelassoon as the number of raw characters inside
[...](parser.pos - start)exceeds 999.
Why this change is necessary
When
LinkSyntax.closefinishes matching a[...]bracket pair and theimmediately following character is
[(a potential full reference link[text][label]), it calls_parseReferenceLinkLabel(parser)to scan from theopening
[to the matching].CommonMark 0.31.2 section 4.7 ("Link reference definitions") specifies:
While
LinkParser.parseLabel(used for[label]: /urldefinitions at the blocklevel) already checked the 999-character limit,
LinkSyntax._parseReferenceLinkLabel(used for[text][label]inlinereferences) had only a
TODOcomment and no length bound at all. This causedtwo problems:
[text][label]with more than999 raw characters inside
[label]were still accepted. For example, alabel containing more than 999 characters due to long whitespace runs could
collapse via
normalizeLinkLabelto match a short[a b]: /urldefinition,evaluating to a link instead of plain text, and custom
linkResolver/linkBuildercallbacks were invoked with labels exceeding 999 characters.unclosed reference label such as
'[a][${'a' * n}]'or'[a][${'a' * n}',_parseReferenceLinkLabelscanned all O(n) characters intobuffer. When aclosing
]was present, it allocated an O(n)labelstring, ran_entirelyWhitespacePattern.hasMatch(label), and passedlabelto_tryCreateReferenceLink/_resolveReferenceLink(which rannormalizeLinkLabeland.replaceAllacross all O(n) characters), only forInlineParser._linkOrImageto rewindparser.posto the opening[of thelabel and re-parse the same n characters as normal inline text.
How this change works
In
_parseReferenceLinkLabel,startrecordsparser.posat the firstcharacter after the opening
[. At the top of eachwhile (true)iteration(before checking
char == $rbracket),parser.pos - startis the exact numberof raw source characters already consumed inside
[...]. Checkingparser.pos - startrather thanbuffer.lengthis necessary because_parseReferenceLinkLabelstrips the backslash when writing\\or\]tobuffer, whereas CommonMark counts raw characters inside[...](includingboth
\and the escaped character whenchar == $backslashadvancesparser.postwice).[...](including a2-character escape at offsets 997 and 998, as in
'${'a' * 997}\\['), itsclosing
]is atparser.pos - start <= 999, which passesparser.pos - start > 999and breaks onchar == $rbracket.[...](or 1,001when a 2-character
\escape starts at offset 998 or 999, as in'${'a' * 998}\\['), the next iteration begins withparser.pos - start >= 1000and immediately returnsnullbefore checkingchar == $rbracket, bounding every reference label scan andbufferallocation to O(1).
Behavior change
A full reference link
[text][label]whose label has more than 999 charactersis no longer resolved; it renders as literal text. CommonMark §4.7: "A link
label can have at most 999 characters inside the square brackets."
LinkParseralready applied this limit to definitions; inline references didnot, so a 1,000-character label could match a definition whose label
normalizes to the same text, and custom
linkResolvers were invoked withunbounded labels.
Differential testing
Rendered every input with
origin/mainand with this branch (GFM extensionset, 30 s per-file timeout) and compared the HTML. Sources:
mdn/content,github/docs,kubernetes/website,freeCodeCamp,tldr,flutter/website,dart-lang/site-www,microsoft/vscode-docs,withastro/docs,rust-lang/book, GitHub READMEs, and Wikipediacmark,cmark-gfm,commonmark.js,pulldown-cmark,md4c,goldmark,markdown-it,comrak,marked,pandoc, and pathological generatorscmark,md4c, andmarkdown-it-pyfuzzersOSS-Fuzz inputs that sit at the 30 s timeout or the stack limit flip between
runs; every one reported as a new failure was re-run standalone on both trees.
Where the HTML changed, the changed region was also compared with the output of
commonmark.js0.31 for the same input.Results:
timeout — standalone it takes 5.4 s on
mainand 5.3 s on this branch (three runs each), so that was load during the run. Cumulative parse time −1.1%.