CVE-2026-71478: league/commonmark AttributesExtension Href/src Unsafe-link Filter Bypass Vulnerability

MODERATE
Advisory Identifier: GHSA-29pj-957v-52mc
CVSS 6.1
CWE-79 CWE-86 CWE-692
Published
Advisory Summary

CVE-2026-71478 is a MODERATE (CVSS 6.1) attributesextension href/src unsafe-link filter bypass vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting league/commonmark libraries in the Packagist ecosystem. The issue involves attributesextension href/src unsafe-link filter bypass via embedded control bytes. Remediation is available by upgrading affected packages to version 2.9.0.

CVE-2026-71478 at a Glance

CVE Identifier CVE-2026-71478
GHSA Identifier GHSA-29pj-957v-52mc
Severity MODERATE
CVSS v3.1 Base Score 6.1
CWE Classification CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Affected Product league/commonmark
Package Ecosystem Packagist
Published Date
Last Updated

Security Impact

CVSS: 6.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality Low (C:L)
Integrity Low (I:L)
Availability None (A:N)

How to Fix CVE-2026-71478

To remediate this vulnerability, upgrade all affected league/commonmark dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

league/commonmark fixed: 2.9.0
Upgrade league/commonmark to 2.9.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for league/commonmark

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-71478, inspect your project dependencies for league/commonmark:

inspect dependencies for league/commonmark

Analyze the terminal output to locate any version of league/commonmark in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.9.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), where the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. In league/commonmark, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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.

CVE-2026-71478 Affected league/commonmark Versions

1 component(s)
Fixed Versions:
2.9.0
Product / Package Ecosystem Affected Versions Fixed Version
league/commonmark
Packagist
≥ 1.5.0
< 2.9.0
2.9.0

References

Frequently Asked Questions

What is CVE-2026-71478?
CVE-2026-71478 is a security vulnerability in league/commonmark involving AttributesExtension href/src unsafe-link filter bypass via embedded control bytes under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
Which league/commonmark versions are affected?
Affected versions include releases prior to 2.9.0 across affected packages: league/commonmark.
What version fixes CVE-2026-71478?
Upgrading to 2.9.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is league/commonmark affected?
Yes, league/commonmark is affected in releases prior to 2.9.0. Upgrade your dependency to 2.9.0 or later.
What is the CVSS score of CVE-2026-71478?
CVE-2026-71478 has a CVSS v3.1 base score of 6.1 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N.
What CWE is associated with CVE-2026-71478?
CVE-2026-71478 is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.).
How can I check Packagist dependencies for CVE-2026-71478?
Run the dependency tree command: 'inspect dependencies for league/commonmark'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-29pj-957v-52mc",
  "modified" : "2026-08-06T20:30:40Z",
  "published" : "2026-08-06T20:30:39Z",
  "aliases" : [ "CVE-2026-71478" ],
  "summary" : "league/commonmark: AttributesExtension href/src unsafe-link filter bypass via embedded control bytes",
  "details" : "## Summary\n\nThe `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:\n\n- **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\".\n- **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.)\n\nThe 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)`.\n\n**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.\n\nThis 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.\n\n## Vulnerability\n\n**Files**:\n- `src/Util/RegexHelper.php:69` (`REGEX_UNSAFE_PROTOCOL`), `:239-242` (`isLinkPotentiallyUnsafe()`)\n- `src/Extension/Attributes/Util/AttributesHelper.php:149-179` (`filterAttributes()`)\n\n**CWE**: CWE-79 (Improper Neutralization of Input During Web Page Generation / XSS) — primary\n- 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.\n- 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.\n\n### Root Cause\n```php\n// src/Util/RegexHelper.php\npublic const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';\n\npublic static function isLinkPotentiallyUnsafe(string $url): bool\n{\n    return \\preg_match(self::REGEX_UNSAFE_PROTOCOL, $url) !== 0 && \\preg_match(self::REGEX_SAFE_DATA_PROTOCOL, $url) === 0;\n}\n\n// src/Extension/Attributes/Util/AttributesHelper.php\nforeach ($attributes as $name => $value) {\n    $attrNameLower = \\strtolower($name);\n    if (! $allowUnsafeLinks && ($attrNameLower === 'href' || $attrNameLower === 'src') && \\is_string($value) && RegexHelper::isLinkPotentiallyUnsafe($value)) {\n        unset($attributes[$name]);\n        continue;\n    }\n    ...\n```\nThe 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).\n\n### Attack Scenario\n1. An application enables the (commonly-used) `AttributesExtension` and sets `allow_unsafe_links => false` — the project's own documented hardening step for untrusted input.\n2. An attacker submits Markdown: `[Click me](javascript:alert(0)){href=\"java<TAB>script:alert(document.cookie)\"}` (TAB is one literal 0x09 byte).\n3. 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.\n4. 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.\n\n**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.\n\nTwo related forms that are **not** exploitable, noted so the fix isn't over-scoped:\n\n- 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.)\n- `<img src>` is unaffected, since `ImageRenderer` unconditionally overwrites `src` from the core URL regardless of the safety verdict.\n\n### Recommended Fix\n\nNormalize 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.\n\n## Affected Versions\n\n**`>= 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.\n\n## Prior Related Advisories\n\nGHSA-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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Packagist",
      "name" : "league/commonmark"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "1.5.0"
      }, {
        "fixed" : "2.9.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.8.3"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/security/advisories/GHSA-29pj-957v-52mc"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/commit/493a5aa7d65754b73846006eaff9c2c4431a8e2c"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/thephpleague/commonmark"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/releases/tag/2.9.0"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-692", "CWE-79", "CWE-86" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-08-06T20:30:39Z",
    "nvd_published_at" : null
  }
}