From 5c7e461dad40953d12f071e690b2a744e7430df9 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 29 Jun 2026 12:57:52 -0400 Subject: [PATCH] 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) --- cmd/server/main.go | 2 +- internal/middleware/csrf.go | 71 +++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 498d153..0d61ab5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -126,7 +126,7 @@ func main() { r.Group(func(r chi.Router) { r.Use(middleware.Session(sessionStore, cfg)) r.Use(middleware.RequireAuth) - r.Use(middleware.CSRFProtect([]string{"https://" + cfg.Host, "http://localhost:" + cfg.Port})) + r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, nil)) // Auth (protected: me, update profile) authHandler.RegisterProtectedRoutes(r) diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 4bbb6bb..117bd4e 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -10,10 +10,24 @@ import ( // 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. -func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { - originSet := make(map[string]struct{}, len(trustedOrigins)) - for _, o := range trustedOrigins { - originSet[o] = struct{}{} +// +// 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 { @@ -25,7 +39,7 @@ func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { // Fallback: extract origin from Referer referer := r.Header.Get("Referer") if referer != "" { - for _, o := range trustedOrigins { + for o := range originSet { if strings.HasPrefix(referer, o) { origin = o break @@ -35,7 +49,7 @@ func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { } if origin != "" { - if _, ok := originSet[origin]; !ok { + if !isOriginTrusted(origin, hostname, port, originSet) { http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden) return } @@ -47,3 +61,48 @@ func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler { }) } } + +// 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 +}