From 7c9e561e6868fc346d8337c14607ddc5ca37e146 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 13:57:41 -0400 Subject: [PATCH] fix(auth): login/registration forms redirect again under hash CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The htmx login/registration partials answered success with an inline `, 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