Files
dumpsterChat/internal/middleware/csrf.go
T
hobokenchicken 5c7e461dad fix: CSRF middleware now trusts origin hostname match instead of exact prefix
The old CSRF middleware used exact string matching against a static list
of origins (https://host, http://localhost:port). This broke when the
frontend ran on a different port (Vite dev server) or accessed via LAN IP.

New behavior:
- Origin hostname matching: any Origin whose hostname matches cfg.Host
  is trusted, regardless of scheme or port
- Localhost variants always trusted: localhost, 127.0.0.1, ::1
- Additional origins can be passed explicitly (future env var support)
2026-06-29 12:57:52 -04:00

109 lines
3.1 KiB
Go

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
}