diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index 8d06d3bd274..db806acbd3f 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -412,8 +412,13 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("making TLS client config: %v", err) } - // servername has a placeholder, so we need to replace it - if strings.Contains(h.TLS.ServerName, "{") { + serverNameHasPlaceholder := strings.Contains(h.TLS.ServerName, "{") + + // We need to use custom DialTLSContext if: + // 1. ServerName has a placeholder that needs to be replaced at request-time, OR + // 2. ProxyProtocol is enabled, because req.URL.Host is modified to include + // client address info with "->" separator which breaks Go's address parsing + if serverNameHasPlaceholder || h.ProxyProtocol != "" { rt.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { // reuses the dialer from above to establish a plaintext connection conn, err := dialContext(ctx, network, addr) @@ -422,9 +427,11 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e } // but add our own handshake logic - repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) tlsConfig := rt.TLSClientConfig.Clone() - tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "") + if serverNameHasPlaceholder { + repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "") + } // h1 only if caddyhttp.GetVar(ctx, tlsH1OnlyVarKey) == true { diff --git a/modules/caddyhttp/reverseproxy/httptransport_test.go b/modules/caddyhttp/reverseproxy/httptransport_test.go index 1fa4965f251..88ac9d5916d 100644 --- a/modules/caddyhttp/reverseproxy/httptransport_test.go +++ b/modules/caddyhttp/reverseproxy/httptransport_test.go @@ -1,11 +1,13 @@ package reverseproxy import ( + "context" "encoding/json" "fmt" "reflect" "testing" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" ) @@ -115,3 +117,81 @@ func TestHTTPTransport_RequestHeaderOps_TLS(t *testing.T) { t.Fatalf("unexpected Host value; want placeholder, got: %s", got) } } + +// TestHTTPTransport_DialTLSContext_ProxyProtocol verifies that when TLS and +// ProxyProtocol are both enabled, DialTLSContext is set. This is critical because +// ProxyProtocol modifies req.URL.Host to include client info with "->" separator +// (e.g., "[2001:db8::1]:12345->127.0.0.1:443"), which breaks Go's address parsing. +// Without a custom DialTLSContext, Go's HTTP library would fail with +// "too many colons in address" when trying to parse the mangled host. +func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) { + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + tests := []struct { + name string + tls *TLSConfig + proxyProtocol string + serverNameHasPlaceholder bool + expectDialTLSContext bool + }{ + { + name: "no TLS, no proxy protocol", + tls: nil, + proxyProtocol: "", + expectDialTLSContext: false, + }, + { + name: "TLS without proxy protocol", + tls: &TLSConfig{}, + proxyProtocol: "", + expectDialTLSContext: false, + }, + { + name: "TLS with proxy protocol v1", + tls: &TLSConfig{}, + proxyProtocol: "v1", + expectDialTLSContext: true, + }, + { + name: "TLS with proxy protocol v2", + tls: &TLSConfig{}, + proxyProtocol: "v2", + expectDialTLSContext: true, + }, + { + name: "TLS with placeholder ServerName", + tls: &TLSConfig{ServerName: "{http.request.host}"}, + proxyProtocol: "", + serverNameHasPlaceholder: true, + expectDialTLSContext: true, + }, + { + name: "TLS with placeholder ServerName and proxy protocol", + tls: &TLSConfig{ServerName: "{http.request.host}"}, + proxyProtocol: "v2", + serverNameHasPlaceholder: true, + expectDialTLSContext: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ht := &HTTPTransport{ + TLS: tt.tls, + ProxyProtocol: tt.proxyProtocol, + } + + rt, err := ht.NewTransport(ctx) + if err != nil { + t.Fatalf("NewTransport() error = %v", err) + } + + hasDialTLSContext := rt.DialTLSContext != nil + if hasDialTLSContext != tt.expectDialTLSContext { + t.Errorf("DialTLSContext set = %v, want %v", hasDialTLSContext, tt.expectDialTLSContext) + } + }) + } +} +