ADR-028: CSP Compatibility with Alpine.js
Date: 2026-07-12
Status
Section titled “Status”Accepted
Context
Section titled “Context”The Content-Security-Policy shipped by internal/middleware/security.go locks
script-src to 'self'. The comment reasoned that HTMX and Alpine.js are
self-hosted script files, so nothing more was needed. That reasoning covered
script loading but not script evaluation: Alpine 3’s expression engine
compiles every x-data, x-show, x-on, and x-init attribute with the
Function constructor, which CSP treats as eval. Without 'unsafe-eval',
every Alpine expression throws
Alpine Expression Error: ... 'unsafe-eval' is not an allowed source.
The failure was invisible while the demo was hardcoded stubs. The 2026-07-12
design review of the ADR-024 surfaces found the entire Alpine layer dead in
every environment: the dark-mode toggle (and its persistence), the user menu,
toast notifications, the showcase source tabs, inline validation, bulk
selection, and the flashcard flip. Two inline <script> blocks in the base
layout (the loading-indicator wiring and an alpine:init placeholder) were
also silently blocked, because script-src without 'unsafe-inline' or a
nonce forbids inline script bodies.
This is a direct conflict between two Accepted ADRs: ADR-014 (strict CSP) and ADR-007/ADR-012 (Alpine.js is the sanctioned client-interactivity layer).
Decision
Section titled “Decision”- Add
'unsafe-eval'toscript-src— the policy becomesscript-src 'self' 'unsafe-eval'. This is Alpine’s documented supported posture for the standard build. - Keep inline scripts forbidden —
script-srcdoes not gain'unsafe-inline'. The base layout’s inline<script>blocks move into the self-hostedapp.js; templates may only ship behavior as Alpine attributes or external files. A server test pins this (no<script>withoutsrc). - All other directives are unchanged (
default-src 'self',form-action 'self',frame-ancestors 'none', …).
Consequences
Section titled “Consequences”- Alpine works; the ADR-007/012 frontend bet is functional again.
'unsafe-eval'weakens CSP as a defense-in-depth layer: an attacker who can already inject markup could evaluate expressions. The primary XSS defense in this stack is templ’s contextual auto-escaping (ADR-017) plus input validation (ADR-014 §2); CSP remains effective against foreign-origin script injection, inline handlers, and clickjacking.- The no-inline-scripts rule becomes testable and tested — future templates
cannot quietly reintroduce inline
<script>.
Alternatives Considered
Section titled “Alternatives Considered”- Alpine CSP build (
@alpinejs/csp). Eval-free, but it forbids inline expressions entirely — everyx-data="{ open: false }"in the codebase (and in the pattern showcase’s teaching material) would need to become a registeredAlpine.data()component. That rewrites the exact idiom the starter exists to demonstrate, and the showcase would then teach a non-standard Alpine dialect. Rejected. - Nonce-based script-src. Nonces authorize inline script blocks, not
Function-constructor evaluation — it does not fix Alpine, only the two inline scripts, which move toapp.jsanyway. Rejected as insufficient. - Drop Alpine for vanilla JS. Contradicts ADR-007/ADR-012 and the product’s HTMX+Alpine positioning. Rejected.
References
Section titled “References”- ADR-007, ADR-012 — Alpine’s role
- ADR-014 — the CSP this amends (amendment note added there)
- ADR-017 — templ escaping as the primary XSS defense
- Alpine.js docs, “Content Security Policy” — the standard build requires
'unsafe-eval'
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1: No rendered page ships an inline
<script>block (every script tag hassrc). - TC-2: The CSP header is exactly the decided policy (
script-src 'self' 'unsafe-eval', no'unsafe-inline').
- TC-1: No rendered page ships an inline
- Checks:
- TC-1 →
internal/server/server_test.goviatask ci(status: block, pre-existing) - TC-2 →
internal/middleware/security_test.goviatask ci(status: block, pre-existing)
- TC-1 →
- Not machine-checkable: None — this ADR is fully pinned by tests.
- Graduation log: (empty)