GHSA-29pj-957v-52mc league/commonmark: AttributesExtension href/src unsafe-link filter bypass via embedded control bytes
﻿## SummaryThe `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) — primaryCWE-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 CauseThe 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 ScenarioAn application enables the (commonly-used) `AttributesExtension` and sets `allow_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)">`, 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 FixNormalize 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 AdvisoriesGHSA-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.
CVSS v3.1 score: 6.1