diff --git a/hive/gateway/gateway.go b/hive/gateway/gateway.go index c61055492..4fbce602c 100644 --- a/hive/gateway/gateway.go +++ b/hive/gateway/gateway.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/http" "net/url" "os" @@ -106,10 +107,55 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler { return r } -// corsMiddleware adds CORS headers for browser requests from the dev UI. +// corsAllowedOrigins builds the set of allowed CORS origins from localhost and +// all local non-loopback IPs on the hive-ui port (default 3000). This allows +// the UI to be accessed from any local address, not just localhost. +func corsAllowedOrigins() map[string]struct{} { + origins := map[string]struct{}{ + "https://localhost:3000": {}, + } + ifaces, err := net.Interfaces() + if err != nil { + return origins + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() || ip.To4() == nil { + continue + } + origins[fmt.Sprintf("https://%s:3000", ip.String())] = struct{}{} + } + } + return origins +} + +// corsMiddleware adds CORS headers for browser requests from the hive-ui. func corsMiddleware(next http.Handler) http.Handler { + allowed := corsAllowedOrigins() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000") + origin := r.Header.Get("Origin") + if origin == "" { + origin = "https://localhost:3000" + } + if _, ok := allowed[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + } else { + w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000") + } w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") w.Header().Set("Access-Control-Allow-Credentials", "true") diff --git a/hive/gateway/gateway_test.go b/hive/gateway/gateway_test.go index b933c6d24..7fb904063 100644 --- a/hive/gateway/gateway_test.go +++ b/hive/gateway/gateway_test.go @@ -860,6 +860,43 @@ func TestCorsMiddleware_OPTIONSRequest(t *testing.T) { assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin")) } +func TestCorsMiddleware_LocalIPOrigin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + handler := corsMiddleware(next) + + // Request from a local IP should be reflected + allowed := corsAllowedOrigins() + for origin := range allowed { + if origin == "https://localhost:3000" { + continue + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Origin", origin) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin")) + return // test one IP is enough + } +} + +func TestCorsMiddleware_UnknownOrigin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + handler := corsMiddleware(next) + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Origin", "https://evil.example.com") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // Unknown origins fall back to localhost + assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin")) +} + func TestSlogRequestLogger_CallsNext(t *testing.T) { nextCalled := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {