fix: CSRF and WS origin check trust request Host header (same-origin)

This commit is contained in:
2026-06-29 14:38:18 -04:00
parent 7f56571004
commit a8a09cf7b4
2 changed files with 139 additions and 123 deletions
+34 -15
View File
@@ -26,8 +26,11 @@ var upgrader = websocket.Upgrader{
} }
// SetAllowedOrigins replaces the default upgrader with one that validates // SetAllowedOrigins replaces the default upgrader with one that validates
// the Origin header against a trusted set. Call once at startup. // the Origin header. Accepts if:
// Exact matches are checked first, then hostname-based matching is used. // 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) { func SetAllowedOrigins(origins []string) {
originSet := make(map[string]struct{}, len(origins)) originSet := make(map[string]struct{}, len(origins))
hostSet := 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 { CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if origin == "" { if origin == "" {
return true // non-browser clients return true
} }
if _, ok := originSet[origin]; ok { if _, ok := originSet[origin]; ok {
return true return true
} }
oh := origin oh := extractHostname(origin)
if idx := strings.Index(oh, "://"); idx >= 0 {
oh = oh[idx+3:] // Match trusted hostnames
}
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, "]")
if _, ok := hostSet[oh]; ok { if _, ok := hostSet[oh]; ok {
return true 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" { if oh == "localhost" || oh == "127.0.0.1" || oh == "::1" {
return true return true
} }
return false 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. // Client is a middleman between the websocket connection and the hub.
type Client struct { type Client struct {
Hub *Hub Hub *Hub
+105 -108
View File
@@ -1,108 +1,105 @@
package middleware package middleware
import ( import (
"net/http" "net/http"
"strings" "strings"
) )
// CSRFProtect returns middleware that validates the Origin header on // CSRFProtect returns middleware that validates the Origin header on
// state-changing methods (POST, PUT, PATCH, DELETE). Requests from origins // state-changing methods (POST, PUT, PATCH, DELETE).
// not in the trustedOrigins list are rejected with 403. Non-browser clients //
// (curl, bots) that send no Origin or Referer header are allowed through, // An origin is trusted if:
// since they are not subject to CSRF. // 1. It's in the explicit additionalOrigins list, OR
// // 2. Its hostname matches the configured host, OR
// If host is non-empty, any Origin whose hostname matches host is allowed. // 3. Its hostname matches the request's own Host header (same-origin check)
// additionalOrigins is a list of extra fully-qualified origins to trust func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler {
// (e.g. from environment variable DUMPSTER_CSRF_ORIGINS, comma-separated). originSet := make(map[string]struct{})
func CSRFProtect(host, port string, additionalOrigins []string) func(http.Handler) http.Handler { for _, o := range additionalOrigins {
// Build a set of exact-match origins for quick lookup o = strings.TrimSpace(o)
originSet := make(map[string]struct{}) if o != "" {
for _, o := range additionalOrigins { originSet[o] = struct{}{}
o = strings.TrimSpace(o) }
if o != "" { }
originSet[o] = struct{}{}
} hostname := host
} if idx := strings.LastIndex(hostname, ":"); idx > 0 {
hostname = hostname[:idx]
// Extract hostname from host (strip port if present) }
hostname := host
if idx := strings.LastIndex(hostname, ":"); idx > 0 { return func(next http.Handler) http.Handler {
hostname = hostname[:idx] return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
} switch r.Method {
case "POST", "PUT", "PATCH", "DELETE":
return func(next http.Handler) http.Handler { origin := r.Header.Get("Origin")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if origin == "" {
switch r.Method { referer := r.Header.Get("Referer")
case "POST", "PUT", "PATCH", "DELETE": if referer != "" {
origin := r.Header.Get("Origin") for o := range originSet {
if origin == "" { if strings.HasPrefix(referer, o) {
// Fallback: extract origin from Referer origin = o
referer := r.Header.Get("Referer") break
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 origin != "" { }
if !isOriginTrusted(origin, hostname, port, originSet) {
http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) if !isOriginTrusted(origin, hostname, port, reqHost, originSet) {
return 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)
next.ServeHTTP(w, r) })
}) }
} }
}
func isOriginTrusted(origin, host, port, reqHost string, originSet map[string]struct{}) bool {
// isOriginTrusted checks if the given Origin header value is trusted. if _, ok := originSet[origin]; ok {
// An origin is trusted if: return true
// 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 { withoutScheme := origin
if _, ok := originSet[origin]; ok { if idx := strings.Index(withoutScheme, "://"); idx >= 0 {
return true withoutScheme = withoutScheme[idx+3:]
} }
if idx := strings.Index(withoutScheme, "/"); idx >= 0 {
// Parse the origin to extract hostname and port withoutScheme = withoutScheme[:idx]
// Origin format: scheme://host[:port] }
withoutScheme := origin
if idx := strings.Index(withoutScheme, "://"); idx >= 0 { originHost := withoutScheme
withoutScheme = withoutScheme[idx+3:] originPort := ""
} if idx := strings.LastIndex(originHost, ":"); idx >= 0 {
// Remove path if any originPort = originHost[idx+1:]
if idx := strings.Index(withoutScheme, "/"); idx >= 0 { originHost = originHost[:idx]
withoutScheme = withoutScheme[:idx] }
}
originHost = strings.TrimPrefix(originHost, "[")
originHost := withoutScheme originHost = strings.TrimSuffix(originHost, "]")
originPort := ""
if idx := strings.LastIndex(originHost, ":"); idx >= 0 { // Match configured host
originPort = originHost[idx+1:] if host != "" && originHost == host {
originHost = originHost[:idx] return true
} }
// Strip brackets from IPv6 // Match request's own Host header (same-origin)
originHost = strings.TrimPrefix(originHost, "[") if reqHost != "" && originHost == reqHost {
originHost = strings.TrimSuffix(originHost, "]") return true
}
if host != "" && originHost == host {
return true // Localhost variants
} if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" {
if port == "" || originPort == "" || originPort == port {
// Also trust localhost variants return true
if originHost == "localhost" || originHost == "127.0.0.1" || originHost == "::1" { }
if port == "" || originPort == "" || originPort == port { }
return true
} return false
} }
return false
}