Skip to content

[markdown] Enforce 999-character limit in _parseReferenceLinkLabel - #2765

Merged
jonasfj merged 1 commit into
mainfrom
markdown_reference_link_label_limit
Nov 3, 2026
Merged

jonasfj merged 1 commit into
mainfrom
markdown_reference_link_label_limit

Conversation

@jonasfj

@jonasfj jonasfj commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

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).

Behavior change

A full reference link [text][label] whose label has more than 999 characters
is no longer resolved; it renders as literal text. CommonMark §4.7: "A link
label can have at most 999 characters inside the square brackets."
LinkParser already applied this limit to definitions; inline references did
not, so a 1,000-character label could match a definition whose label
normalizes to the same text, and custom linkResolvers were invoked with
unbounded labels.


Differential testing

Rendered every input with origin/main and with this branch (GFM extension
set, 30 s per-file timeout) and compared the HTML. Sources:

  • pub.dev: 367,926 Markdown files from 93,452 packages
  • Real-world docs: 116,231 files from 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 Wikipedia
  • Parser test suites: 6,458 cases from cmark, cmark-gfm, commonmark.js,
    pulldown-cmark, md4c, goldmark, markdown-it, comrak, marked,
    pandoc, and pathological generators
  • OSS-Fuzz: 57,716 inputs from the cmark, md4c, and markdown-it-py fuzzers

OSS-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.js 0.31 for the same input.

Results:

  • No HTML differences in any source.
  • 1 OSS-Fuzz timeout resolved; one 93 KB OSS-Fuzz input was reported as a new
    timeout — standalone it takes 5.4 s on main and 5.3 s on this branch (three runs each), so that was load during the run. Cumulative parse time −1.1%.

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).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

Copy link
Copy Markdown

PR Health

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
markdown Non-Breaking 7.3.1 7.4.1-wip 7.4.1-wip ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

Coverage ✔️
File Coverage
pkgs/markdown/lib/src/inline_syntaxes/link_syntax.dart 💚 99 % ⬆️ 0 %

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 skip-coverage-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check (for example, for non-user-visible changes when the package version is already at -wip).

License Headers ✔️
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

Files
no missing headers

All source files should start with a license header.

Unrelated files missing license headers
Files
pkgs/html/test/differential_test.dart
pkgs/html/test/dom_parsing_test.dart
pkgs/html/test/mxss_test.dart
pkgs/html/test/noah_ark_clause_limit_test.dart

This check can be disabled by tagging the PR with skip-license-check.

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.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

Unused Dependencies ✔️
Package Status
markdown ✔️ All dependencies utilized correctly.

For details on how to fix these, see dependency_validator.

This check can be disabled by tagging the PR with skip-unused-dependencies-check.

@jonasfj
jonasfj marked this pull request as ready for review November 2, 2026 13:47
@jonasfj
jonasfj requested a review from srawlins as a code owner November 2, 2026 13:47
@jonasfj
jonasfj requested review from szakarias and removed request for srawlins November 2, 2026 13:47
@jonasfj
jonasfj merged commit 31888a5 into main Nov 3, 2026
23 of 24 checks passed
@jonasfj
jonasfj deleted the markdown_reference_link_label_limit branch November 3, 2026 12:01
copybara-service Bot pushed a commit to dart-lang/sdk that referenced this pull request Nov 5, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants