fix(ui): close stored-XSS class in admin UI static assets - #781
Conversation
The admin UI built HTML by string interpolation and relied on hand-rolled
escapeHtml helpers that escaped <, >, & but NOT ' or ". Attacker-influenced
values (session_id, call_id, transaction_id, tool_call_id, model/endpoint,
provider names) interpolated into inline onclick="...('${x}')" JS strings or
quoted attributes could break out and execute.
Fix the whole class:
- Migrate the genuinely attacker-controlled JS-string/event-handler sinks in
history_list.html, diff_viewer.html, request_logs.html, and
inference_providers.html to DOM construction (createElement / textContent /
addEventListener / dataset) — markup and quotes inert by construction, no new
dependency. Matches the existing nav.js createBadgeElement pattern.
- Harden the retained escapeHtml helpers in conversation_live.js and
diff_viewer.html to escape all five HTML-significant characters.
- Add source-level regression guards in
tests/luthien_proxy/unit_tests/ui/test_static_xss_guards.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ReviewClean, well-scoped XSS-class fix. The PR description does most of my work for me — the rationale for DOM construction over a bespoke escaper or a new dependency is convincing, the inline comments at each migrated sink explain why (not just what), and the regression guards are a good way to prevent re-introduction in a repo with no JS runtime. A few observations:
|
- emitter._write_db: add sqlite3.Error to the dropped-write except clause so a failed SQLite write (notably the session_summaries update) ticks dropped_db_writes instead of being silently absorbed by emit()'s gather. Add a SQLite dropped-counter regression test. - user_labels.py: correct MAX_DISPLAY_NAME_LENGTH comment — length is enforced at the route boundary (Pydantic), the service owns only the non-blank invariant (matches the declined service-level guard). - migration 021 (postgres): string_agg(DISTINCT ... ORDER BY model) for deterministic backfill. - dev/context/decisions.md: document the drop-event-on-summary-failure trade-off. Addresses third claude-review on #780 (the request_logs user_id XSS it flagged was already resolved by the #781 merge reconciliation to DOM construction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(ui): credentials.html XSS sinks + harden XSS guards (follow-up to #781)
Address bot review on LuthienResearch#781: - credentials.html: the file built HTML by raw string concatenation with no escaper at all (so an escapeHtml grep missed it). Migrate both credential tables to DOM construction: * cached-credentials: key_hash inline-onclick + title="" attr + cell text * server-credentials: name into a data-name="" attr via a quote-unsafe escaper (escHtml), gated only by a server-side regex Also convert the config/credentials/server error-message innerHTML sinks (e.message is unconstrained server text) to textContent. Removed escHtml. - Widen the _INLINE_HANDLER_JS_STRING regression guard: it previously stopped at the first quote and only caught ${...}; now also catches the string-concat onclick shape and multi-arg forms. Documented its exact coverage and accepted gaps rather than over-claiming. Added a self-test for the concat shape and a credentials.html-specific guard. - Add context-limit header comments to the hardened escapeHtml helpers (conversation_live.js, diff_viewer.html): safe for HTML text and *quoted* attributes only, NOT JS-string / URL / unquoted-attribute contexts. - history_list.html empty-state: innerHTML string-literal -> replaceChildren. CSP backstop tracked as a follow-up Trello card (separate refactor; needs nonces or inline-script extraction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause
The admin UI is vanilla JS served as static HTML (no bundler, no framework — Alpine.js is vendored for nav only). Several render paths built HTML by string interpolation and relied on hand-rolled
escapeHtmlhelpers shaped like:That escapes
<,>,&but not'or". So any attacker-influenced value interpolated into an inlineonclick="...('${escapeHtml(x)}')"JS string, or into a quoted HTML attribute, can break out and execute. The interpolated values are request/model-derived:session_id,call_id,transaction_id,tool_call_id, model/endpoint names, provider names.This is a recurring class, not a one-off — #780 and #752 each tripped the same pattern, plus a pre-existing
session_idsink inhistory_list.html.Standard solution chosen — and why
DOM construction (
createElement/textContent/setAttribute/addEventListener+dataset) for the genuinely attacker-controlled JS-string and event-handler sinks. This is the browser-native, zero-dependency safe answer: quotes and markup become inert by construction because values are assigned as data/text, never parsed as HTML or JS. It's also already the established pattern in this repo —nav.js'screateBadgeElementbuilds its nodes exactly this way.For the two large template-literal render files that aren't worth a full rewrite in a bug-fix PR (
conversation_live.js,diff_viewer.html), I hardened the existingescapeHtmlto escape all five HTML-significant characters, closing their attribute-interpolation sinks.What I rejected:
package.json/ no bundler / no JS toolchain violates the repo's KISS / no-unnecessary-deps norms. The cost isn't justified.The dependency tradeoff: DOM construction is more verbose than template literals, but it's stdlib, it matches existing code, and it's the only option that makes the sink safe regardless of context.
Files / sinks fixed
DOM-construction migration (attacker-controlled JS-string / event-handler sinks):
history_list.html—viewSession('${escapeHtml(session.session_id)}')onclick + preview/meta rendering; brokenescapeHtmlremoved (now unused).diff_viewer.html—selectCall('${call.call_id}')onclick (was unescaped) + recent-calls card text.request_logs.html—showTransaction('${log.transaction_id}')onclick (was unescaped) + log rows (endpoint/model/direction) + transaction/session detail meta.inference_providers.html—editProvider('...')/deleteProvider('...')inline onclicks →addEventListener+dataset; resolves the file's ownTODO(post-merge)about this exact idiom; its (correct)escapeHtmlremoved (now unused).Escaper hardening (attribute-interpolation sinks in large template-literal files):
conversation_live.js—escapeHtmlnow escapes"/'; closes thedata-tool-call-id="${escapeHtml(...)}"attribute breakout.diff_viewer.html—escapeHtmlhardened for defense-in-depth (its remaining uses are HTML-text, already safe).Tests:
tests/luthien_proxy/unit_tests/ui/test_static_xss_guards.py— source-level regression guards: no inline-handler JS-string interpolation in the migrated files, retained escapers escape both quote characters, and each specific sink is wired viaaddEventListener. (The repo has no JS runtime, so these are source-text assertions — see "Not runtime-verified" below.)Out of scope (deliberately)
policy_config.js/form_renderer.jsalso use inlineonclick="window.moveSubPolicy('${safePath}', ...)". I left these:safePathgoes through the file'sesc()which does escape quotes, andpathis an internally-computed JSON-schema field path (derived from the policy config schema, not from request traffic) — not attacker-influenced. Migrating them toaddEventListenerfor consistency is a reasonable follow-up but isn't part of this XSS-class fix.Relationship to #780 / #752
history_list.htmland adding a local quote-hardening to that file'sescapeHtml. This PR is built on currentmain, not feat: user-differentiation core (history per-user view, labels, session_summaries) #780, and addresses the root cause class-wide — it may supersede feat: user-differentiation core (history per-user view, labels, session_summaries) #780's local hardening. Whichever merges second should reconcilehistory_list.html(this PR removes that file'sescapeHtmlentirely in favor of DOM construction, so feat: user-differentiation core (history per-user view, labels, session_summaries) #780's local hardening of it becomes moot).fragments/sessions.html. That fragment file does not exist on currentmain(neither does feat: user-differentiation core (history per-user view, labels, session_summaries) #780'suser_idUI sink) — so it's not in this PR's tree. The pattern it represents is exactly what this PR eliminates; when feat(ui): cursor pagination + lazy loading for admin dashboard #752 lands, itssessions.htmlshould use DOM construction forsession_idrather thanescapeHtml-into-onclick.CSP backstop (noted, not implemented)
A Content-Security-Policy header on the admin UI routes (
src/luthien_proxy/ui/routes.py) would be a strong defense-in-depth backstop. It's not implemented here because every admin page uses inline<script>blocks and inline event handlers (Alpine'sx-data/@change, the policy-config inline onclicks). A meaningfulscript-src 'self'CSP would break all of them and requires either per-page nonces or moving all inline JS to external files — a substantial, separate refactor that doesn't belong in a focused bug-fix PR. Recommended as a follow-up.One PR = One Concern / COE
Strictly the XSS-class fix; no feature changes. This is a bug fix and warrants a COE-style note (root cause: context-insensitive hand-rolled escaper + string-interpolated HTML; the recurrence across #780/#752 is the signal that the pattern, not any single sink, is the defect).
🤖 Generated with Claude Code