From a8a09cf7b4f60d77a7ccf188511a82f41353dd69 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 29 Jun 2026 14:38:18 -0400 Subject: [PATCH] fix: CSRF and WS origin check trust request Host header (same-origin) --- internal/gateway/client.go | 49 ++++++--- internal/middleware/csrf.go | 213 ++++++++++++++++++------------------ 2 files changed, 139 insertions(+), 123 deletions(-) diff --git a/internal/gateway/client.go b/internal/gateway/client.go index cff3a96..db29de3 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -26,8 +26,11 @@ var upgrader = websocket.Upgrader{ } // SetAllowedOrigins replaces the default upgrader with one that validates -// the Origin header against a trusted set. Call once at startup. -// Exact matches are checked first, then hostname-based matching is used. +// the Origin header. Accepts if: +// 1. Exact match in origins list, OR +// 2. Hostname matches a trusted hostname, OR +// 3. Hostname matches the request's own Host header (same-origin), OR +// 4. Localhost variant func SetAllowedOrigins(origins []string) { originSet := make(map[string]struct{}, len(origins)) hostSet := make(map[string]struct{}, len(origins)) @@ -53,34 +56,50 @@ func SetAllowedOrigins(origins []string) { CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { - return true // non-browser clients + return true } if _, ok := originSet[origin]; ok { return true } - oh := origin - if idx := strings.Index(oh, "://"); idx >= 0 { - oh = oh[idx+3:] - } - if idx := strings.Index(oh, "/"); idx >= 0 { - oh = oh[:idx] - } - if idx := strings.LastIndex(oh, ":"); idx >= 0 { - oh = oh[:idx] - } - oh = strings.TrimPrefix(oh, "[") - oh = strings.TrimSuffix(oh, "]") + oh := extractHostname(origin) + + // Match trusted hostnames if _, ok := hostSet[oh]; ok { return true } + + // Match request's own Host header (same-origin) + reqHost := extractHostname(r.Host) + if reqHost != "" && oh == reqHost { + return true + } + + // Localhost variants if oh == "localhost" || oh == "127.0.0.1" || oh == "::1" { return true } + return false }, } } +func extractHostname(s string) string { + h := s + if idx := strings.Index(h, "://"); idx >= 0 { + h = h[idx+3:] + } + if idx := strings.Index(h, "/"); idx >= 0 { + h = h[:idx] + } + if idx := strings.LastIndex(h, ":"); idx >= 0 { + h = h[:idx] + } + h = strings.TrimPrefix(h, "[") + h = strings.TrimSuffix(h, "]") + return h +} + // Client is a middleman between the websocket connection and the hub. type Client struct { Hub *Hub diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 117bd4e..504ccb4 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -1,108 +1,105 @@ -package middleware - -import ( - "net/http" - "strings" -) - -// CSRFProtect returns middleware that validates the Origin header on -// state-changing methods (POST, PUT, PATCH, DELETE). Requests from origins -// not in the trustedOrigins list are rejected with 403. Non-browser clients -// (curl, bots) that send no Origin or Referer header are allowed through, -// since they are not subject to CSRF. -// -// If host is non-empty, any Origin whose hostname matches host is allowed. -// additionalOrigins is a list of extra fully-qualified origins to trust -// (e.g. from environment variable DUMPSTER_CSRF_ORIGINS, comma-separated). -func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler { - // Build a set of exact-match origins for quick lookup - originSet := make(map[string]struct{}) - for _, o := range additionalOrigins { - o = strings.TrimSpace(o) - if o != "" { - originSet[o] = struct{}{} - } - } - - // Extract hostname from host (strip port if present) - hostname := host - if idx := strings.LastIndex(hostname, ":"); idx > 0 { - hostname = hostname[:idx] - } - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "POST", "PUT", "PATCH", "DELETE": - origin := r.Header.Get("Origin") - if origin == "" { - // Fallback: extract origin from Referer - referer := r.Header.Get("Referer") - if referer != "" { - for o := range originSet { - if strings.HasPrefix(referer, o) { - origin = o - break - } - } - } - } - - if origin != "" { - if !isOriginTrusted(origin, hostname, port, originSet) { - http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) - return - } - } - // If both Origin and Referer are empty, allow through - // (non-browser client like curl, bots, mobile apps) - } - next.ServeHTTP(w, r) - }) - } -} - -// isOriginTrusted checks if the given Origin header value is trusted. -// An origin is trusted if: -// 1. It's in the explicit originSet, OR -// 2. Its hostname matches the configured host (any scheme/port) -func isOriginTrusted(origin, host, port string, originSet map[string]struct{}) bool { - if _, ok := originSet[origin]; ok { - return true - } - - // Parse the origin to extract hostname and port - // Origin format: scheme://host[:port] - withoutScheme := origin - if idx := strings.Index(withoutScheme, "://"); idx >= 0 { - withoutScheme = withoutScheme[idx+3:] - } - // Remove path if any - if idx := strings.Index(withoutScheme, "/"); idx >= 0 { - withoutScheme = withoutScheme[:idx] - } - - originHost := withoutScheme - originPort := "" - if idx := strings.LastIndex(originHost, ":"); idx >= 0 { - originPort = originHost[idx+1:] - originHost = originHost[:idx] - } - - // Strip brackets from IPv6 - originHost = strings.TrimPrefix(originHost, "[") - originHost = strings.TrimSuffix(originHost, "]") - - if host != "" && originHost == host { - return true - } - - // Also trust localhost variants - if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { - if port == "" || originPort == "" || originPort == port { - return true - } - } - - return false -} +package middleware + +import ( + "net/http" + "strings" +) + +// CSRFProtect returns middleware that validates the Origin header on +// state-changing methods (POST, PUT, PATCH, DELETE). +// +// An origin is trusted if: +// 1. It's in the explicit additionalOrigins list, OR +// 2. Its hostname matches the configured host, OR +// 3. Its hostname matches the request's own Host header (same-origin check) +func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler { + originSet := make(map[string]struct{}) + for _, o := range additionalOrigins { + o = strings.TrimSpace(o) + if o != "" { + originSet[o] = struct{}{} + } + } + + hostname := host + if idx := strings.LastIndex(hostname, ":"); idx > 0 { + hostname = hostname[:idx] + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST", "PUT", "PATCH", "DELETE": + origin := r.Header.Get("Origin") + if origin == "" { + referer := r.Header.Get("Referer") + if referer != "" { + for o := range originSet { + if strings.HasPrefix(referer, o) { + origin = o + break + } + } + } + } + + if origin != "" { + // Extract request host for same-origin check + reqHost := r.Host + if idx := strings.LastIndex(reqHost, ":"); idx > 0 { + reqHost = reqHost[:idx] + } + + if !isOriginTrusted(origin, hostname, port, reqHost, originSet) { + http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) + return + } + } + } + next.ServeHTTP(w, r) + }) + } +} + +func isOriginTrusted(origin, host, port, reqHost string, originSet map[string]struct{}) bool { + if _, ok := originSet[origin]; ok { + return true + } + + withoutScheme := origin + if idx := strings.Index(withoutScheme, "://"); idx >= 0 { + withoutScheme = withoutScheme[idx+3:] + } + if idx := strings.Index(withoutScheme, "/"); idx >= 0 { + withoutScheme = withoutScheme[:idx] + } + + originHost := withoutScheme + originPort := "" + if idx := strings.LastIndex(originHost, ":"); idx >= 0 { + originPort = originHost[idx+1:] + originHost = originHost[:idx] + } + + originHost = strings.TrimPrefix(originHost, "[") + originHost = strings.TrimSuffix(originHost, "]") + + // Match configured host + if host != "" && originHost == host { + return true + } + + // Match request's own Host header (same-origin) + if reqHost != "" && originHost == reqHost { + return true + } + + // Localhost variants + if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { + if port == "" || originPort == "" || originPort == port { + return true + } + } + + return false +}