diff --git a/AGENTS.md b/AGENTS.md
index 7271575b..f3e4e2f6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
guard means recomputing the hash (the failing test prints it); adding
a *second* inline script means hashing it too and extending
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
- Everything else goes in a bundled `web/src` module.
+ Everything else goes in a bundled `web/src` module. This includes
+ partials emitted from Go handler strings — those use data attributes
+ + `web/src/auth_forms.ts` (post-swap redirect/stash) or htmx's
+ `HX-Redirect`/`HX-Refresh` headers; `internal/middleware`'s
+ Go-source scan is the tripwire for that vector.
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
handlers never go through the hash path; hash-mode CSP refuses them
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 82f3fea5..273cbb12 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -10,6 +10,7 @@ import (
"context"
"errors"
"fmt"
+ "html"
"html/template"
"math"
"net/http"
@@ -335,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
- html := fmt.Sprintf(`
Registration successful! Redirecting...
-`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
+ // CSP: see the login handler — data attributes, not inline scripts.
+ html := fmt.Sprintf(`Registration successful! Redirecting...
`,
+ accessToken)
return c.HTML(http.StatusCreated, html)
}
@@ -481,17 +478,18 @@ func (h *AuthHandler) Login(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
+ // CSP: hash-mode script-src refuses inline scripts, so the htmx
+ // partial carries data attributes instead; web/src/auth_forms.ts
+ // stashes the token and navigates on htmx:afterSwap. The redirect
+ // target is path-only (open-redirect hygiene) and the old
+ // 'user'/'refreshToken' localStorage writes are gone — nothing
+ // ever read them.
redirect := c.FormValue("redirect")
- if redirect == "" {
+ if redirect == "" || !strings.HasPrefix(redirect, "/") {
redirect = "/dashboard"
}
- html := fmt.Sprintf(`Login successful! Redirecting...
-`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), redirect)
+ html := fmt.Sprintf(`Login successful! Redirecting...
`,
+ accessToken, html.EscapeString(redirect))
return c.HTML(http.StatusOK, html)
}
diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go
index f99643b8..b5bb7958 100644
--- a/internal/handlers/collections.go
+++ b/internal/handlers/collections.go
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
}
if c.Request().Header.Get("HX-Request") == "true" {
- html := `Collection deleted! Redirecting...
`
+ // CSP: no inline scripts in partials — auth_forms.ts navigates.
+ html := `Collection deleted! Redirecting...
`
return c.HTML(http.StatusOK, html)
}
diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go
index 28b0f5e7..5d803e81 100644
--- a/internal/handlers/devices.go
+++ b/internal/handlers/devices.go
@@ -499,7 +499,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
// Handle HTMX requests - return HTML with page reload script
if c.Request().Header.Get("HX-Request") == "true" {
- html := `Token regenerated! Reloading ...
that
+// hash-mode CSP silently refused. This test scans the non-test Go sources
+// for the same forbidden patterns: inline script tags and native on*=
+// handler attributes in emitted HTML. Partials that need to navigate or
+// stash state use data attributes + web/src/auth_forms.ts, or htmx's
+// HX-Redirect/HX-Refresh headers.
+
+var goScriptTagRe = regexp.MustCompile(` as rendered by
+ // hashed into script-src (sha256 of the bytes between the
+ // script tags as rendered by
// templates.ThemeFlashGuard()). Editing the guard requires
// recomputing the hash — enforced by
// internal/middleware/security_headers_test.go, which renders
diff --git a/web/src/auth_forms.ts b/web/src/auth_forms.ts
new file mode 100644
index 00000000..04055c65
--- /dev/null
+++ b/web/src/auth_forms.ts
@@ -0,0 +1,27 @@
+// CSP-safe post-swap navigation for server-emitted status partials.
+//
+// The login/registration/collection-delete htmx flows used to answer with an
+// inline