summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 00:35:02 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 00:35:02 +0000
commiteb089ef4c9416bd3d5642bb5a4437355ffa2fdd0 (patch)
tree8e4fbd833defa346f506c95a80345b3ab0fc379a
parent3a9263abd0a5b2b759a8f691f001ff71f5417748 (diff)
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 <noreply@anthropic.com>
-rw-r--r--cmd/dashboard/main.go5
-rw-r--r--internal/auth/middleware.go26
-rw-r--r--internal/handlers/claudomator_proxy.go6
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
}