Repository navigation
Update dependency league/commonmark to v2.10.2 [SECURITY] - #890
Merged
renovate[bot] merged 1 commit intoOct 2, 2026
Merged
Conversation
Contributor
Author
Branch automerge failureThis PR was configured for branch automerge. However, this is not possible, so it has been raised as a PR instead.
|
|
Hello 👋 here is the most recent benchmark result:
This comment gets update everytime a new commit comes in! |
renovate
Bot
deleted the
renovate/packagist-league-commonmark-vulnerability
branch
September 5, 2026 05:51
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
2 times, most recently
from
September 5, 2026 09:43
4c0a615 to
4811518
Compare
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
from
September 14, 2026 17:18
4811518 to
3a595e2
Compare
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
2 times, most recently
from
September 16, 2026 12:57
e3289df to
120b76f
Compare
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
5 times, most recently
from
September 30, 2026 17:10
a7435ef to
21e3fcb
Compare
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
from
October 1, 2026 22:22
21e3fcb to
c7cfcd3
Compare
| datasource | package | from | to | | ---------- | ----------------- | ----- | ------ | | packagist | league/commonmark | 2.9.0 | 2.10.2 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
renovate
Bot
force-pushed
the
renovate/packagist-league-commonmark-vulnerability
branch
from
October 2, 2026 14:36
c7cfcd3 to
861c9aa
Compare
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.
This PR contains the following updates:
2.9.0→2.10.2league/commonmark XSS:
on*event-handler filter inAttributesExtensionbypassed with a U+000C form feedGHSA-f8fg-pg57-v4j8
More information
Details
Summary
The
AttributesExtensiondocuments a security guarantee:Prefixing the attribute name with a single U+000C FORM FEED byte defeats that guarantee.
{<FF>onclick="alert(1)"}passes throughAttributesHelper::filterAttributes()untouched and iswritten verbatim into the output, where browsers parse it as a genuine
onclickhandler.The same prefix defeats the
allow_unsafe_linkscheck, letting ajavascript:URI through onhref/srceven whenallow_unsafe_linksisfalse.This bypasses the fix shipped in the 2.7.0 security release ("Fix XSS in AttributesExtension",
43207253ea5f14867c77c697cd3838c446cadcea), which added
filterAttributes()for the expresspurpose of blocking these attributes.
Throughout this report
<FF>denotes a literal U+000C byte ("\x0C"in PHP). It is invisible inrendered text, so all payloads below are written with PHP escape sequences to stay unambiguous.
Details
Three behaviours combine.
1.
\x0Csurvives the parser'strim().AttributesHelper::SINGLE_ATTRIBUTEbegins with\s*, andCursor::match()returns$matches[0][0]— the entire match, including that leading whitespace. The result is cleanedwith PHP's
trim():PCRE
\smatches\x0C, but PHP's defaulttrim()charlist is" \t\n\r\0\x0B"— it includesthe vertical tab
\x0Bbut not the form feed\x0C. The byte is therefore consumed by theregex, retained in the returned match, and not stripped. It ends up inside the attribute name:
\x0Cis the only byte with this property: every other character the HTML5 tokenizer treats aswhitespace (
\x09,\x0A,\x0D,\x20), plus\x0B, is in PHP's trim charlist. The PoCincludes a
\x0Bcase as a control, and it is correctly stripped.2. The filter's string comparisons miss it.
filterAttributes()compares the raw name against literal strings:3. The renderer never escapes attribute names.
Because the HTML5 tokenizer treats
\x0Cas whitespace between attributes, the browser readsthe name as plain
onclick.PoC
Full observed output (
\x0Cshown escaped; it is a literal single byte in the real output):hello {onclick="alert(1)"}<p>hello</p>hello {\x0Conclick="alert(1)"}<p \x0Conclick="alert(1)">hello</p>hello {\x0Bonclick="alert(1)"}<p>hello</p>[click](javascript:alert(1))<p><a>click</a></p>[click](https://example.com){\x0Chref="javascript:alert(1)"}<p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p>{\x0Conerror="alert(1)"}<p><img \x0Conerror="alert(1)" src="…" alt="x" /></p># heading+ newline +{\x0Conclick="alert(1)"}<h1 \x0Conclick="alert(1)">heading</h1>In case E the injected
hrefprecedes the legitimate one. Per the HTML5 duplicate-attribute rulethe first occurrence wins, so the
javascript:URI is the one the browser actually uses.Browser confirmation. Loading the library's unmodified output in Chrome for Testing 148:
The
onerrorcase executes with no user interaction — rendering the attacker's Markdown issufficient.
Verified against git HEAD (
f966b17a) and against tag2.9.0, on PHP 8.5.8.Impact
Stored cross-site scripting in any application that renders untrusted Markdown with
AttributesExtensionenabled andattributes.allowleft at its default[]— even when theapplication has followed every hardening step in
docs/2.x/security.md(
html_input => 'escape',allow_unsafe_links => false,max_nesting_level => 100).Consequences are the usual for stored XSS: session and cookie theft, actions performed as the
viewing user, and account takeover where the host application permits it. Because the payload can
be attached to an image (
onerror), it fires on page load without requiring the victim tointeract with anything.
The affected configuration is the extension's default:
attributes.allowdefaults to[], andthe documentation describes that default as safe with respect to
on*attributes.Workaround for users
Setting an explicit allow list takes the other branch of
filterAttributes(), which drops theform-feed name because it is not in the list:
Verified:
hello {\x0Conclick="alert(1)"}then renders as<p>hello</p>.Suggested fix
The narrow fix is to add
\x0Cto the trim charlist atAttributesHelper.phplines 62, 89, 90and 94. That closes this instance but leaves the shape of the problem in place.
A more durable fix is to reject anything that is not a well-formed attribute name in
filterAttributes(), reusing the constant the parser already defines (RegexHelperis alreadyimported in that file):
As defence in depth,
HtmlElement::__toString()could validate or escape$key. It currentlytrusts its callers to supply safe attribute names, and
filterAttributes()is the only thingstanding between that method and user-supplied input.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: Denial of service in the SmartPunct and Attributes extensions
GHSA-jjv6-8j6v-6j52
More information
Details
Impact
Two first-party extensions contain quadratic parsing paths. Both ship with the library but must be explicitly registered on the
Environment; neither is included inCommonMarkConverter,GithubFlavoredMarkdownConverter, orGithubFlavoredMarkdownExtension. Applications that do not registerSmartPunctExtensionorAttributesExtensionare not affected by this advisory.1.
SmartPunctExtension— quote replacement recopies the whole text node (affected from 2.0.0).ReplaceUnpairedQuotesListenerconverts each unpairedQuotenode back to aTextnode and merges it into its neighbours viaAdjacentTextMerger. The merge reads the left node's literal into a local variable, appends to that variable, and writes it back — and because the read aliases the node's string, every append copies the entire accumulated literal rather than only the bytes added. The listener runs this once per surviving unpaired quote against the same continuously growing text node, so the same buffer is fully re-copied a linear number of times.A 1.2 MB document of alternating text segments and apostrophes takes 34.9 seconds to convert, against 0.069 seconds for the same input with the extension not registered.
Hardened configuration makes this worse rather than better:
QuoteParserappends theQuotenode to the AST before pushing it onto the delimiter stack, somax_delimiters_per_lineremoves the quote-pairing work while leaving every node the listener must process.2.
AttributesExtension— block-level attribute runs re-scan their siblings (affected from 1.5.0).AttributesListener::findTargetAndDirection()walks the entire remaining sibling chain for every block-levelAttributesnode whose target is the following node. The backward half of that walk returns immediately for such nodes, and the forward half stops only at a sibling that is not itself an attributes node — which a contiguous run never provides — so a run of k nodes costs k(k-1)/2 steps.An input placing each
{#a}on its own line, with a single reference definition to keep the run contiguous, takes 28.4 seconds at 16,000 attribute blocks while producing zero bytes of output.This is the block-level counterpart of GHSA-g2gp-3wwq-f4ph, patched in 2.9.0. That fix is incomplete: the early break it introduced is guarded on the node being an
AttributesInline, so block-levelAttributesnodes still re-scan. Applications that upgraded to 2.9.0 specifically to address GHSA-g2gp-3wwq-f4ph remain exposed to this variant.3.
AttributesExtension— class lists are rebuilt on every merge (affected from 1.5.0).AttributesHelper::mergeAttributes()round-trips the accumulated class list throughexplodeandimplodeon each merge. An#idattribute assigns a scalar and skips the branch entirely, but a.classattribute appends to an array which is then imploded to a string, written to the target node, and read back on the next iteration — so the ith merge pays a cost proportional to i three separate times.{.c}repeated 32,000 times takes 33.5 seconds, against 0.26 seconds for byte-identical input using{#a}— a 130x gap that widens with input size. Both the inline and the block-level attribute paths are affected.Overall impact. An unauthenticated attacker who can submit Markdown to an affected application can consume disproportionate CPU time with a comparatively small request, occupying PHP workers and preventing legitimate requests from completing. The impact is limited to availability: no data is disclosed, rendered output is unchanged, and no rendering restriction is bypassed.
No library-level configuration gates any of these paths. For the Attributes extension in particular, neither the
attributes/allowallow-list nor theon*event-handler hardening added in 2.7.0 has any effect, because the expensive work happens while parsing and resolving the AST, before any attribute filtering or rendering takes place.Patches
The issues are patched in
2.9.1and later:AttributesListenernow records the runs it has already walked, so each contiguous run of block-level attribute nodes is scanned once rather than once per node.mergeAttributes()repeatedly; the listener holds pending attributes and joins them in a single pass.The SmartPunct path affects
2.0.0through2.9.0. The Attributes paths affect1.5.0through2.9.0, including releases that already contain the 2.9.0 fix for GHSA-g2gp-3wwq-f4ph. The 1.x release line is no longer supported, so its users must upgrade to2.9.1or later.Workarounds
If you cannot upgrade immediately:
SmartPunctExtensionorAttributesExtensionwhen converting untrusted Markdown. This fully removes the affected paths.Restricting conversion to trusted users, applying strict execution-time limits, and rate-limiting requests reduce exposure but are not substitutes for upgrading. Configuration options including
attributes/allow,max_delimiters_per_line,max_nesting_level,html_input, andallow_unsafe_linksdo not mitigate these issues.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: Denial of service via distinctly-named attributes in the Attributes extension
GHSA-8rr7-cvq3-gmfh
More information
Details
Impact
AttributesExtensionships with the library but must be explicitly registered on theEnvironment; it is not included inCommonMarkConverter,GithubFlavoredMarkdownConverter, orGithubFlavoredMarkdownExtension. Applications that do not registerAttributesExtensionare not affected by this advisory.Two paths in the extension re-process every attribute a node has already collected each time another attribute is applied to it. When the attributes carry distinct names, the collected set grows by one on every step and is walked again in full, so a run of n attributes costs O(n²).
1. Attribute nodes resolving to a common target (affected from 1.5.0).
AttributesListener::processDocument()merges each attribute node into the set accumulated for its target, then filters the result. Both operations traverse that entire set:AttributesHelper::mergeAttributes()rebuilds it witharray_merge(), andAttributesHelper::filterAttributes()matches a regular expression against every name in it. A run of attribute nodes sharing one target therefore re-walks a set that grows by a key per node.Two input shapes reach this path: adjacent inline attributes at the start of a block (
{a0="v"}{a1="v"}…, where quoting the values is what keeps them separate — an unquoted value swallows the}{that follows it), and a chain of attribute blocks held at their default target by reference definitions ({a0=v}/[a]: u/{a1=v}/[a]: u/ …).256 KB of adjacent inline attributes takes 20.0 seconds to convert, against 0.09 seconds once patched.
2. Consecutive attribute-block lines (affected from 2.0.0).
AttributesBlockContinueParser::tryContinue()merges each continuation line into the block's accumulated attributes, again rebuilding the whole set on every line. One distinct attribute per line ({a0=v}/{a1=v}/ …) grows it by a key each time.256 KB of such lines takes 1.9 seconds to convert while producing zero bytes of output, against 0.08 seconds once patched.
Relationship to GHSA-jjv6-8j6v-6j52. The fix released in 2.9.1 for that advisory made the
classattribute cheap to accumulate, but left every other attribute name on the original path. Applications that upgraded to 2.9.1 or 2.9.2 remain exposed to this variant.Overall impact. An unauthenticated attacker who can submit Markdown to an affected application can consume disproportionate CPU time with a comparatively small request, occupying PHP workers and preventing legitimate requests from completing. The impact is limited to availability: no data is disclosed, rendered output is unchanged, and no rendering restriction is bypassed.
Patches
The issue is patched in
2.10.0. Both paths now fold each node — or each line — into the accumulated attributes at a cost proportional to that node or line alone, rather than re-merging and re-filtering everything gathered so far. Rendered output is unchanged, down to the order in which attributes appear.The listener path affects
1.5.0through2.9.2. The continuation-line path affects2.0.0through2.9.2. The 1.x release line is no longer supported, so its users must upgrade to2.10.0or later.Workarounds
If you cannot upgrade immediately:
AttributesExtensionwhen converting untrusted Markdown. This fully removes both paths.The
attributes/allowallow-list added in 2.7.0 is not a mitigation. A non-empty allow-list happens to bound the first path, because unlisted names are discarded before they accumulate, but it does nothing for the second: continuation lines are merged while parsing, before any filtering takes place.Restricting conversion to trusted users, applying strict execution-time limits, and rate-limiting requests reduce exposure but are not substitutes for upgrading.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: Denial of service via crafted code fences, reference links, and emphasis delimiters
GHSA-j8pm-gj4c-rq4x
More information
Details
Impact
Affected versions of
league/commonmarkperform super-linear work on three independent parsing paths, all of which are reachable on a stocknew CommonMarkConverter()with default configuration and no extensions registered. Each trigger fits on a single line of input, so no complex Markdown structure is required.The three paths were introduced at different times. This advisory's version range is their union; the individual ranges are:
0.6.02.9.00.6.02.9.0*,_,~)2.6.02.9.0=)2.9.02.9.01. Fenced code block detection — quadratic, affected from 0.6.0.
FencedCodeStartParsermatches the following pattern:The lookahead enforces the CommonMark rule that a backtick fence's info string may not itself contain a backtick, but neither the lookahead nor the backtick run it guards is atomic or possessive. On a line consisting of a long backtick run, filler text, and a single trailing backtick, the quantifier gives back one character at a time and re-runs the lookahead across the remainder of the line on every candidate fence length.
A 320 KB single line takes roughly 27 seconds to convert. The identical payload with one
xcharacter prefixed — which fails the parser's own leading-character guard — takes 0.011 seconds.preg_last_error()returns0at every input size tested, including runs of 160,000 characters, so PCRE never reachespcre.backtrack_limitand this is sustained CPU consumption rather than an early bail-out.2. Reference link label lookup — effectively quadratic, affected from 0.6.0.
When a shortcut or collapsed reference link is attempted,
CloseBracketParser::tryParseReference()copies the entire span between the brackets and passes it toReferenceMap::get(), which normalizes the label — up to four full passes over its length (trim,preg_replace,mb_check_encoding, andstrtolower, ormb_convert_caseon the non-ASCII path). Nested brackets produce one such lookup per closing bracket, each on a span two characters longer than the last.In 2.x the normalization sits behind an early return for an empty reference map, so a single 8-byte reference definition anywhere in the document (
[x]: y) is enough to unlock the path. At n = 64,000 nested brackets the same input takes 22.0 seconds with that line present versus 0.59 seconds without it. A single non-ASCII character inside the brackets forces themb_convert_casebranch, costing roughly 2.5x more again.3. Emphasis, strikethrough, and highlight delimiter processing — super-linear, affected from 2.6.0.
DelimiterStack::processDelimiters()remains linear only because of theopenersBottommemo, which bounds the backward opener scan — an argument that holds only if the memo's key space is O(1).EmphasisDelimiterProcessor::getCacheKey(), and the equivalents inStrikethroughDelimiterProcessorandMarkDelimiterProcessor, embed the closer's raw current run length in the key, leaving that space unbounded. An attacker spends O(n) bytes minting a growing number of distinct run lengths; each distinct length is a fresh key whose recorded bound starts at zero, forcing a full backward re-scan of the entire pile of openers.The resulting work grows as roughly n^1.5. This is sub-quadratic, but the amplification over linear growth itself scales with input size, so it worsens as inputs grow: 800 KB of ordinary asterisks, letters, and spaces costs roughly 27 seconds on a stock converter.
This path is a regression introduced in 2.6.0. Before that release the cache key was the bare delimiter character — a bounded key space that amortized correctly.
*and_are affected on any default configuration from 2.6.0 onward.~(StrikethroughExtension, included inGithubFlavoredMarkdownConverterandGithubFlavoredMarkdownExtension) is affected from 2.6.0.=(HighlightExtension) is affected only from 2.9.0, whenMarkDelimiterProcessorwas declared cacheable.Overall impact. An unauthenticated attacker who can submit Markdown for conversion can use a comparatively small request to consume disproportionate CPU time. Repeated or concurrent requests can occupy all available PHP workers and prevent legitimate requests from completing. The impact is limited to availability: no data is disclosed, rendered output is unchanged, and no rendering restriction is bypassed. Applications that process only trusted Markdown are not remotely exploitable.
Settings such as
html_input,allow_unsafe_links, andmax_nesting_leveldo not mitigate any of these, because the expensive work occurs during parsing, before rendering.max_delimiters_per_linebounds the third path only, and does so lossily — it silently discards emphasis once the cap is exhausted.Patches
The issues are patched in
2.9.1and later:[afollowed by 998 spaces andb]against a[a b]: /urldefinition — previously rendered as a link and now renders literally. This follows cmark, which applies its own label-length cap before normalizing (cmark_reference_lookup()), and matches how this library has always handled the equivalent[text][label]form viaLinkParserHelper::parseLinkLabel(). commonmark.js normalizes first and still resolves such labels.min(length, 2)for emphasis,min(length, 3)for strikethrough and highlight — restoring a bounded key space while preserving byte-identical output.Versions from
0.6.0through2.9.0are affected by at least one of these paths; see the table above for which paths apply to which releases. The 0.x and 1.x release lines are no longer supported, so their users must upgrade to2.9.1or later.Workarounds
If you cannot upgrade immediately, enforce a maximum length for individual lines before passing input to the converter, in addition to a total request-size limit. A per-line limit matters because every trigger described above fits within a single line. Because the cost grows super-linearly, the cap must be genuinely small to bound worst-case CPU.
Setting
max_delimiters_per_linereduces exposure to the delimiter path only, and does so by silently dropping emphasis from the rendered output. It has no effect on the fenced code or reference link paths.Restricting conversion to trusted users, applying strict execution-time limits, rate-limiting requests, and limiting concurrent conversions all reduce exposure, but none is a complete substitute for upgrading.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: Quadratic-time denial of service in the GitHub Flavored Markdown Table extension block-start scan
GHSA-3q6v-r5mr-hxv8
More information
Details
Summary
league/commonmark's GitHub Flavored Markdown Table extension registersTableStartParseras a block-start parser. While a paragraph is the active block, the core block parser callsTableStartParser::tryStart()on every non-blank line. Its first action fetches the entire growing paragraph buffer viagetParagraphContent()and runsstrpos($paragraph, '|')across all of it. For a paragraph of M pipe-free lines, line k rescans about k lines of buffer, so total work is 1+2+...+M, which is O(M^2). An unauthenticated user who submits a single large paragraph of pipe-free lines that do not begin with a letter (see Attack Chain) to any service that converts untrusted Markdown withGithubFlavoredMarkdownConverter(or any environment that enablesTableExtension) drives seconds to tens of seconds of single-core CPU that grows quadratically with body size, enough to exhaust worker processes and deny service.Root Cause
The GFM table detector performs a per-line "quick check" against the whole accumulated paragraph rather than only the portion that could form a table header. A paragraph never closes while non-blank lines keep arriving, so its buffer grows without bound, and the quick check rescans the entire buffer on each line. Only the paragraph's last line can ever be a table header (it is extracted separately via
strrpos/substr), so scanning the full multi-line buffer for a pipe on every line is unnecessary work and creates quadratic time complexity. There is no input-size cap and the default nesting limit is not reached, so nothing bounds the scan.Impact
An unauthenticated, remote attacker with no user interaction can cause denial of service against any application that renders untrusted Markdown with the GitHub Flavored Markdown converter or any configuration that enables the Table extension (the default GFM bundle enables it). A single request carrying a large paragraph of pipe-free, blank-line-free lines that do not begin with a letter consumes CPU proportional to the square of the input size: measured runs show roughly 4x CPU growth per input doubling. A body of a few megabytes ties up a worker for seconds to tens of seconds; repeated or concurrent requests exhaust all available PHP worker processes, denying service to legitimate users. Impact is limited to availability; there is no confidentiality or integrity impact.
Proof of Concept
Requires PHP with league/commonmark 2.x installed. The harness parses pipe-free paragraphs of increasing size with the real MarkdownParser and the real block-start parsers, comparing a GFM-plus-Table configuration against a core-only configuration to isolate the Table parser's contribution.league/commonmark(2.10.1 or any 2.x release).GithubFlavoredMarkdownConverter.Letter-leading input scales linearly (doubles per input doubling) because
SkipLinesStartingWithLettersParseraborts before the Table parser is reached. Non-letter-leading input quadruples per doubling (0.75, 2.36, 7.70, 27.81), confirming O(M^2) behavior attributable to the Table start parser.Attack Chain
GithubFlavoredMarkdownConverter, or any environment that addsTableExtension. The default GFM bundle registers the Table extension:GithubFlavoredMarkdownExtensionaddsTableExtension(src/Extension/GithubFlavoredMarkdownExtension.php:30), which registersTableStartParser(src/Extension/Table/TableExtension.php:56).|character, and lines that do not begin with a letter (for examplestr_repeat("12345678\n", 200000)).MarkdownParser::parseLine()loops over block-start parsers for every non-blank line while a paragraph is active (src/Parser/MarkdownParser.php:135-147).ParagraphParser::tryContinue()keeps the paragraph open for every non-blank line, so the buffer keeps growing (src/Parser/Block/ParagraphParser.php:46-53), appended line by line inReferenceParser::parse()(src/Reference/ReferenceParser.php:89-95). Since 2.0.2, the highest-prioritySkipLinesStartingWithLettersParserreturnsBlockStart::abort()for any line whose first non-space character is a letter, short-circuitingfindBlockStart()beforeTableStartParser. When lines begin with a non-letter (e.g. a digit), that parser returns none and every remaining core parser returns null, so control reachesTableStartParseron every line (src/Parser/MarkdownParser.php:224-236).MarkdownInput, andmax_nesting_leveldefaults toPHP_INT_MAX, which the depth-1 paragraph never approaches. No guard bounds the whole-buffer scan.TableStartParser::tryStart()runsstrpos($paragraph, '|')over the entire buffer returned bygetParagraphContent()(src/Extension/Table/TableStartParser.php:35-38). With no pipe present,strposscans to the end of the buffer on every line.Bypass Evidence
No prior fix exists for this code path, so this is not an incomplete-fix or regression case; it is a distinct, previously undisclosed quadratic path. The strongest disproof attempts were made and all failed:
TableStartParser; the core-only baseline is linear while the isolated Table contribution quadruples per input doubling.strposshort-circuits or the buffer does not actually grow. Refuted by source trace:ReferenceParser::parse()appends every line unconditionally before its state switch, and for pipe-free textstrposfinds no|and scans the full buffer each line.TableStartParser. Partially correct: since 2.0.2SkipLinesStartingWithLettersParseraborts block-start scanning for lines beginning with a letter, so letter-leading input (including the originalaaaaaaaaPoC) never reaches the Table parser and runs in linear time. Input whose lines begin with a non-letter (e.g. a digit) passes every core parser and reachesTableStartParseron every line, exhibiting the quadratic behavior.git logonsrc/Extension/Table/TableStartParser.phpshows no security fix ever touched this rescan, and the code is present unchanged in the latest release.Affected Versions
Ecosystem: composerPackage: league/commonmarkConfirmed affected range: >= 2.0.0, <= 2.10.1Latest release checked: 2.10.1 (Packagist, repo.packagist.org/p2/league/commonmark.json)Fix status: not fixedNote:
SkipLinesStartingWithLettersParserwas introduced in 2.0.2. On 2.0.0 and 2.0.1 the issue can also be triggered with letter-leading lines. Regardless, the underlying quadratic scan itself is present across the whole >= 2.0.0, <= 2.10.1 range.The vulnerable rescan is artifact-verified present in tags 2.8.0 and 2.10.1 (the latest published release). The lower bound 2.0.0 is inferred from the 2.x block-parser rewrite that introduced the
getParagraphContent()-based architecture this quick check depends on; the checkout contains only tags 2.8.0 through 2.10.1, so the path is not artifact-verified below 2.8.0. No published release removes or bounds the whole-buffer scan.Suggested Fix
Enforce that the per-line quick check inspects only the paragraph's last line, which is the only line that can form a table header. Compute the last line break with
strrpos($paragraph, "\n"), derive the last line, and teststrpos($lastLine, '|')instead of scanning the whole buffer. This bounds the per-line check to the length of the last line and removes the quadratic behavior without changing table detection semantics. As an interim mitigation without a code change, operators can cap the size of untrusted Markdown accepted for conversion, or disable the Table extension for untrusted input, which reduces exposure but does not remove the underlying quadratic path.Reported by zx (GitHub: @manus-pi).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: DisallowedRawHtml bypassed when a disallowed tag name ends the raw-HTML literal
GHSA-97jj-33gv-5xf9
More information
Details
## Summary
The
DisallowedRawHtmlextension does not escape a disallowed tag when the tag name is the last thing in the raw HTML. A Markdown line containing just<scriptis emitted unchanged, and the next block can supply its attributes. With the shipped GFM defaults this allows stored XSS by anyone who can post Markdown.Details
DisallowedRawHtmlRendererescapes tags with this regex:The trailing character class requires one character after the tag name. The block parser does not:
RegexHelper::PARTIAL_HTMLBLOCKOPENaccepts end of line after a tag name, so<scriptalone opens an HTML block. Because a renderedHtmlBlockhas no trailing newline, the regex has nothing to match and the<passes through.In the browser the newline is still present, so the tag name terminates there and whatever follows becomes attributes.
This is the same filter that GHSA-4v6x-c7xx-hw9f fixed in 2.8.1. That fix widened the character class but still requires one character, so this case was not covered.
Reproduction
Render this with
GithubFlavoredMarkdownConverterand default settings:Output:
A browser parses that as
<script src="/evil.js">with a junk<spanattribute, and the script runs.<iframewith<span onload="...">works the same way and does not need a later</script>in the page.Control:
<script src="/evil.js"></script>is correctly escaped to<script src="/evil.js"></script>.Affected versions
1.3.0 (when the extension was added) through the current release. The
</styleand mid-line forms are only affected as continuation lines inside an already-open HTML block.Preconditions
html_inputisallow(the default)DisallowedRawHtmlextension is active, which the GFM extension enables automaticallySetting
html_inputtoescapeorstripfully mitigates this.Suggested fix
Allow end of string after the tag name:
This escapes every bypass shape above and leaves
<div>,<scripts>and<span class="a">untouched. The existing unit test only covers tag names followed by another character, so a case for a bare tag name should be added.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
league/commonmark XSS:
on*event-handler filter inAttributesExtensionbypassed with a U+000C form feedCVE-2026-86431 / GHSA-f8fg-pg57-v4j8
More information
Details
Summary
The
AttributesExtensiondocuments a security guarantee:Prefixing the attribute name with a single U+000C FORM FEED byte defeats that guarantee.
{<FF>onclick="alert(1)"}passes throughAttributesHelper::filterAttributes()untouched and iswritten verbatim into the output, where browsers parse it as a genuine
onclickhandler.The same prefix defeats the
allow_unsafe_linkscheck, letting ajavascript:URI through onhref/srceven whenallow_unsafe_linksisfalse.This bypasses the fix shipped in the 2.7.0 security release ("Fix XSS in AttributesExtension",
43207253ea5f14867c77c697cd3838c446cadcea), which added
filterAttributes()for the expresspurpose of blocking these attributes.
Throughout this report
<FF>denotes a literal U+000C byte ("\x0C"in PHP). It is invisible inrendered text, so all payloads below are written with PHP escape sequences to stay unambiguous.
Details
Three behaviours combine.
1.
\x0Csurvives the parser'strim().AttributesHelper::SINGLE_ATTRIBUTEbegins with\s*, andCursor::match()returns$matches[0][0]— the entire match, including that leading whitespace. The result is cleanedwith PHP's
trim():PCRE
\smatches\x0C, but PHP's defaulttrim()charlist is" \t\n\r\0\x0B"— it includesthe vertical tab
\x0Bbut not the form feed\x0C. The byte is therefore consumed by theregex, retained in the returned match, and not stripped. It ends up inside the attribute name:
\x0Cis the only byte with this property: every other character the HTML5 tokenizer treats aswhitespace (
\x09,\x0A,\x0D,\x20), plus\x0B, is in PHP's trim charlist. The PoCincludes a
\x0Bcase as a control, and it is correctly stripped.2. The filter's string comparisons miss it.
filterAttributes()compares the raw name against literal strings:3. The renderer never escapes attribute names.
Because the HTML5 tokenizer treats
\x0Cas whitespace between attributes, the browser readsthe name as plain
onclick.PoC
Full observed output (
\x0Cshown escaped; it is a literal single byte in the real output):hello {onclick="alert(1)"}<p>hello</p>hello {\x0Conclick="alert(1)"}<p \x0Conclick="alert(1)">hello</p>hello {\x0Bonclick="alert(1)"}<p>hello</p>[click](javascript:alert(1))<p><a>click</a></p>[click](https://example.com){\x0Chref="javascript:alert(1)"}<p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p>{\x0Conerror="alert(1)"}<p><img \x0Conerror="alert(1)" src="…" alt="x" /></p># heading+ newline +{\x0Conclick="alert(1)"}<h1 \x0Conclick="alert(1)">heading</h1>In case E the injected
hrefprecedes the legitimate one. Per the HTML5 duplicate-attribute rulethe first occurrence wins, so the
javascript:URI is the one the browser actually uses.Browser confirmation. Loading the library's unmodified output in Chrome for Testing 148:
The
onerrorcase executes with no user interaction — rendering the attacker's Markdown issufficient.
Verified against git HEAD (
f966b17a) and against tag2.9.0, on PHP 8.5.8.Impact
Stored cross-site scripting in any application that renders untrusted Markdown with
AttributesExtensionenabled andattributes.allowleft at its default[]— even when theapplication has followed every hardening step in
docs/2.x/security.md(
html_input => 'escape',allow_unsafe_links => false,max_nesting_level => 100).Consequences are the usual for stored XSS: session and cookie theft, actions performed as the
viewing user, and account takeover where the host application permits it. Because the payload can
be attached to an image (
onerror), it fires on page load without requiring the victim tointeract with anything.
The affected configuration is the extension's default:
attributes.allowdefaults to[], andthe documentation describes that default as safe with respect to
on*attributes.Workaround for users
Setting an explicit allow list takes the other branch of
filterAttributes(), which drops theform-feed name because it is not in the list:
Verified:
hello {\x0Conclick="alert(1)"}then renders as<p>hello</p>.Suggested fix
The narrow fix is to add
\x0Cto the trim charlist atAttributesHelper.phplines 62, 89, 90and 94. That closes this instance but leaves the shape of the problem in place.
A more durable fix is to reject anything that is not a well-formed attribute name in
filterAttributes(), reusing the constant the parser already defines (RegexHelperis alreadyimported in that file):
As defence in depth,
HtmlElement::__toString()could validate or escape$key. It currentlytrusts its callers to supply safe attribute names, and
filterAttributes()is the only thingstanding between that method and user-supplied input.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
league/commonmark: Denial of service via crafted code fences, reference links, and emphasis delimiters
CVE-2026-86430 / GHSA-j8pm-gj4c-rq4x
More information
Details
Impact
Affected versions of
league/commonmarkperform super-linear work on three independent parsing paths, all of which are reachable on a stocknew CommonMarkConverter()with default configuration and no extensions registered. Each trigger fits on a single line of input, so no complex Markdown structure is required.The three paths were introduced at different times. This advisory's version range is their union; the individual ranges are:
0.6.02.9.00.6.02.9.0*,_,~)2.6.02.9.0=)2.9.02.9.01. Fenced code block detection — quadratic, affected from 0.6.0.
FencedCodeStartParsermatches th