refactor: move test-only PolicyContext.for_testing into a test fixture - #785
Conversation
Removed the `PolicyContext.for_testing()` classmethod from the production `policy_core/policy_context.py` and replaced it with a `make_policy_context()` factory in `tests/luthien_proxy/fixtures/policy_context.py`. Updated all ~50 call sites across the unit-test suite to import and use the new factory. Behavior is preserved (same NullEventEmitter-backed construction); this only relocates test scaffolding out of the shipped module. Trello: 69de7bb0 (Remove test-specific code from production modules) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the for_testing -> make_policy_context move: add the missing fixture imports in three test modules and remove an accidental self-import in the fixture module itself. dev_checks now passes (ruff, pyright, 1601 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ReviewClean, well-scoped refactor — moves the test-only Positives
Findings1. PR description is from a different PR (blocking before merge). 2. Leftover
A pure mechanical rename ( 3. Redundant string quotes on type hints (style nit). Non-issues I checked
Recommendation: fix the PR description (blocker), and ideally the |
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