Summary
league/commonmark: AttributesExtension href/src unsafe-link filter bypass via embedded control bytes
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, becauseparseAttributes()alreadytrim()s the value; other C0 bytes are not trimmed.)
The filter is a literal anchored-prefix regex (RegexHelper::isLinkPotentiallyUnsafe() / REGEX_UNSAFE_PROTOCOL) that matches neither obfuscated form, so in both cases the browser still executes javascript:alert(1).
This is confirmed reproducible even with allow_unsafe_links => 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 allow_unsafe_links, but did not normalize control bytes before checking, so these obfuscation techniques were never covered.
Vulnerability
Files:
src/Util/RegexHelper.php:69(REGEX_UNSAFE_PROTOCOL),: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
REGEX_UNSAFE_PROTOCOLis 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
// src/Util/RegexHelper.php
public const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
public static function isLinkPotentiallyUnsafe(string $url): bool
{
return \preg_match(self::REGEX_UNSAFE_PROTOCOL, $url) !== 0 && \preg_match(self::REGEX_SAFE_DATA_PROTOCOL, $url) === 0;
}
// src/Extension/Attributes/Util/AttributesHelper.php
foreach ($attributes as $name => $value) {
$attrNameLower = \strtolower($name);
if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) {
unset($attributes[$name]);
continue;
}
...
The Attributes extension's own quote-value grammar (PARTIAL_DOUBLEQUOTEDVALUE = '"[^"]*"') 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
- An application enables the (commonly-used)
AttributesExtensionand setsallow_unsafe_links => false, the project's own documented hardening step for untrusted input. - An attacker submits Markdown:
[Click me](javascript:alert(0)){href="java<TAB>script:alert(document.cookie)"}(TAB is one literal 0x09 byte). - 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. - 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](javascript:alert(0)) rather than a normal link. LinkRenderer overwrites attrs['href'] with the node's own URL unless that URL is itself judged unsafe, so [x](https://example.com){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)">, buthrefon 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, sinceImageRendererunconditionally overwritessrcfrom the core URL regardless of the safety verdict.
Affected Versions
>= 1.5.0, <= 2.8.3 - every release that ships the AttributesExtension. Verified by installing each version and rendering the payloads with allow_unsafe_links => false. The attribute-value grammar (PARTIAL_DOUBLEQUOTEDVALUE = '"[^"]*"') 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 allow_unsafe_links 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.
Impact
Untrusted input is rendered as active markup in a victim's browser, which can run script in their session. Typical impact: session or credential theft, and actions taken as the user.
CVE-2026-71478 has a CVSS score of 6.1 (Medium). The vector is network-reachable, no privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.9.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
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.
Frequently Asked Questions
- What is CVE-2026-71478? CVE-2026-71478 is a medium-severity cross-site scripting (XSS) vulnerability in league/commonmark (composer), affecting versions >= 1.5.0, <= 2.8.3. It is fixed in 2.9.0. Untrusted input is rendered as active markup in a victim's browser, which can run script in their session.
- How severe is CVE-2026-71478? CVE-2026-71478 has a CVSS score of 6.1 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of league/commonmark are affected by CVE-2026-71478? league/commonmark (composer) versions >= 1.5.0, <= 2.8.3 is affected.
- Is there a fix for CVE-2026-71478? Yes. CVE-2026-71478 is fixed in 2.9.0. Upgrade to this version or later.
- Is CVE-2026-71478 exploitable, and should I be worried? Whether CVE-2026-71478 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-71478 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-71478? Upgrade
league/commonmarkto 2.9.0 or later.