Research/drupal-htmx-attr
0-dayNo CVEHighPublic

Drupal, AttributeXss still allows HTMX event attributes

Drupal 11.4.8 Xss::attributes() denylists hx / data-hx / srcdoc. AttributeXss does not. A Link field can store hx-on-click. Standard plus BigPipe loads core/htmx. Stored XSS. No CVE yet.

Name
Drupal, AttributeXss still allows HTMX event attributes
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Drupal
Affected
Drupal core 11.4.8 (61044508). Authenticated user who can write a Link field or menu link. Victim views the page and clicks. Incomplete SA-CORE-2026-011.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, drupal, xss, htmx, cwe-79, authenticated

Xss.php got the skip list. AttributeXss did not

I am @abraxas_null. The proof of concept is on GitHub: abraxas/drupal-htmx-attr (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is Drupal core 11.4.8 (61044508071749629aaf3380aa85971e94df9e84). No CVE yet. CWE-79. 8.7 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). Remote stored XSS. Writer: a content editor who can set a Link field or a menu link. Victim: anyone whose page loads HTMX, including a site admin who clicks.

SA-CORE-2026-011 closed HTMX attrs in Xss::attributes(). hx, data-hx, srcdoc. Link and menu attributes do not go through Xss::filter. They go through AttributeXss::sanitizeAttributes. That skip list still only drops style and on*. Standard plus BigPipe loads core/htmx. When the viewer clicks, HTMX binds hx-on-* and runs the stored JS in the site origin. Session cookies and admin UI actions follow. This is stored XSS, not PHP RCE.

Why Drupal

I had a prior 11.4.7 pack. 11.4.8 is the pin after SA-CORE-2026-004 through 013. The interesting work on a patched CMS is incomplete siblings: the function they remembered versus the function they did not. HTMX was the 011 story. Xss.php was the patch. AttributeXss is the other copy of the skip list.

I only wanted Critical or High. Medium stays on the floor unless it chains. Admin Composer RCE is expected. Unauthenticated RCE, SQLi, and LPE on this pin were NONE.

Drupal's own intake is the Drupal security team via Report a security vulnerability on drupal.org, or security@drupal.org. Hall-of-fame credit only. There is no paid core bounty.

Pinning 11.4.8

GitHub is a mirror of git.drupal.org. Tag 11.4.8. Commit 61044508071749629aaf3380aa85971e94df9e84. Drupal::VERSION = '11.4.8'. Latest core on the day I cloned. 11.4.7 already had the SA wave. 11.4.8 is the re-verify plus leftover pass.

The skip list is half the hunt

Before you look for leftovers, you write down the SAs so you do not re-file them.

  • SA-CORE-2026-004 PostgreSQL Entity Query IN keys. array_values().
  • SA-CORE-2026-005 JSON:API PHP object injection. relationship-meta + checkForSerializedStrings.
  • SA-CORE-2026-006 gadget chain. Kernel tests present.
  • SA-CORE-2026-007 rebuild.php Host.
  • SA-CORE-2026-008 oEmbed discovery document host. First hop gated. Second hop is a sibling leftover, not this post.
  • SA-CORE-2026-009 JSON:API/REST image MIME. Those two paths only. CKEditor/Media Library is a sibling leftover, not this post.
  • SA-CORE-2026-010 image style non-private://. Patched.
  • SA-CORE-2026-011 HTMX attrs in Xss::attributes(). Xss.php only. This leftover.
  • SA-CORE-2026-012 Layout Builder current block-label XSS. Current row uses @label. Siblings in Move Block are the other leftover.
  • SA-CORE-2026-013 CKEditor 5 / es-toolkit XSS. 11.4.7+.

Known public, not unpublished: POST /user/password?_format=json still skips the HTML-form flood. Mail-bomb only, not ATO. Low and omitted: install.php still never calls setupTrustedHosts.

Five surfaces, two leftovers in this pair

I split the tree: XSS/HTMX, file/MIME/oEmbed, JSON:API/unserialize/SQLi, auth/CSRF/Host, REST/media/Views. JSON:API access stays patched. EntityQuery IN keys stay patched. Serialized-field POI stays patched. rebuild.php Host stays patched.

The XSS/HTMX hunt is this one. Two skip lists. One of them got hx.

Xss.php (patched in SA-011):

PHP
$skip = (
  in_array($attribute_name, ['style', 'srcdoc']) ||
  str_starts_with($attribute_name, 'on') ||
  str_starts_with($attribute_name, 'data-hx') ||
  str_starts_with($attribute_name, 'hx') ||
  str_starts_with($attribute_name, '-') ||
  strlen($attribute_name) > 96
);

AttributeXss.php 41-48 (leftover):

PHP
$skip = (
  $attribute_name == 'style' ||
  str_starts_with($attribute_name, 'on') ||
  str_starts_with($attribute_name, '-') ||
  strlen($attribute_name) > 96
);

Callers: LinkFormatter.php, MenuLinkContent::getUrlObject(). JSON:API/REST/form writes options.attributes -> rendered <a hx-on-click> -> BigPipe page loads HTMX -> click runs JS in the victim origin.

The class docblock on AttributeXss already says it should fold into Xss. It did not.

What I almost filed instead

Treating Xss::filter as the leftover. It is the control. Xss::filter('<a ... hx-on-click=...>') strips. If it kept the attr, SA-011 had regressed.

Unauth JSON:API write. Lab logged in as uid 1 first. A stranger with no account cannot set the field.

PHP RCE. This leftover is browser JS. Not PHP on the server. Not LPE.

Driving a real browser click as SUCCESS. The oracle is the attr remaining on the node HTML plus core/htmx loaded. HTMX binding hx-on-* is inferred from that library being on the page. I did not puppet a click.

A reverse shell. Theatre.

The lab

Live drupal:11.4.8-apache on loopback. Standard profile. JSON:API write enabled for the lab. Loopback http://127.0.0.1:19480. Compose project drupal-htmx-attr. Witness DRUPAL-HTMX-ATTR-WITNESS.

  1. Site-install Standard 11.4.8. Enable jsonapi / serialization / big_pipe. Add a Link field on article.
  2. Control: AttributeXss keeps hx-on-click; Xss::filter strips it.
  3. Session login, CSRF token, POST /jsonapi/node/article with hx-on-click / data-hx-on-click = window['DRUPAL-HTMX-ATTR-WITNESS']=1.
  4. GET /node/{nid}. Expect the attrs in HTML and core/htmx loaded.

Last line:

Plain text
SUCCESS drupal-htmx-attr how=live write=jsonapi nid=1 hx-on-click=kept jsonapi-stored=1 xss-filter=stripped htmx=loaded marker=1 version=11.4.8 DRUPAL-HTMX-ATTR-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/drupal-htmx-attr
cd drupal-htmx-attr
python3 drupal-htmx-attr-Abraxas-Labs.py

or cd lab && ./run.sh. Bind it to loopback.

Wrong turns, in the order I made them

Re-filing SA-011. Xss.php is patched. The other class is not.

Looking at on* only. HTMX event attrs are hx-on-* and data-hx-on-*. They do not start with on. That is why 011 added a prefix skip. AttributeXss never got it.

Treating menu links as a second finding. Same sink. Same class. One leftover.

Inflating to unauth stored XSS. JSON:API without a session does not write the field in this lab.

A reverse shell. Theatre. The oracle is hx-on-click on the node HTML.

What an attacker can do

Writer: a content editor who can set link options.attributes (Link field, menu link, JSON:API). Victim: anyone whose page loads HTMX (Standard + BigPipe loads core/htmx), including a site admin who clicks the link.

Integrity of stored field (lab-proven). POST /jsonapi/node/article with field_link.options.attributes.hx-on-click and data-hx-on-click. JSON:API 201. Stored attrs still contain hx-on-click.

Integrity of HTML (lab-proven). LinkFormatter -> AttributeXss::sanitizeAttributes. GET /node/1 HTML contains hx-on-click and the marker window['DRUPAL-HTMX-ATTR-WITNESS']=1.

Integrity of victim session (inferred). HTMX binds hx-on-*. Lab did not drive a browser click. Act as the viewer (session cookie, admin UI clicks) is the same stored XSS. Lab stops at the attribute remaining in HTML.

Closed (lab-proven). Xss::filter strips hx-on-click. Unauthenticated write of the link. PHP RCE / LPE.

The fix

Copy the hx / data-hx / srcdoc denylist into AttributeXss. Fold into Xss as the class docblock already says.

I am not going to print a JSON:API body you can paste at someone else's /jsonapi/node/article. The missing skip list is the useful part. If you own the site, run the proof of concept against loopback.

References

Drupal, AttributeXss still allows HTMX event attributes · Abraxas Labs