CVE-2026-71478
mediumpriority · track · 0EPSS 0.004 · 29 %ileEUUSEPSS · scored 2026-10-27
league/commonmark is a PHP library for parsing and rendering CommonMark Markdown. From 1.5.0 until 2.9.0, the AttributesExtension's href and src unsafe-link filter can be bypassed by embedding control bytes, such as a tab, carriage return, line feed, or leading C0 control character, in a javascript: URL that browsers discard before parsing the scheme, causing the browser to still execute the script even when the unsafe-link filter is enabled. This issue is fixed in 2.9.0.
cvss
Technical severity, 0–10. One row per scoring source.
base score 0–10 · higher = more severe · hover a vector to decode
ssvc
A stakeholder decision — act, attend, or track — from exploitation and impact.
weaknesses
The CWE weakness classes this flaw belongs to.
affected products (2)
Products and version ranges known to be affected.
advisories (1)
Vendor and ecosystem advisories describing this.
- GHSA-29pj-957v-52mcleague/commonmark: AttributesExtension href/src unsafe-link filter bypass via embedded control bytes2026-08-06ghsa · osvUS
## 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()` / `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_PROTOCOL` 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 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 1. An application enables the (commonly-used) `AttributesExtension` and sets `allow_unsafe_links => false` — the project's own documented hardening step for untrusted input. 2. An attacker submits Markdown: `[Click me](javascript:alert(0)){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](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)">`, 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 `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.
also known as (3)
Other names and identifiers for this entity.
sources
Which sources assert this record, and when.
references (10)
External citations — patches, advisories, analysis.
- https://github.com/thephpleague/commonmark/commit/493a5aa7d65754b73846006eaff9c2c4431a8e2ccnnvd
- https://github.com/thephpleague/commonmark/releasesPatchcnnvd
- https://github.com/thephpleague/commonmark/releases/tag/2.9.0cnnvd
- https://github.com/thephpleague/commonmark/security/advisories/GHSA-29pj-957v-52mccnnvd
- https://github.com/thephpleague/commonmark/commit/493a5aa7d65754b73846006eaff9c2c4431a8e2cenisa_euvd
- https://github.com/thephpleague/commonmark/releases/tag/2.9.0enisa_euvd
- https://github.com/thephpleague/commonmark/security/advisories/GHSA-29pj-957v-52mcenisa_euvd
- https://github.com/thephpleague/commonmark/commit/493a5aa7d65754b73846006eaff9c2c4431a8e2cnvd
Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.
Free scan