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)
This commit is contained in:
+1
-1
@@ -126,7 +126,7 @@ func main() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Session(sessionStore, cfg))
|
r.Use(middleware.Session(sessionStore, cfg))
|
||||||
r.Use(middleware.RequireAuth)
|
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)
|
// Auth (protected: me, update profile)
|
||||||
authHandler.RegisterProtectedRoutes(r)
|
authHandler.RegisterProtectedRoutes(r)
|
||||||
|
|||||||
@@ -10,10 +10,24 @@ import (
|
|||||||
// not in the trustedOrigins list are rejected with 403. Non-browser clients
|
// not in the trustedOrigins list are rejected with 403. Non-browser clients
|
||||||
// (curl, bots) that send no Origin or Referer header are allowed through,
|
// (curl, bots) that send no Origin or Referer header are allowed through,
|
||||||
// since they are not subject to CSRF.
|
// since they are not subject to CSRF.
|
||||||
func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler {
|
//
|
||||||
originSet := make(map[string]struct{}, len(trustedOrigins))
|
// If host is non-empty, any Origin whose hostname matches host is allowed.
|
||||||
for _, o := range trustedOrigins {
|
// additionalOrigins is a list of extra fully-qualified origins to trust
|
||||||
originSet[o] = struct{}{}
|
// (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 func(next http.Handler) http.Handler {
|
||||||
@@ -25,7 +39,7 @@ func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler {
|
|||||||
// Fallback: extract origin from Referer
|
// Fallback: extract origin from Referer
|
||||||
referer := r.Header.Get("Referer")
|
referer := r.Header.Get("Referer")
|
||||||
if referer != "" {
|
if referer != "" {
|
||||||
for _, o := range trustedOrigins {
|
for o := range originSet {
|
||||||
if strings.HasPrefix(referer, o) {
|
if strings.HasPrefix(referer, o) {
|
||||||
origin = o
|
origin = o
|
||||||
break
|
break
|
||||||
@@ -35,7 +49,7 @@ func CSRFProtect(trustedOrigins []string) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if origin != "" {
|
if origin != "" {
|
||||||
if _, ok := originSet[origin]; !ok {
|
if !isOriginTrusted(origin, hostname, port, originSet) {
|
||||||
http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"forbidden origin"}`, http.StatusForbidden)
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user