Skip to content

fix(ui): close stored-XSS class in admin UI static assets - #781

Merged
jaidhyani merged 1 commit into
mainfrom
worktree-agent-ab412748c98c3498b
May 29, 2026
Merged

jaidhyani merged 1 commit into
mainfrom
worktree-agent-ab412748c98c3498b

Conversation

@jaidhyani

Copy link
Copy Markdown
Member

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 escapeHtml helpers shaped like:

function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }

That escapes <, >, & but not ' or ". So any attacker-influenced value interpolated into an inline onclick="...('${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_id sink in history_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's createBadgeElement builds 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 existing escapeHtml to escape all five HTML-significant characters, closing their attribute-interpolation sinks.

What I rejected:

  • A new bespoke escaper as the strategy — the maintainer explicitly flagged "we shouldn't have to roll our own," and an escaper is the fragile approach (context-sensitive: an HTML-text escaper is wrong in attribute/JS-string contexts). DOM construction removes the context-sensitivity entirely.
  • A small auto-escaping templating/sanitizer library (e.g. DOMPurify, lit-html) — the templating volume is small and localized to a handful of render functions. Adding a JS dependency to a repo with no 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.htmlviewSession('${escapeHtml(session.session_id)}') onclick + preview/meta rendering; broken escapeHtml removed (now unused).
  • diff_viewer.htmlselectCall('${call.call_id}') onclick (was unescaped) + recent-calls card text.
  • request_logs.htmlshowTransaction('${log.transaction_id}') onclick (was unescaped) + log rows (endpoint/model/direction) + transaction/session detail meta.
  • inference_providers.htmleditProvider('...') / deleteProvider('...') inline onclicks → addEventListener + dataset; resolves the file's own TODO(post-merge) about this exact idiom; its (correct) escapeHtml removed (now unused).

Escaper hardening (attribute-interpolation sinks in large template-literal files):

  • conversation_live.jsescapeHtml now escapes "/'; closes the data-tool-call-id="${escapeHtml(...)}" attribute breakout.
  • diff_viewer.htmlescapeHtml hardened 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 via addEventListener. (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.js also use inline onclick="window.moveSubPolicy('${safePath}', ...)". I left these: safePath goes through the file's esc() which does escape quotes, and path is an internally-computed JSON-schema field path (derived from the policy config schema, not from request traffic) — not attacker-influenced. Migrating them to addEventListener for consistency is a reasonable follow-up but isn't part of this XSS-class fix.

Relationship to #780 / #752

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's x-data/@change, the policy-config inline onclicks). A meaningful script-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

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>
@jaidhyani
jaidhyani marked this pull request as ready for review May 29, 2026 05:05
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review

Clean, 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:

credentials.html has the same pattern and isn't addressed

src/luthien_proxy/static/credentials.html:632 is the exact stored-XSS class this PR is eliminating:

html += '<td><button class="danger" onclick="invalidateOne(\'' + cred.key_hash + '\')">Invalidate</button></td>';

And :628 interpolates cred.key_hash into a title="..." attribute and as text — also unescaped. :642 interpolates e.message into innerHTML.

The values happen to be safe today — key_hash is hashlib.sha256(...).hexdigest() (credential_manager.py:79-81), so it's constrained to [0-9a-f], and e.message comes from fetch/JSON errors. But that's defense by character-set, the exact failure mode that bit #780 / #752 when "internally derived" turned out not to be.

The PR description explicitly carves out policy_config.js / form_renderer.js as out-of-scope with rationale (internally-computed path, escaped via esc()). credentials.html isn't mentioned in either bucket. Suggest one of:

  • Add it to the DOM-migration sweep (it's three sinks).
  • Acknowledge it explicitly with the same "non-attacker-controlled by SHA-256 character set" reasoning as the policy_config carve-out, so a future reader knows it was considered.

Regression-guard regex is narrower than the docstring implies

_INLINE_HANDLER_JS_STRING (test_static_xss_guards.py:74-77) is:

on\w+\s*=\s*["'][^"']*\(\s*\\?['"]\$\{

The [^"']* between = and ( means the regex only catches the first argument-list opening, with no intervening quotes. It catches the patterns this PR removed (onclick="foo('${x}')") but would slip past:

  • Multi-arg: onclick="foo(123, '${x}')" — the ' before ${ is the second argument, but the regex can't get past the first ( and the comma run because [^"']* ends at the first quote.
  • Backslash-escaped variant inside double-quoted attribute followed by additional args: onclick="foo(\"a\", \"${x}\")".

Not blocking — none of the migrated files have these shapes today — but worth either widening the regex (e.g., allow [^"']*?(?:\([^"']*)? to skip through prior args) or noting the limitation in the docstring so a future maintainer doesn't trust it for more than it catches.

Inline comment justifying timeEl.innerHTML = timeStr is correct

history_list.html:610-613 — verified: formatTimeRange at :681 composes its result from a static days/months array and Date numeric methods. No request data flows into timeStr. The "limited static markup is safe" claim holds. Nice to leave the breadcrumb.

Minor: hardcoded-HTML innerHTML sites left in migrated files

history_list.html:542 (container.innerHTML = '<div class="empty-state">No sessions found</div>') and inference_providers.html empty-state path now use the new DOM-construction style — but :542 still uses innerHTML with a string literal. Functionally safe (no interpolation), but minor inconsistency with the new pattern in the same file. Trivially: replaceChildren + createElement.

Defense-in-depth note on the hardened escapeHtml helpers

The new five-char escaper in conversation_live.js and diff_viewer.html is correct for HTML-text and quoted attribute contexts. It is still not safe for unquoted attributes, JS-string contexts, or URL contexts. The current call sites are all quoted-attribute / HTML-text, so this is fine today. If you want a future-proof guard, an assertion or comment at the function header — "use only in HTML text or quoted attribute contexts" — would prevent the next maintainer from reaching for it in an href="javascript:..." or unquoted data-foo=${...} context.

CSP follow-up

Strongly agree it's a separate refactor — the inline <script> blocks plus Alpine x-data/@change mean a meaningful script-src 'self' needs nonces or a real extraction pass. Worth a Trello ticket so it doesn't drop.

Summary

Approve with the credentials.html ask — that's the one item I think genuinely belongs in this PR (or in an explicit out-of-scope note), because it's the same class and the PR's stated framing is "this class, not these instances." Everything else is nits / follow-ups.

@jaidhyani
jaidhyani merged commit 96ed44f into main May 29, 2026
4 checks passed
@jaidhyani
jaidhyani deleted the worktree-agent-ab412748c98c3498b branch May 29, 2026 05:21
jaidhyani pushed a commit that referenced this pull request May 29, 2026
- 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>
jaidhyani added a commit that referenced this pull request May 29, 2026
fix(ui): credentials.html XSS sinks + harden XSS guards (follow-up to #781)
legion-implementer Bot pushed a commit to trajectory-labs-pbc/luthien-proxy that referenced this pull request Jun 12, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant