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(` tags. + if strings.HasSuffix(name, "_templ.go") { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + src := string(raw) + for _, m := range goScriptTagRe.FindAllStringIndex(src, -1) { + t.Errorf("%s:%d: literal 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