From eb089ef4c9416bd3d5642bb5a4437355ffa2fdd0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 3 Jun 2026 00:35:02 +0000 Subject: fix: add CSRF and WebSocket origin protection to Claudomator proxy Token-based CSRF is impractical for a reverse proxy whose UI doesn't know Doot's session tokens, so state-changing requests to /claudomator/* are now validated against the configured WebAuthn origin via Origin/Referer header. WebSocket upgrades reject mismatched or missing Origin headers before hijacking the connection. Both checks are no-ops when WebAuthnOrigin is unset (local dev). Co-Authored-By: Claude Sonnet 4.6 --- cmd/dashboard/main.go | 5 +++-- internal/auth/middleware.go | 26 ++++++++++++++++++++++++++ internal/handlers/claudomator_proxy.go | 6 +++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 09292b4..c648ede 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -164,7 +164,7 @@ func main() { r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally // Initialize Claudomator reverse proxy - claudomatorProxy := handlers.NewClaudomatorProxy(cfg.ClaudomatorURL) + claudomatorProxy := handlers.NewClaudomatorProxy(cfg.ClaudomatorURL, cfg.WebAuthnOrigin) // Rate limiter for auth endpoints authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow) @@ -234,9 +234,10 @@ func main() { // GitHub webhook: no auth (GitHub POSTs with HMAC, no session) r.Post("/claudomator/api/webhooks/github", claudomatorProxy.ServeHTTP) - // All other Claudomator routes: RequireAuth only, no CSRF + // All other Claudomator routes: RequireAuth + origin check (CSRF protection for proxy) r.Group(func(r chi.Router) { r.Use(authHandlers.Middleware().RequireAuth) + r.Use(auth.OriginCheck(cfg.WebAuthnOrigin)) r.Handle("/claudomator/*", claudomatorProxy) }) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 78f3b53..04e7111 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "encoding/base64" "net/http" + "net/url" "strings" "github.com/alexedwards/scs/v2" @@ -120,6 +121,31 @@ func GetCSRFTokenFromContext(ctx context.Context) string { return token } +// OriginCheck returns middleware that validates the Origin (or Referer) header +// against allowedOrigin for state-changing requests. Use for reverse-proxy +// routes where injecting a CSRF token into the proxied UI is not practical. +// When allowedOrigin is empty the check is skipped (dev/no-TLS setups). +func OriginCheck(allowedOrigin string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if allowedOrigin != "" && + (r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH") { + origin := r.Header.Get("Origin") + if origin == "" { + if ref, err := url.Parse(r.Header.Get("Referer")); err == nil && ref.Host != "" { + origin = ref.Scheme + "://" + ref.Host + } + } + if origin != allowedOrigin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) + } +} + func generateToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) diff --git a/internal/handlers/claudomator_proxy.go b/internal/handlers/claudomator_proxy.go index bfbbabc..f3f0cd2 100644 --- a/internal/handlers/claudomator_proxy.go +++ b/internal/handlers/claudomator_proxy.go @@ -13,7 +13,7 @@ import ( // targetURL, stripping the "/claudomator" prefix from the path. WebSocket // upgrade requests are handled via raw TCP hijacking to support long-lived // connections. -func NewClaudomatorProxy(targetURL string) http.Handler { +func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { target, err := url.Parse(targetURL) if err != nil { panic("claudomator: invalid target URL: " + err.Error()) @@ -50,6 +50,10 @@ func NewClaudomatorProxy(targetURL string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + if allowedOrigin != "" && r.Header.Get("Origin") != allowedOrigin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } proxyWebSocket(w, r, target) return } -- cgit v1.2.3