Part of #34. Blocks #29, #30, #31.
Summary
Every tabs template interpolates the request-controlled tab.id directly into four Alpine expression attributes. Alpine evaluates those attribute values as JavaScript source, so a tab.id containing a quote breaks out of the string literal and executes arbitrary script on page load — no user interaction required.
This is a real injection, not a theoretical one. It was found while reviewing #22 and confirmed with a working proof-of-concept before this ticket was filed.
The vulnerable pattern
src/cf_ui/templates/jinja/bulma/Tabs.jinja, lines 15 and 21-23:
:class="{ 'is-active': active === '{{ tab.id }}' }"
:aria-selected="active === '{{ tab.id }}'"
:tabindex="tabIndexFor('{{ tab.id }}')"
@click.prevent="setActive('{{ tab.id }}')"
Proof
Rendered with a hostile tab.id, with Django-style HTML entity escaping already applied to the quote characters:
:tabindex="tabIndexFor('');window.PWNED=true;('')"
Loaded in Chromium via Playwright, reading the attribute back through the DOM:
attribute as Alpine reads it: tabIndexFor('');window.PWNED=true;('')
window.PWNED : True
user interaction required : none - page load only
HTML escaping does not mitigate this. The HTML parser decodes ' back to ' while building the DOM, and Alpine reads the already-decoded attribute value. By the time the string reaches Alpine's evaluator it is ', not '. A template engine escapes an attribute correctly; it has no way to escape JavaScript source text that happens to live inside one.
Scope
Four sites per file. On master that is 16 sites across four files:
src/cf_ui/templates/jinja/bulma/Tabs.jinja
src/cf_ui/templates/jinja/daisy/Tabs.jinja
src/cf_ui/templates/cotton/_themes/bulma/tabs.html
src/cf_ui/templates/cotton/_themes/daisy/tabs.html
The three open theme PRs each copied the pattern into their own theme — #29 (bootstrap), #30 (foundation), #31 (fomantic) — so the same four bindings exist in six more files on those branches, for 40 sites in total once all five themes land.
Only the tabs component is affected. A sweep of every :attr / @event / x-* attribute across all five themes found no other interpolation into an Alpine expression. hx-get / hx-target interpolation is not in scope — HTMX treats those values as URLs and selectors, not as source text.
The fix
cf_ui_alpine.js already documents the correct rule, and already applies it one level up. From initTabs():
// Read from a data attribute rather than an interpolated
// `x-data="cfTabs('{{ active }}')"`. The value is request-controlled,
// and a template engine escapes an attribute correctly but has no
// way to escape JavaScript source text.
this.active = this.$el.dataset.cfActive || null;
The tab id needs the same treatment, and the plumbing is already in place: every <a role="tab"> carries data-cf-tab="{{ tab.id }}", and tabIndexFor already reads tabs[0].dataset.cfTab. The bindings should read $el.dataset.cfTab instead of an interpolated literal:
:aria-selected="active === $el.dataset.cfTab"
:tabindex="tabIndexFor($el.dataset.cfTab)"
@click.prevent="setActive($el.dataset.cfTab)"
The :class binding sits on the wrapping <li role="presentation">, which carries no data attribute today — add data-cf-tab="{{ tab.id }}" to it so $el.dataset.cfTab resolves there too, rather than reaching across the DOM from the <li> into its child.
A data-* attribute is never evaluated as source, so a hostile id becomes an inert string in every one of these positions. The server-rendered no-JS fallbacks (class="{% if tab.id == active %}..., aria-selected=, tabindex=) are ordinary attribute interpolation and are already safe — they stay as they are.
Acceptance criteria
Blocking
This blocks #29, #30, and #31. Merging them first means patching this in five themes instead of two, by exactly the same reasoning that made #21 block the theme-expansion epic. The three branches will each need to pick up the fixed pattern before merge.
Part of #34. Blocks #29, #30, #31.
Summary
Every tabs template interpolates the request-controlled
tab.iddirectly into four Alpine expression attributes. Alpine evaluates those attribute values as JavaScript source, so atab.idcontaining a quote breaks out of the string literal and executes arbitrary script on page load — no user interaction required.This is a real injection, not a theoretical one. It was found while reviewing #22 and confirmed with a working proof-of-concept before this ticket was filed.
The vulnerable pattern
src/cf_ui/templates/jinja/bulma/Tabs.jinja, lines 15 and 21-23::class="{ 'is-active': active === '{{ tab.id }}' }" :aria-selected="active === '{{ tab.id }}'" :tabindex="tabIndexFor('{{ tab.id }}')" @click.prevent="setActive('{{ tab.id }}')"Proof
Rendered with a hostile
tab.id, with Django-style HTML entity escaping already applied to the quote characters:Loaded in Chromium via Playwright, reading the attribute back through the DOM:
HTML escaping does not mitigate this. The HTML parser decodes
'back to'while building the DOM, and Alpine reads the already-decoded attribute value. By the time the string reaches Alpine's evaluator it is', not'. A template engine escapes an attribute correctly; it has no way to escape JavaScript source text that happens to live inside one.Scope
Four sites per file. On
masterthat is 16 sites across four files:src/cf_ui/templates/jinja/bulma/Tabs.jinjasrc/cf_ui/templates/jinja/daisy/Tabs.jinjasrc/cf_ui/templates/cotton/_themes/bulma/tabs.htmlsrc/cf_ui/templates/cotton/_themes/daisy/tabs.htmlThe three open theme PRs each copied the pattern into their own theme — #29 (
bootstrap), #30 (foundation), #31 (fomantic) — so the same four bindings exist in six more files on those branches, for 40 sites in total once all five themes land.Only the tabs component is affected. A sweep of every
:attr/@event/x-*attribute across all five themes found no other interpolation into an Alpine expression.hx-get/hx-targetinterpolation is not in scope — HTMX treats those values as URLs and selectors, not as source text.The fix
cf_ui_alpine.jsalready documents the correct rule, and already applies it one level up. FrominitTabs():The tab id needs the same treatment, and the plumbing is already in place: every
<a role="tab">carriesdata-cf-tab="{{ tab.id }}", andtabIndexForalready readstabs[0].dataset.cfTab. The bindings should read$el.dataset.cfTabinstead of an interpolated literal:The
:classbinding sits on the wrapping<li role="presentation">, which carries no data attribute today — adddata-cf-tab="{{ tab.id }}"to it so$el.dataset.cfTabresolves there too, rather than reaching across the DOM from the<li>into its child.A
data-*attribute is never evaluated as source, so a hostile id becomes an inert string in every one of these positions. The server-rendered no-JS fallbacks (class="{% if tab.id == active %}...,aria-selected=,tabindex=) are ordinary attribute interpolation and are already safe — they stay as they are.Acceptance criteria
tab.idmust not produce an Alpine expression attribute containing an unescaped'. Assert on the rendered output, in both the Jinja and the Cotton unit tiers, parametrized over every theme inTHEMES.tab.idand asserts the payload did not execute. This is the assertion that actually proves the fix, because it exercises the real HTML parser and the real Alpine evaluator, which the unit tier cannot.:attror@eventattribute in any theme template contains{{. Add this as a lint-style test over the whole template tree so a sixth theme cannot reintroduce the pattern silently.masterfiles.tabindex="0"in the widget including the no-activefirst-tab case, keyboard nav and manual activation still pass,js_offassertions still pass.docs/accessibility.mdrecords the rule next to the existing focus-management notes: request-controlled values reach Alpine throughdata-attributes, never through interpolated expression source.Blocking
This blocks #29, #30, and #31. Merging them first means patching this in five themes instead of two, by exactly the same reasoning that made #21 block the theme-expansion epic. The three branches will each need to pick up the fixed pattern before merge.