league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed
Summary
The AttributesExtension documents a security guarantee:
Note: Attributes starting with
on(e.g.onclickoronerror) are capable of executing JavaScript code and are therefore never allowed by default. You must explicitly add them to theallowlist 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.
{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 `` 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