CVE-2026-71478: XSS
Summary
The AttributesExtension's href/src unsafe-link filter (AttributesHelper::filterAttributes()) can be bypassed by embedding control bytes in a javascript: URL that browsers discard before parsing the scheme. Two variants:
- Tab/newline inside the scheme — a literal ASCII TAB (0x09), CR (0x0D), or LF (0x0A), e.g. java<TAB>script:alert(1). Per the WHATWG URL Standard's "basic URL parser" step 3, browsers "remove all ASCII tab or newline from input". - Leading C0 controls — e.g. <0x01>javascript:alert(1). Per step 1 of the same algorithm, browsers remove any leading or trailing C0 control or space. (A leading space alone does not bypass, because parseAttributes() already trim()s the value; other C0 bytes are not trimmed.)
The filter is a literal anchored-prefix regex (RegexHelper::isLinkPotentiallyUnsafe() / REGEXUNSAFEPROTOCOL) that matches neither obfuscated form, so in both cases the browser still executes javascript:alert(1).
This is confirmed reproducible even with allowunsafelinks => false set — i.e. even applications that have followed the library's own documented hardening guidance for untrusted input remain exploitable.
This is a sibling gap in the same defense that CVE-2025-46734 (GHSA-3527-qv2q-pfvx) fixed in v2.7.0 — that fix made href/src respect allowunsafelinks, but did not normalize control bytes before checking, so these obfuscation techniques were never covered.
Vulnerability
Files: - src/Util/RegexHelper.php:69 (REGEXUNSAFEPROTOCOL), :239-242 (isLinkPotentiallyUnsafe()) - src/Extension/Attributes/Util/AttributesHelper.php:149-179 (filterAttributes())
CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation / XSS) — primary - CWE-692 (Incomplete Denylist to Cross-Site Scripting) — the anchored-prefix denylist in REGEXUNSAFEPROTOCOL is incomplete. This is a composite of CWE-184 and CWE-79, so it captures the full "incomplete denylist → XSS" chain on its own. - CWE-86 (Improper Neutralization of Invalid Characters in Identifiers in Web Pages) — the specific evasion technique: control bytes embedded within the URI scheme identifier, which the browser strips before resolving it.
Root Cause php // src/Util/RegexHelper.php public const REGEXUNSAFEPROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
public static function isLinkPotentiallyUnsafe(string $url): bool { return \pregmatch(self::REGEXUNSAFEPROTOCOL, $url) !== 0 && \pregmatch(self::REGEXSAFEDATAPROTOCOL, $url) === 0; }
// src/Extension/Attributes/Util/AttributesHelper.php foreach ($attributes as $name => $value) { $attrNameLower = \strtolower($name); if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \isstring($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) { unset($attributes[$name]); continue; } ... The Attributes extension's own quote-value grammar (PARTIALDOUBLEQUOTEDVALUE = '"[^"]"') accepts any byte except " inside quotes, including raw tab/CR/LF and other C0 controls, and parseAttributes() only trim()s (leading/trailing, and only the default charlist " \t\n\r\0\x0B" — so a leading \x01 survives). Critically, the core Markdown link-destination path (LinkParserHelper → UrlEncoder::unescapeAndEncode()) percent-encodes every control byte before this same safety check ever runs — but the Attributes extension's href/src handling has no equivalent normalization step, so the raw control byte reaches both the check and the final HTML output (Xml::escape() only escapes & < > " ', not tab/CR/LF, since they're legal bytes inside an HTML attribute).
Attack Scenario 1. An application enables the (commonly-used) AttributesExtension and sets allowunsafelinks => false — the project's own documented hardening step for untrusted input. 2. An attacker submits Markdown: Click me){href="java<TAB>script:alert(document.cookie)"} (TAB is one literal 0x09 byte). 3. The library emits <a href="java<TAB>script:alert(document.cookie)">Click me</a> — isLinkPotentiallyUnsafe() doesn't match the tab-split scheme, so the filter takes no action. 4. A victim viewing/clicking the link has the browser strip the embedded TAB and execute javascript:alert(document.cookie) in the victim's session — stored XSS, cookie theft, account takeover potential.
Why the payload needs an unsafe core destination. Step 2 above deliberately uses Click me) rather than a normal link. LinkRenderer overwrites attrs['href'] with the node's own URL unless that URL is itself judged unsafe — so x{href="java<TAB>script:..."} renders the harmless href="https://example.com", and an empty destination x{href="..."} renders href="". The attacker therefore supplies a core destination that the filter does catch, which suppresses the overwrite and lets the attribute-supplied href reach the final tag. This is no obstacle in practice — the attacker writes the entire Markdown document.
Two related forms that are not exploitable, noted so the fix isn't over-scoped:
- Attaching the attribute to a non-link block — hi {href="java<TAB>script:alert(1)"} — does bypass the filter and emits <p href="java<TAB>script:alert(1)">, but href on a <p> is inert: there is nothing to navigate. (An earlier draft of this report described this as a "simpler, unconditional variant" of the attack; it is a filter bypass, not an XSS.) - <img src> is unaffected, since ImageRenderer unconditionally overwrites src from the core URL regardless of the safety verdict.
Recommended Fix
Normalize inside RegexHelper::isLinkPotentiallyUnsafe() before testing, mirroring the WHATWG URL parser's own normalization. This covers both variants, fixes every call site at once (LinkRenderer, ImageRenderer, and any third-party callers), and needs no changes in the Attributes extension.
Affected Versions
>= 1.5.0, <= 2.8.3 - every release that ships the AttributesExtension. Verified by installing each version and rendering the payloads with allowunsafelinks => false. The attribute-value grammar (PARTIALDOUBLEQUOTEDVALUE = '"[^"]"') has accepted raw control bytes since the extension was introduced, and none of the intervening parser rewrites narrowed it.
Prior Related Advisories
GHSA-3527-qv2q-pfvx / CVE-2025-46734 fixed a different Attributes-extension XSS (unallowlisted on handlers, href/src not respecting allowunsafelinks at all) in v2.7.0. This issue bypasses the specific href/src protection that fix introduced (the control-byte normalization gap was not part of that fix) - but the obfuscated inputs also work on older versions.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/league/commonmarkto a version that resolves this vulnerability.Fixed in 2.9.0 - Upgrade
Upgrade
v2.7.0to a version that resolves this vulnerability.Patch GHSA-3527-qv2q-pfvx - Configuration
Before applying REGEX_UNSAFE_PROTOCOL in isLinkPotentiallyUnsafe(), normalize the input by removing leading/trailing C0 control/space and removing all ASCII tab/newline characters (0x09, 0x0A, 0x0D) from the URI string. This closes the control-byte obfuscation bypass where the browser strips controls before resolving javascript:.
RegexHelper::isLinkPotentiallyUnsafe() / REGEX_UNSAFE_PROTOCOL Normalize input (strip/normalize leading/trailing C0 controls and ASCII tab/newline) before testing = Per WHATWG basic URL parser: remove ASCII tab/newline before applying REGEX_UNSAFE_PROTOCOL anchored-prefix denylist