fix(websocket): add support for WSS in WebSocket URL construction based on forwarded headers

This commit is contained in:
zeed-w-beez 2026-03-25 10:16:34 +08:00
parent c2c2d7674f
commit 01705249af
No known key found for this signature in database
GPG key ID: 80D9A061738877DD
3 changed files with 84 additions and 1 deletions

View file

@ -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"
}

View file

@ -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")
}
}

View file

@ -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)
}