fix(security): stop tab ids reaching Alpine as expression source (#32) - #35
fix(security): stop tab ids reaching Alpine as expression source (#32)#35fsecada01 wants to merge 3 commits into
Conversation
A request-controlled `tab.id` executed as JavaScript on page load. Every tabs template spliced it into four attributes Alpine evaluates as source — `:class`, `:aria-selected`, `:tabindex`, `@click.prevent` — so an id containing an apostrophe closed its string literal, ran, and reopened it. The surrounding expression still parsed, so Alpine logged nothing and no interaction was required. HTML escaping does not mitigate this and cannot: the parser decodes `'` back to `'` while building the DOM, and Alpine reads the decoded attribute. The escaping is gone before the evaluator sees the value. The value has to stop being source, so every binding now reads `$el.dataset.cfTab`. `data-cf-tab` was already on each tab for the roving tabindex; the only new plumbing is on the row wrapper that carries the active class in Bulma. `cf_ui_alpine.js` already stated this rule in `initTabs()` and already followed it for `data-cf-active` — it was never carried one level down. Sixteen sites across the two shipped themes. The three open theme branches each copied the pattern, which is why this lands before them: two themes to patch now instead of five later, the same reasoning that put #21 ahead of the expansion epic. Tests: - `tests/unit/test_alpine_expression_safety.py` guards the rule over every template in the package, not per theme, so a sixth theme cannot reintroduce it by copying a fifth. Verified non-vacuous both ways: it failed on exactly the four tabs templates before the fix, and dropping `data-cf-tab` from the Bulma wrapper still fails the pairing test. - `tests/e2e/test_alpine_injection.py` proves the payload is inert in a real browser, which is the only tier that can — the decode-then- evaluate sequence needs a real parser and a real Alpine. It asserts both that the payload did not run and that the bindings did evaluate; a fix that made Alpine throw would satisfy the first alone. - The two `test_tabs_keeps_the_alpine_contract` cases asserted the old interpolated form and now assert the data-attribute one. 625 unit + integration pass, 83 E2E pass, 65 node pass, ruff and prek clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Adversarial review — PR #35
OverviewMoves Four findings, one of them significant. 1. HIGH — the
|
Review of PR #35 found a second exposure and two soft spots in the new guard. `install_cf_ui` leaves JinjaX's `autoescape` off — `jinjax.Catalog()` builds its environment with autoescape disabled and adopts it only from a caller-supplied `jinja_env`. So on the FastAPI/Litestar path `data-cf-tab="{{ tab.id }}"` is unescaped, and a double-quote-bearing id breaks out of the attribute and installs a live event handler. Reproduced through the real installer. Django/cotton is unaffected. That is not a regression — the old code was exposed the same way, with the expression injection on top — but it means "a `data-` attribute has no such seam" was a stronger claim than the code supports. Filed as #36, because switching autoescape on changes behaviour for existing consumers and is a release-shaped decision, not a line folded into a security patch. `docs/accessibility.md` now states the exposure and the interim workaround, and the CHANGELOG points at #36 rather than implying attribute safety cf-ui does not yet provide. Worth recording why the suite could not see it: every Jinja fixture in `tests/` hand-builds an environment with autoescape enabled, which is friendlier than what `install_cf_ui` produces. The escaping assertions were proving a property of the harness — the proxy-test shape #19 exists to close. Guard hardening, all in code this PR introduced: - `ALPINE_ATTR` matched only double-quoted values, so `:class='{ "is-active": … }'` and bare values slipped through. Single quotes are exactly what an author reaches for when the expression needs a double quote, and this guard is the only thing standing between a sixth theme and reintroducing #32. All three quoting forms now match. - Added `test_the_guard_flags_what_it_claims_to`: thirteen cases pinning what the regex catches and what it correctly ignores (`hx-*`, `data-*`, interpolation-free expressions). A lint that quietly stops matching reports clean forever. - Both new files took `THEMES` from `cf_ui.themes` instead of a literal, so the per-theme cases and the E2E proof pick up bootstrap, foundation and fomantic the moment those land — they are the reason this PR exists, and would otherwise have been silently uncovered. - The E2E Alpine URL comes from `_ALPINE_CDN`/`_DEFAULTS` rather than a fourth hardcoded copy of `3.14.1`. 638 unit + integration pass, ruff and prek clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
|
Addressed the review. Second commit ( Finding 1 — split out as #36 rather than folded in. Switching JinjaX's Findings 2, 3, 4 — fixed, all in code this PR introduced. 638 unit + integration pass (up from 625 — the guard's self-tests), ruff and prek clean. |
…on-injection CHANGELOG only. Kept both sides, and folded master's bootstrap-only #32 note into this branch's full #32 section rather than leaving two entries for the same fix — #29 carried the fix in on its own branch, so the section now names all the themes it covers. The tree-wide guard from this branch now also scans master's bootstrap templates. They pass: #29 landed with the fixed pattern, so the guard finds nothing, which is the merge order working as intended. 872 unit + integration pass, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Bootstrap (#22) and Foundation (#23) were split out of the same parent issue and touch the same five registration points, so every conflict here is additive — both themes' entries belong in the merged file. Resolved as unions, in `THEMES` order: - `themes.py`, `test_theme_dispatch.py`, `test_accessibility.py` — both themes listed. `ACTIVE_TAB_CLASS` now records that Foundation reuses Bulma's `is-active` token on the `<li class="tabs-title">`, whereas DaisyUI and Bootstrap mark the `<a role="tab">` itself. - `jinja_app/main.py` — both CSS pins, and one shared comment for the two themes that ship prebuilt CSS whose JavaScript cf-ui declines to load. - `e2e/conftest.py` — all four server fixtures (8775/8776 Bootstrap, 8777/8778 Foundation) and all four page fixtures. Each cotton server fixture got its own `terminate()`/`wait()`; the conflict region ended before the shared teardown, so taking either side would have left one Django subprocess running for the rest of the session. Two things git could not see: - `test_resolve_theme_rejects_a_stub_theme_by_name` named `foundation` on master, because Foundation was still a stub when Bootstrap landed. It now names `fomantic`, and `resolve_theme("foundation")` gets its own positive test — otherwise the merged suite asserted the opposite of what this branch ships and no conflict marker would have said so. - The Foundation tabs templates still passed `'{{ tab.id }}'` into four Alpine expressions (#32). Fixed the same way as the other themes: `data-cf-tab` on the element carrying the binding, read back through `$el.dataset.cfTab`. Foundation puts `:class` on the `<li>`, so the `<li>` carries its own copy of the attribute. Landing the theme without this would put the bug in a fourth theme and go red against the tree-wide guard in #35. Also adds the CHANGELOG section the branch never got. Gates: 903 unit+integration, 178 E2E (26 skipped), 65 node, ruff clean, prek clean twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Closes #32. Part of #34. Unblocks #29, #30, #31 — each of those branches needs to merge
masterand adopt the fixed pattern before merge.What was wrong
Every tabs template interpolated the request-controlled
tab.idinto four attributes Alpine evaluates as JavaScript source::class="{ 'is-active': active === '{{ tab.id }}' }" :aria-selected="active === '{{ tab.id }}'" :tabindex="tabIndexFor('{{ tab.id }}')" @click.prevent="setActive('{{ tab.id }}')"An id containing an apostrophe closes its string literal, runs, and reopens it — so the surrounding expression still parses, Alpine logs nothing, and no user interaction is required. Confirmed with a working payload before the ticket was filed, and the E2E test in this PR reproduces it: with the fix reverted it fails on the "did not execute" assertion, having already passed the "bindings evaluated" one.
HTML escaping is not the fix and cannot be. The parser decodes
'back to'while building the DOM, and Alpine reads the decoded attribute — the escaping is gone before the evaluator sees the value. A template engine escapes an attribute correctly; it has no idea it is writing JavaScript source.The fix
The value stops being source. Every binding reads it back from a
data-attribute:data-cf-tab="{{ tab.id }}"was already on every<a role="tab">—tabIndexForalready read it for the roving tabindex — so the only new plumbing is on the<li>wrapper that carries the active class under Bulma.$el.dataset.*resolves against the element the directive sits on, so reaching from the<li>into its child would have put theme-specific DOM structure back intocf_ui_alpine.js.cf_ui_alpine.jsalready stated this exact rule ininitTabs()and already followed it fordata-cf-active. It was never carried one level down; that is the whole bug.Scope
Sixteen sites, four files, both shipped themes.
The three open theme PRs each copied the pattern into their own theme, so this lands ahead of them — two themes to patch now instead of five later, which is the same reasoning that put #21 ahead of the theme-expansion epic. 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 untouched: HTMX treats those as a URL and a selector, not as source.Tests
tests/unit/test_alpine_expression_safety.py— the rule as a guard over every template in the package rather than a per-theme check, so a sixth theme cannot reintroduce it by copying a fifth. Verified non-vacuous in both directions: before the fix it failed on exactly the four tabs templates and passed on the other 66; and droppingdata-cf-tabfrom the Bulma wrapper still fails the pairing test that says an element reading$el.dataset.cfTabmust carry the attribute itself.tests/e2e/test_alpine_injection.py— the tier that actually proves the fix, because the mechanism depends on two things pytest does not have: a parser that decodes entities while building the DOM, and Alpine's evaluator reading the decoded value back. It asserts the payload did not run and that the bindings did evaluate — a fix that made Alpine throw on the expression would satisfy the first assertion while leaving the widget dead. The page is assembled in the test from the real templates, the realcf_ui_alpine.js, and the pinned Alpine build rather than served by the demo app, which should not grow a route whose job is to render a payload.Two existing cases (
test_tabs_keeps_the_alpine_contract, Jinja and Cotton) asserted the old interpolated form and now assert the data-attribute one.Gates
pytest tests/unit tests/integrationpytest tests/e2e --browser chromiumnode --test tests/js/*.test.mjsruff check src testsruff format --check src testsprek(run twice)Behaviour is unchanged and still asserted: roving tabindex still puts exactly one
tabindex="0"in the widget including the no-activefirst-tab case, keyboard nav and manual activation still pass, and thejs_offassertions still pass.Follow-ups, not in this diff
just test-jssilently runs zero tests and exits 0 on Windows — the quoted glob reaches node literally undercmd.exe. Pre-existing, Windows-only, CI unaffected (it invokes node directly). Same root cause as thejust test-tailwindquoting fixed in CI: build the Tailwind plugin against real Tailwind, and assert the bad build fails #17..claude/worktrees/holds three stale agent worktrees from the parallel theme work, untracked and not in.gitignore. Harmless until someone runsgit add ., butruff format --check .picks them up.