diff --git a/caddytest/integration/webtransport_test.go b/caddytest/integration/webtransport_test.go index 6efe17e94..470fc9d0d 100644 --- a/caddytest/integration/webtransport_test.go +++ b/caddytest/integration/webtransport_test.go @@ -425,6 +425,135 @@ func TestWebTransport_ReverseProxyForwardsHeaders(t *testing.T) { } } +// TestWebTransport_ReverseProxyExpandsSNIPlaceholder proves that a +// placeholder in the transport's tls_server_name (here driven off a request +// header) is expanded per session before the WebTransport upstream dial, so +// the upstream observes the resolved SNI rather than the literal "{...}" +// string. The normal HTTP/3 path handles this via a custom h3Transport.Dial +// hook (#7737); the WebTransport path dials through its own Dialer and so +// must expand the placeholder itself. +func TestWebTransport_ReverseProxyExpandsSNIPlaceholder(t *testing.T) { + if testing.Short() { + t.Skip() + } + + // Capture the SNI the upstream observed on its TLS handshake. + gotSNI := make(chan string, 1) + upstreamAddr, stopUpstream := startStandaloneWebTransport(t, func(sess *webtransport.Session, r *http.Request) { + sni := "" + if r.TLS != nil { + sni = r.TLS.ServerName + } + select { + case gotSNI <- sni: + default: + } + _ = sess.CloseWithError(0, "") + }) + t.Cleanup(stopUpstream) + + config := fmt.Sprintf(`{ + "admin": {"listen": "localhost:2999"}, + "apps": { + "http": { + "http_port": 9080, + "https_port": 9443, + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":9443"], + "protocols": ["h3"], + "enable_webtransport": true, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "versions": ["3"], + "tls": { + "insecure_skip_verify": true, + "server_name": "{http.request.header.X-SNI}" + } + }, + "upstreams": [{"dial": "127.0.0.1:%d"}] + } + ] + } + ], + "tls_connection_policies": [ + { + "certificate_selection": {"any_tag": ["cert0"]}, + "default_sni": "a.caddy.localhost" + } + ] + } + } + }, + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/a.caddy.localhost.crt", + "key": "/a.caddy.localhost.key", + "tags": ["cert0"] + } + ] + } + }, + "pki": {"certificate_authorities": {"local": {"install_trust": false}}} + } +}`, upstreamAddr.Port) + + tester := caddytest.NewTester(t) + tester.InitServer(config, "json") + + dialer := &webtransport.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // local CA + ServerName: "a.caddy.localhost", + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + EnableStreamResetPartialDelivery: true, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // The client sends X-SNI on the Extended CONNECT; the proxy's + // {http.request.header.X-SNI} placeholder resolves to this value and + // becomes the SNI on the upstream dial. + reqHdr := http.Header{"X-Sni": []string{"sni.example.com"}} + + var sess *webtransport.Session + deadline := time.Now().Add(3 * time.Second) + for { + _, s, err := dialer.Dial(ctx, "https://127.0.0.1:9443/", reqHdr) + if err == nil { + sess = s + break + } + if time.Now().After(deadline) { + t.Fatalf("webtransport dial through proxy failed: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + defer sess.CloseWithError(0, "") + + select { + case got := <-gotSNI: + if got != "sni.example.com" { + t.Errorf("upstream observed SNI %q; want the expanded placeholder value \"sni.example.com\"", got) + } + case <-time.After(3 * time.Second): + t.Fatal("upstream did not observe a WebTransport session in time") + } +} + // TestWebTransport_UpstreamDialFailureSurfaces5xx proves the WT path dials // the upstream BEFORE upgrading the client, so an unreachable upstream // returns a proper 5xx on the client's Dial call (webtransport-go surfaces diff --git a/modules/caddyhttp/reverseproxy/webtransport_transport.go b/modules/caddyhttp/reverseproxy/webtransport_transport.go index abd83ef8e..a7670ed97 100644 --- a/modules/caddyhttp/reverseproxy/webtransport_transport.go +++ b/modules/caddyhttp/reverseproxy/webtransport_transport.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" @@ -123,6 +124,16 @@ func (h *Handler) webTransportHijack(rw http.ResponseWriter, req *http.Request, errors.New("webtransport: transport does not include HTTP/3; set versions to [\"3\"]"))} } + // Expand SNI placeholders (e.g. tls_server_name {http.request.host}) per + // session. The normal HTTP/3 path does this via a custom h3Transport.Dial + // hook (#7737); the WebTransport path dials through its own Dialer and + // bypasses that hook, so expand here. Clone first — the transport's TLS + // config is shared across sessions and must not be mutated in place. + if strings.Contains(tlsCfg.ServerName, "{") { + tlsCfg = tlsCfg.Clone() + tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "") + } + // Dial the upstream BEFORE upgrading the client. If the upstream is // unreachable or refuses the CONNECT, a proper 5xx goes back over the // H3 stream and the client's Dial sees the real status — instead of