From 7906ea834f07fc9a13711cea919ad52e6c3f66e1 Mon Sep 17 00:00:00 2001 From: 9ex Date: Sat, 28 Mar 2026 19:38:53 +0800 Subject: [PATCH] fix(web): honor forwarded headers for Pico websocket URLs Build Pico websocket URLs from the externally visible host and port so reverse-proxied WebUI deployments can connect through 443 without exposing the internal launcher port. --- web/backend/api/gateway_host.go | 59 +++++++++++++-------- web/backend/api/gateway_host_test.go | 77 ++++++++++++---------------- web/backend/api/pico.go | 6 +-- 3 files changed, 73 insertions(+), 69 deletions(-) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 592571a28..dd0eab29e 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -47,6 +47,13 @@ func gatewayProbeHost(bindHost string) string { return bindHost } +func firstForwardedValue(raw string) string { + if raw == "" { + return "" + } + return strings.TrimSpace(strings.Split(raw, ",")[0]) +} + func (h *Handler) gatewayProxyURL() *url.URL { cfg, err := config.LoadConfig(h.configPath) port := 18790 @@ -64,17 +71,6 @@ func (h *Handler) gatewayProxyURL() *url.URL { } } -func requestHostName(r *http.Request) string { - reqHost, _, err := net.SplitHostPort(r.Host) - if err == nil { - return reqHost - } - if strings.TrimSpace(r.Host) != "" { - return r.Host - } - return "127.0.0.1" -} - func requestWSScheme(r *http.Request) string { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) @@ -93,16 +89,37 @@ func requestWSScheme(r *http.Request) string { return "ws" } -func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { - host := h.effectiveGatewayBindHost(cfg) - if host == "" || host == "0.0.0.0" { - host = requestHostName(r) +func requestWSAuthority(r *http.Request) string { + scheme := requestWSScheme(r) + authority := firstForwardedValue(r.Header.Get("X-Forwarded-Host")) + if authority == "" { + authority = strings.TrimSpace(r.Host) } - // Use web server port instead of gateway port to avoid exposing extra ports - // The WebSocket connection will be proxied by the backend to the gateway - wsPort := h.serverPort - if wsPort == 0 { - wsPort = 18800 // default web server port + if authority == "" { + return "127.0.0.1" } - return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" + + parsed, err := url.Parse("//" + authority) + if err != nil { + return authority + } + if parsed.Port() != "" { + return authority + } + + forwardedPort := firstForwardedValue(r.Header.Get("X-Forwarded-Port")) + if forwardedPort == "" { + return authority + } + if (scheme == "wss" && forwardedPort == "443") || (scheme == "ws" && forwardedPort == "80") { + return authority + } + if parsed.Hostname() == "" { + return authority + } + return net.JoinHostPort(parsed.Hostname(), forwardedPort) +} + +func buildWsURL(r *http.Request) string { + return requestWSScheme(r) + "://" + requestWSAuthority(r) + "/pico/ws" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index ae3434862..1b0336e12 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -31,27 +31,11 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { } } -func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - launcherPath := launcherconfig.PathForAppConfig(configPath) - if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ - Port: 18800, - Public: true, - }); err != nil { - t.Fatalf("launcherconfig.Save() error = %v", err) - } - - h := NewHandler(configPath) - h.SetServerOptions(18800, false, false, nil) - - cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" - cfg.Gateway.Port = 18790 - +func TestBuildWsURLUsesRequestHostWhenNoForwardedHost(t *testing.T) { req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "192.168.1.9:18800" - if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { + if got := buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } } @@ -136,53 +120,56 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { } func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - cfg := config.DefaultConfig() - cfg.Gateway.Host = "0.0.0.0" - cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + if got := buildWsURL(req); got != "wss://chat.example.com/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com/pico/ws") } } func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - cfg := config.DefaultConfig() - cfg.Gateway.Host = "0.0.0.0" - cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + if got := buildWsURL(req); got != "wss://secure.example.com/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com/pico/ws") } } func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - cfg := config.DefaultConfig() - cfg.Gateway.Host = "0.0.0.0" - cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) req.Host = "chat.example.com" req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + if got := buildWsURL(req); got != "ws://chat.example.com/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com/pico/ws") + } +} + +func TestBuildWsURLUsesForwardedHostWithoutDefaultHTTPSPort(t *testing.T) { + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "127.0.0.1:18800" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "pico.rex.cc") + req.Header.Set("X-Forwarded-Port", "443") + + if got := buildWsURL(req); got != "wss://pico.rex.cc/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://pico.rex.cc/pico/ws") + } +} + +func TestBuildWsURLUsesForwardedHostWithNonDefaultPort(t *testing.T) { + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "127.0.0.1:18800" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "pico.rex.cc") + req.Header.Set("X-Forwarded-Port", "8443") + + if got := buildWsURL(req); got != "wss://pico.rex.cc:8443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://pico.rex.cc:8443/pico/ws") } } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index d345d980c..d9f3b4fee 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{