From 678aff07de9c0cfc1612be8c7fa66a2932c2bdcd Mon Sep 17 00:00:00 2001 From: Tjeerd Ritsma Date: Tue, 27 Oct 2026 16:57:23 +0100 Subject: [PATCH] [client] Send the relay hostname as Host when dialing a relay by IP (#8359) --- .../client/dialer/ws/dialopts_generic.go | 3 +- shared/relay/client/dialer/ws/dialopts_js.go | 2 +- shared/relay/client/dialer/ws/ws.go | 23 ++++- shared/relay/client/dialer/ws/ws_test.go | 97 +++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/shared/relay/client/dialer/ws/dialopts_generic.go b/shared/relay/client/dialer/ws/dialopts_generic.go index 8008d89d3..5e1cf4c4f 100644 --- a/shared/relay/client/dialer/ws/dialopts_generic.go +++ b/shared/relay/client/dialer/ws/dialopts_generic.go @@ -8,8 +8,9 @@ import ( "github.com/coder/websocket" ) -func createDialOptions(serverName string, underlyingOut *net.Conn) *websocket.DialOptions { +func createDialOptions(serverName, host string, underlyingOut *net.Conn) *websocket.DialOptions { return &websocket.DialOptions{ HTTPClient: httpClientNbDialer(serverName, underlyingOut), + Host: host, } } diff --git a/shared/relay/client/dialer/ws/dialopts_js.go b/shared/relay/client/dialer/ws/dialopts_js.go index 5b11fe765..25c9b372c 100644 --- a/shared/relay/client/dialer/ws/dialopts_js.go +++ b/shared/relay/client/dialer/ws/dialopts_js.go @@ -8,7 +8,7 @@ import ( "github.com/coder/websocket" ) -func createDialOptions(_ string, _ *net.Conn) *websocket.DialOptions { +func createDialOptions(_, _ string, _ *net.Conn) *websocket.DialOptions { // WASM version doesn't support HTTPClient or custom TLS config. return &websocket.DialOptions{} } diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go index 6b310b73d..946d267fb 100644 --- a/shared/relay/client/dialer/ws/ws.go +++ b/shared/relay/client/dialer/ws/ws.go @@ -31,8 +31,13 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, return nil, err } + host, err := requestHost(wsURL, serverName) + if err != nil { + return nil, err + } + var underlying net.Conn - opts := createDialOptions(serverName, &underlying) + opts := createDialOptions(serverName, host, &underlying) wsConn, resp, err := websocket.Dial(ctx, wsURL, opts) if err != nil { @@ -78,6 +83,22 @@ func prepareURL(address string) (string, error) { return parsed.String(), nil } +// requestHost keeps the server name in the Host header when dialing by IP, so +// proxies that route by host still match. +func requestHost(wsURL, serverName string) (string, error) { + if serverName == "" { + return "", nil + } + parsed, err := url.Parse(wsURL) + if err != nil { + return "", fmt.Errorf("parse relay URL %q: %w", wsURL, err) + } + if port := parsed.Port(); port != "" { + return net.JoinHostPort(serverName, port), nil + } + return serverName, nil +} + // httpClientNbDialer builds the http client used by the websocket library. // underlyingOut, when non-nil, is populated with the raw conn from the // transport's DialContext so the caller can read its RemoteAddr. diff --git a/shared/relay/client/dialer/ws/ws_test.go b/shared/relay/client/dialer/ws/ws_test.go index 7357adbc0..5f04284f6 100644 --- a/shared/relay/client/dialer/ws/ws_test.go +++ b/shared/relay/client/dialer/ws/ws_test.go @@ -1,7 +1,12 @@ package ws import ( + "context" + "net" + "net/http" + "net/http/httptest" "testing" + "time" ) func TestPrepareURL(t *testing.T) { @@ -74,3 +79,95 @@ func TestPrepareURL(t *testing.T) { }) } } + +func TestRequestHost(t *testing.T) { + tests := []struct { + name string + wsURL string + serverName string + want string + }{ + { + name: "no server name keeps the URL host", + wsURL: "wss://1.2.3.4:443/relay", + want: "", + }, + { + name: "IP with port", + wsURL: "wss://1.2.3.4:443/relay", + serverName: "relay.example.com", + want: "relay.example.com:443", + }, + { + name: "IP with non-standard port", + wsURL: "ws://1.2.3.4:45678/relay", + serverName: "relay.example.com", + want: "relay.example.com:45678", + }, + { + name: "IP without port", + wsURL: "wss://1.2.3.4/relay", + serverName: "relay.example.com", + want: "relay.example.com", + }, + { + name: "IPv6 with port", + wsURL: "wss://[2001:db8::1]:443/relay", + serverName: "relay.example.com", + want: "relay.example.com:443", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := requestHost(tt.wsURL, tt.serverName) + if err != nil { + t.Fatalf("requestHost(%q, %q) err = %v", tt.wsURL, tt.serverName, err) + } + if got != tt.want { + t.Errorf("requestHost(%q, %q) = %q, want %q", tt.wsURL, tt.serverName, got, tt.want) + } + }) + } +} + +func TestDialByIPSendsServerNameAsHost(t *testing.T) { + hosts := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hosts <- r.Host + http.NotFound(w, r) + })) + defer srv.Close() + + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + serverName string + want string + }{ + {name: "dial by IP with server name", serverName: "relay.example.com", want: "relay.example.com:" + port}, + {name: "dial without server name", want: "127.0.0.1:" + port}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, err := (Dialer{}).Dial(ctx, "rel://127.0.0.1:"+port, tt.serverName); err == nil { + t.Fatal("expected the handshake to fail against a plain HTTP server") + } + + select { + case got := <-hosts: + if got != tt.want { + t.Errorf("server saw Host %q, want %q", got, tt.want) + } + case <-ctx.Done(): + t.Fatal("server never received the handshake request") + } + }) + } +}