ADR-0056: CSP Hardening β No eval, No Inline Handlers, Nonce-Only ScriptsΒΆ
- Status: Accepted
- Date: 2026-10-01
- Deciders: Core Engineering Team
- Supersedes: ADR-0014
- Issues: #1110
- Related: PR #7076
ContextΒΆ
ADR-0014 established the SecurityHeadersMiddleware and documented several trade-offs in the initial Content Security Policy, including 'unsafe-inline' in script-src and 'unsafe-eval' in script-src to support HTMX hx-vals="js:{...}" and hx-on:* attribute evaluation:
"'unsafe-eval'": Still required β HTMX evaluates hx-vals="js:{...}" and hx-on:* attributes
via htmx.config.allowEval. Tracked in issue #4655.
This left two attack surfaces:
on*handler injection: A crafted redirect link (/admin?error=<payload>) could reachinnerHTMLand execute code if the old flash-banner rendered user-controlled text directly.script-src-attr 'unsafe-inline'permitted that execution path.- HTMX code evaluation:
htmx.config.allowEval = true(the library default) allowed injectedhx-vals="js:{...}",hx-vars, and trigger-filter expressions to reacheval.
Both issues were mitigated by other controls at the time (no user text in flash banners, input validation), but a defence-in-depth gap remained: removing either mitigation could silently open an XSS path. This ADR closes that gap at the CSP and JS configuration layers.
DecisionΒΆ
1. Disable HTMX code evaluationΒΆ
Set htmx.config.allowEval = false in mcpgateway/admin_ui/admin.js immediately after the existing inlineScriptNonce configuration:
// Injected markup must not reach htmx code evaluation (hx-on, hx-vals="js:", hx-vars, trigger filters).
htmx.config.allowEval = false;
All hx-vals="js:{...}" usages have been migrated to htmx:configRequest event handlers. All hx-on:* attributes have been migrated to addEventListener calls.
2. Change script-src-attr from 'unsafe-inline' to 'none'ΒΆ
Update mcpgateway/middleware/security_headers.py:
# Before (ADR-0014):
"script-src-attr 'unsafe-inline'",
# After (ADR-0056):
"script-src-attr 'none'",
This directive governs inline on* event-handler attributes (onclick=, onload=, etc.). Setting it to 'none' means injected markup can never become script execution, regardless of what reaches innerHTML.
3. Replace the hand-rolled HTML sanitizer with DOMPurifyΒΆ
The previous sanitizeHtmlForInsertion() in mcpgateway/admin_ui/security.js used a manual <template> walk. That walk was correct but did not handle all mXSS re-serialisation patterns.
Replace it with a DOMPurify call using a guard config that:
- Keeps htmx, Alpine, data-, and aria- attributes the UI depends on.
- Removes all
hx-on*/data-hx-on*attributes via auponSanitizeAttributehook (these are the attributes HTMX would evaluate as code ifallowEvalwere re-enabled). - Forbids
<iframe>,<object>,<embed>,<meta>, and<base>. - Uses
FORCE_BODYto prevent<template>/<style>hoisting.
The hook is global, so it also covers any other caller of window.DOMPurify.
4. Replace user-controlled flash-banner text with a server-side code allowlistΒΆ
The admin redirect helpers now emit opaque error codes (permission_denied, conflict, etc.) instead of user-visible exception strings. The flash-banner IIFE in admin.html maps those codes to display text through a hardcoded ERROR_MESSAGES / INFO_MESSAGES lookup:
const ERROR_MESSAGES = {
permission_denied: 'You do not have permission to perform this action.',
conflict: 'This item is being modified by another request. Please try again.',
// β¦ all codes β¦
};
text.textContent = `β ${Object.hasOwn(ERROR_MESSAGES, errorCode) ? ERROR_MESSAGES[errorCode] : GENERIC_ERROR}`;
textContent assignment is used, not innerHTML, so no markup can be injected regardless of what arrives in the URL parameter. Unknown codes show a generic message.
5. Remove inline on* attributes from templatesΒΆ
Remaining onclick=, onchange=, and onsubmit= attributes in partial templates (a2a_agent_plugin_bindings_partial.html and admin.html) are replaced with data-action="β¦" attributes wired via event-delegation scripts that run with the per-request nonce.
ConsequencesΒΆ
β BenefitsΒΆ
- Defence in depth:
script-src-attr 'none'closes the inline-handler execution path independently of input validation and sanitiser correctness. - eval eliminated:
htmx.config.allowEval = falseremoves HTMX as an eval vector. - mXSS hardened: DOMPurify handles the full spectrum of parser-mismatch payloads that the previous hand-rolled sanitiser did not address.
- Reflected-text XSS closed: Flash-banner codes are allowlisted server-side; attacker- chosen wording and markup are impossible even with a crafted link.
- Stale URL params cleaned immediately:
history.replaceStatenow runs synchronously before the banner renders, so the URL is clean on first display and there is no 5-second auto-dismiss race.
β Trade-offsΒΆ
htmx.config.allowEval = falseremoves support forhx-vals="js:{...}",hx-vars, and trigger-filter expressions. All existing usages have been migrated; new Admin UI code must not rely on eval-based HTMX features.- DOMPurify bundle size: Adding DOMPurify to the bundle increases the JS payload by ~18 KB (minified, pre-gzip). This is an acceptable trade-off given the security benefit.
style-src 'unsafe-inline': Kept from ADR-0014 for Tailwind inline style attributes. This is a known residual risk; migrating to style nonces is tracked as a future enhancement.
π MaintenanceΒΆ
- New error codes: When adding admin redirect paths, add the new code to both
_build_admin_redirectcallers inadmin.pyand theERROR_MESSAGESmap inadmin.html. The JS unit testadmin-flash-message-xss.test.jsenforces sync between the two files. - HTMX eval migration: Any new feature that would require
htmx.config.allowEval = truemust go through an ADR update; re-enabling eval silently removes the DOMPurify backstop forhx-onattributes. - DOMPurify version: Pin the DOMPurify version in
package.jsonand review the changelog on each update for changes toADD_ATTRcallback semantics.
Alternatives ConsideredΒΆ
| Alternative | Why Not Chosen |
|---|---|
Keep 'unsafe-inline' in script-src-attr | Does not close the inline-handler path; requires trusting all future input-validation and sanitiser changes to be flawless |
Nonce script-src-attr instead of 'none' | No browser supports per-attribute nonces; the only safe values are 'none' and 'unsafe-inline' |
| Keep hand-rolled sanitiser | Does not handle mXSS re-serialisation patterns; DOMPurify is the maintained industry standard |
| Server-side HTML escaping of flash text only | Does not prevent future regressions if another path reaches innerHTML; defence in depth requires the CSP layer |
TestingΒΆ
- Unit (
tests/unit/js/admin-flash-message-xss.test.js): JSDOM execution of the flash IIFE with mXSS payloads, unknown codes, known codes, dismissal, and URL cleanup. - Unit (
tests/unit/js/innerhtml-guard.test.js):installInnerHtmlGuardwith mXSS anddata-actionpreservation. - Unit (
tests/unit/js/security.test.js): DOMPurify-backedsanitizeHtmlForInsertionwith all known mXSS payload patterns. - Unit (
tests/unit/mcpgateway/middleware/test_security_headers_middleware.py): Assertsscript-src-attr 'none'is present and'unsafe-inline'is absent. - Unit (
tests/unit/mcpgateway/test_admin.py): All redirect error handlers emit opaque codes; exception text never reaches the URL. - E2E (
tests/live_gateway/e2e/test_admin_csp_inline_handlers.py): Live gateway CSP header check on/admin/login. - Playwright (
tests/playwright/security/test_admin_flash_message_xss.py): Full browser execution check βwindow.__flashXssmust remainundefinedafter crafted-link navigation.
StatusΒΆ
Accepted and implemented as part of the validation and escaping tightening work (issue #1110). Supersedes the CSP directives and trade-off notes originally recorded in ADR-0014.