fix(auth): login/registration forms redirect again under hash CSP
The htmx login/registration partials answered success with an inline <script> that stashed the auth token and set window.location — refused by hash-mode script-src (no 'unsafe-inline'), so the user saw "Login successful! Redirecting..." and nothing else (reported from the home- page login form; /login was equally broken). The same pattern lived in collections.go (delete → redirect) and devices.go (token regen → reload, whose script tag was also unclosed). Replacement, per the AGENTS.md CSP rules: - auth.go login/registration: the partial now carries data-auth-token + data-redirect (redirect is path-only — open-redirect hygiene — and escaped); web/src/auth_forms.ts (new, armed from main.ts on every page) listens for htmx:afterSwap, stashes the token, and navigates. - collections.go delete: data-redirect, same listener. - devices.go token regen: htmx's native HX-Refresh response header. - The dead localStorage 'user'/'refreshToken' writes are dropped — no reader anywhere in web/src; only 'token' is load-bearing (19 modules). Tripwire extension: internal/middleware/csp_go_sources_test.go scans non-test Go sources for literal <script> tags and native on*= attributes (hand-written files only — templ codegen is owned by the .templ scan), verified to fire on a reintroduced probe. AGENTS.md CSP section notes the Go-emitted-partial vector. Verified live: home-page login form → /dashboard with token stashed and dashboard loading clean; /register form → /dashboard; collections and devices share the same mechanisms (data-redirect / HX-Refresh). Gates: make test (9 pkgs incl. both tripwires), vitest 155, Playwright 20, tsc/vp clean.
This commit is contained in:
1 parent
b7595c3011
commit
7c9e561e68
8 files changed
+124
-20
No files matched your search
@@ -80,7 +80,11 @@ refuses, **silently at build/test time**, two authoring patterns:
|
|||||||
guard means recomputing the hash (the failing test prints it); adding
|
guard means recomputing the hash (the failing test prints it); adding
|
||||||
a *second* inline script means hashing it too and extending
|
a *second* inline script means hashing it too and extending
|
||||||
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
|
`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=`,
|
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
|
||||||
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
|
||||||
handlers never go through the hash path; hash-mode CSP refuses them
|
handlers never go through the hash path; hash-mode CSP refuses them
|
||||||
|
|||||||
+13
-15
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -335,13 +336,9 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
|
// CSP: see the login handler — data attributes, not inline scripts.
|
||||||
<script>
|
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
|
||||||
localStorage.setItem('token', '%s');
|
accessToken)
|
||||||
localStorage.setItem('refreshToken', '%s');
|
|
||||||
localStorage.setItem('user', JSON.stringify(%s));
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username))
|
|
||||||
return c.HTML(http.StatusCreated, html)
|
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" {
|
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")
|
redirect := c.FormValue("redirect")
|
||||||
if redirect == "" {
|
if redirect == "" || !strings.HasPrefix(redirect, "/") {
|
||||||
redirect = "/dashboard"
|
redirect = "/dashboard"
|
||||||
}
|
}
|
||||||
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
|
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
|
||||||
<script>
|
accessToken, html.EscapeString(redirect))
|
||||||
localStorage.setItem('token', '%s');
|
|
||||||
localStorage.setItem('refreshToken', '%s');
|
|
||||||
localStorage.setItem('user', JSON.stringify(%s));
|
|
||||||
window.location.href = '%s';
|
|
||||||
</script>`, 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)
|
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -496,7 +496,8 @@ func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := `<div class="text-green-500">Collection deleted! Redirecting...</div><script>window.location.href = '/collections';</script>`
|
// CSP: no inline scripts in partials — auth_forms.ts navigates.
|
||||||
|
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -499,7 +499,10 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
|||||||
|
|
||||||
// Handle HTMX requests - return HTML with page reload script
|
// Handle HTMX requests - return HTML with page reload script
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
html := `<div class="text-green-500">Token regenerated! Reloading ... </div><script>window.location.reload();`
|
// CSP: no inline scripts in partials; HX-Refresh makes htmx
|
||||||
|
// reload the page natively (the old script tag was also unclosed).
|
||||||
|
c.Response().Header().Set("HX-Refresh", "true")
|
||||||
|
html := `<div class="text-green-500">Token regenerated! Reloading ... </div>`
|
||||||
return c.HTML(http.StatusOK, html)
|
return c.HTML(http.StatusOK, html)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go-source CSP tripwire.
|
||||||
|
//
|
||||||
|
// The templ scan lives in templates/csp_source_test.go, but htmx partials
|
||||||
|
// can also be emitted from Go handler strings — auth.go used to answer a
|
||||||
|
// successful login with an inline <script>window.location…</script> 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(`<script`)
|
||||||
|
|
||||||
|
var goHandlerAttrRe = regexp.MustCompile(
|
||||||
|
`on(click|error|submit|load|change|input|focus|blur|mouseover|keydown|keyup)\s*=\s*"`)
|
||||||
|
|
||||||
|
func TestGoSourcesAreCSPSafe(t *testing.T) {
|
||||||
|
root := "../.."
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
switch d.Name() {
|
||||||
|
case "node_modules", "static", ".git", "testdata", "docs":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Generated templ codegen is out of scope: its source of truth is
|
||||||
|
// the .templ file (scanned by templates/csp_source_test.go), and
|
||||||
|
// the codegen legitimately emits external <script src=…> 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 <script> in Go source — hash-mode CSP refuses inline scripts in emitted partials; use data attributes + web/src/auth_forms.ts or HX-Redirect/HX-Refresh",
|
||||||
|
path, 1+strings.Count(src[:m[0]], "\n"))
|
||||||
|
}
|
||||||
|
for _, m := range goHandlerAttrRe.FindAllStringIndex(src, -1) {
|
||||||
|
t.Errorf("%s:%d: native on*= handler attribute in Go source — CSP hash-mode refuses it; use Alpine bindings or delegated listeners",
|
||||||
|
path, 1+strings.Count(src[:m[0]], "\n"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
|||||||
//
|
//
|
||||||
// The inline script allow-list: the theme flash guard is the
|
// The inline script allow-list: the theme flash guard is the
|
||||||
// ONLY inline script in the app, and its byte-stable content is
|
// ONLY inline script in the app, and its byte-stable content is
|
||||||
// hashed into script-src (sha256 of the bytes between
|
// hashed into script-src (sha256 of the bytes between the
|
||||||
// <script> and </script> as rendered by
|
// script tags as rendered by
|
||||||
// templates.ThemeFlashGuard()). Editing the guard requires
|
// templates.ThemeFlashGuard()). Editing the guard requires
|
||||||
// recomputing the hash — enforced by
|
// recomputing the hash — enforced by
|
||||||
// internal/middleware/security_headers_test.go, which renders
|
// internal/middleware/security_headers_test.go, which renders
|
||||||
|
|||||||
@@ -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 <script> that stashed the auth token and redirected. Hash-mode CSP
|
||||||
|
// (no 'unsafe-inline') refuses those scripts, so the server now emits plain
|
||||||
|
// data attributes instead and this module performs the navigation:
|
||||||
|
//
|
||||||
|
// <div data-redirect="/dashboard" data-auth-token="…">Login successful!…</div>
|
||||||
|
//
|
||||||
|
// One delegated body listener covers every page (the main bundle loads
|
||||||
|
// everywhere htmx does), so no per-page wiring. Devices' token regeneration
|
||||||
|
// uses htmx's native HX-Refresh response header instead and needs nothing
|
||||||
|
// here.
|
||||||
|
//
|
||||||
|
// Note: only the token is stashed. The old script also wrote localStorage
|
||||||
|
// 'user'/'refreshToken', but nothing in web/src ever read them.
|
||||||
|
|
||||||
|
export function armAuthFormRedirects() {
|
||||||
|
document.body.addEventListener("htmx:afterSwap", (event) => {
|
||||||
|
const detail = (event as CustomEvent).detail as { target?: HTMLElement } | undefined;
|
||||||
|
const el = detail?.target?.querySelector("[data-redirect]");
|
||||||
|
if (!el) return;
|
||||||
|
const token = el.getAttribute("data-auth-token");
|
||||||
|
if (token) localStorage.setItem("token", token);
|
||||||
|
window.location.href = el.getAttribute("data-redirect") ?? "/";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import "./docs";
|
|||||||
import "./header";
|
import "./header";
|
||||||
import "./index";
|
import "./index";
|
||||||
import "./image_fallback";
|
import "./image_fallback";
|
||||||
|
import { armAuthFormRedirects } from "./auth_forms";
|
||||||
import "./library-switcher";
|
import "./library-switcher";
|
||||||
import "./linking";
|
import "./linking";
|
||||||
import "./login";
|
import "./login";
|
||||||
@@ -38,4 +39,5 @@ import "./unlinked_books";
|
|||||||
import "./websocket";
|
import "./websocket";
|
||||||
import "./woodPanelingInit";
|
import "./woodPanelingInit";
|
||||||
|
|
||||||
|
armAuthFormRedirects();
|
||||||
Alpine.start();
|
Alpine.start();
|
||||||
Reference in new issue
Block a user