From 01705249af5ea5e9892dab9950a88184816b77fb Mon Sep 17 00:00:00 2001 From: zeed-w-beez Date: Wed, 25 Mar 2026 10:16:34 +0800 Subject: [PATCH] fix(websocket): add support for WSS in WebSocket URL construction based on forwarded headers --- web/backend/api/gateway_host.go | 42 +++++++++++++++++++++ web/backend/api/gateway_host_test.go | 34 +++++++++++++++++ web/frontend/src/features/chat/websocket.ts | 9 ++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index ddba7d42c..e48f4b6a8 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -75,6 +75,29 @@ func requestHostName(r *http.Request) string { return "127.0.0.1" } +// forwardedRFC7239Proto returns the proto value from the first Forwarded element (RFC 7239), or "". +func forwardedRFC7239Proto(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("Forwarded")) + if raw == "" { + return "" + } + first := strings.TrimSpace(strings.Split(raw, ",")[0]) + for _, part := range strings.Split(first, ";") { + part = strings.TrimSpace(part) + idx := strings.IndexByte(part, '=') + if idx <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(part[:idx])) + if key != "proto" { + continue + } + v := strings.Trim(strings.TrimSpace(part[idx+1:]), `"'`) + return strings.ToLower(v) + } + return "" +} + 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])) @@ -86,6 +109,19 @@ func requestWSScheme(r *http.Request) string { } } + if p := forwardedRFC7239Proto(r); p != "" { + if p == "https" || p == "wss" { + return "wss" + } + if p == "http" || p == "ws" { + return "ws" + } + } + + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on") { + return "wss" + } + if r.TLS != nil { return "wss" } @@ -100,6 +136,12 @@ func requestHTTPScheme(r *http.Request) string { return "https" } } + if p := forwardedRFC7239Proto(r); p == "https" { + return "https" + } + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on") { + return "https" + } if r.TLS != nil { return "https" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index ae3434862..30a8921dd 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -186,3 +186,37 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") } } + +func TestBuildWsURLUsesWSSWhenForwardedHeaderProtoIsHTTPS(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://10.0.0.1/api/pico/token", nil) + req.Host = "chat.example.com" + req.Header.Set("Forwarded", `for=203.0.113.1;proto=https;host=chat.example.com`) + + 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") + } +} + +func TestBuildWsURLUsesWSSWhenXForwardedSslOn(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://10.0.0.1/api/pico/token", nil) + req.Host = "chat.example.com" + req.Header.Set("X-Forwarded-Ssl", "on") + + 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") + } +} diff --git a/web/frontend/src/features/chat/websocket.ts b/web/frontend/src/features/chat/websocket.ts index 6b132e9a6..58b9a8413 100644 --- a/web/frontend/src/features/chat/websocket.ts +++ b/web/frontend/src/features/chat/websocket.ts @@ -13,8 +13,15 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string { if (isLocalHost && !isBrowserLocal) { parsedUrl.hostname = window.location.hostname - finalWsUrl = parsedUrl.toString() } + + // HTTPS pages cannot open ws:// (mixed content). Backend may still return ws + // when TLS terminates at a reverse proxy without X-Forwarded-Proto. + if (window.location.protocol === "https:" && parsedUrl.protocol === "ws:") { + parsedUrl.protocol = "wss:" + } + + finalWsUrl = parsedUrl.toString() } catch (error) { console.warn("Could not parse ws_url:", error) }