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.
This commit is contained in:
parent
30155c1c59
commit
7906ea834f
3 changed files with 73 additions and 69 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue