CSP: replace 'unsafe-inline' with a content hash for the theme flash guard

script-src now allow-lists the guard by sha256 of its rendered bytes
(middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the
guard is the app's only inline <script>. security_headers_test.go renders
templates.ThemeFlashGuard() and fails with the replacement hash if the
script text ever drifts.

Executing anti-XSS strict CSP surfaced three latent violations the
eval-removal sweep couldn't see (hash mode is stricter than eval mode,
because inline event handler ATTRIBUTES now count against script-src):

1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers
   (modal open/close, folder-browser picker, purge button) converted to
   Alpine @click/@submit component methods — openCreateLibraryModal,
   hideModal, pickBrowsePath added to the admin component;
   purgeArchivedItems promoted from its window global into the component
   and the (window as any) exposure removed.

2. dashboard/docs: onclick handlers converted — dashboard's
   event.stopPropagation() → @click.stop; docs' sidebar/search closers →
   closeDocsSidebar/closeSearchResults component methods.

3. 8 onerror= image-fallback attributes (cover placeholders app-wide +
   audiobook cover hide) replaced by data-fallback attributes handled by
   one delegated capture-phase listener in web/src/image_fallback.ts —
   works for htmx-swapped content without re-binding.

Verified: theme guard applies a saved theme on public pages under the
hash CSP; 14-page authenticated sweep clean; admin pages clean under the
promoted admin; admin-library modal open/close bindings exercised;
readers render error-free; go build/vet/middleware-test, tsc, vp
check/test (146), build:ts all green.
This commit is contained in:
John O'Keefe committed 2026-10-25 20:54:09 -04:00
1 parent 6a598786e9
commit fb95b6d400
26 files changed
+180 -54

No files matched your search

+19 -3
View File
@@ -4,6 +4,11 @@ import (
"github.com/labstack/echo/v5"
)
// ScriptHashThemeFlashGuard is the CSP script-src hash of the theme flash
// guard's inline script (the app's only inline script). Enforced against
// the rendered component by security_headers_test.go.
const ScriptHashThemeFlashGuard = "'sha256-JYIVpFyKdLUtWizMha0v69f6yHds+GbJRBWjvpuECBY='"
// SecurityHeadersMiddleware adds security headers to all responses
func SecurityHeadersMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -14,12 +19,23 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
c.Response().Header().Set("X-XSS-Protection", "1; mode=block")
c.Response().Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// script-src notes: 'unsafe-inline' is required by the pre-paint
// theme flash guard (inline <script>). Alpine runs via its
// CSP-safe build (@alpinejs/csp), which needs no eval — do NOT
// CSP-safe Alpine build (@alpinejs/csp) needs no eval — do NOT
// add 'unsafe-eval' here without removing that build.
//
// The inline script allow-list: the theme flash guard is the
// ONLY inline script in the app, and its byte-stable content is
// hashed into script-src (sha256 of the bytes between
// <script> and </script> as rendered by
// templates.ThemeFlashGuard()). Editing the guard requires
// recomputing the hash — enforced by
// internal/middleware/security_headers_test.go, which renders
// the component and asserts the hash matches.
//
// style-src keeps 'unsafe-inline' for htmx-injected styles and
// Alpine :style attribute writes.
// font/img blob: — the reader renders EPUB-embedded fonts and
// images from blob: URLs inside the foliate content frame.
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")