Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘
🐘 Packagist
Not in CISA KEV
MEDIUM severity

CVE-2026-71478 league/commonmark

MEDIUMFix: thephpleague/commonmark@493a5aa

CVE-2026-71478 is a medium-severity (CVSS 6.1) Cross-site Scripting (XSS) vulnerability in league/commonmark. A fix is available for league/commonmark — see the affected versions and patch details below.

league/commonmark: AttributesExtension href/src unsafe-link filter bypass via embedded control bytes

Also known asGHSA-29pj-957v-52mc
Published
Aug 6, 2026
Updated
Sep 12, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Sep 21, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-71478.

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs16th percentile — riskier than 16% of all scored CVEsHighest risk

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

CVE-2026-71478 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 377,333 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected
🐘league/commonmark

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

Description

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

// 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 (LinkParserHelperUrlEncoder::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.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistleague/commonmark1.5.0&&< 2.9.02.9.0composer require league/commonmark:^2.9.0

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for league/commonmark, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update league/commonmark to 2.9.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-71478 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like CVE-2026-71478 can be triaged on real exposure rather than presence alone.

Tailored to CVE-2026-71478. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## 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 lead
O3 Security · Impact-Aware SCA

Is CVE-2026-71478 in your dependencies?

O3 Security finds CVE-2026-71478 across Packagist dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.

CVE-2026-71478: XSS (Medium 6.1) | O3 Security