From 8d90a6ab1ee9a97d8594b82c5e938d8790b80211 Mon Sep 17 00:00:00 2001 From: Brendan Creane Date: Thu, 28 May 2026 09:46:36 -0700 Subject: [PATCH] ipn/ipnlocal: add HTTP/2 Content-Type tests for serve reverse proxy (#19905) Adds two tests exercising the HTTP/2-inbound -> plaintext HTTP/1.1 backend path through serve's reverseProxy and through the full serveWebHandler entry point (with a funnel serveHTTPContext). Updates #19866 Signed-off-by: Brendan Creane --- ipn/ipnlocal/serve_test.go | 223 +++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index 05f4936b2..8287d99a0 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -15,6 +15,7 @@ import ( "fmt" "io" "mime" + "net" "net/http" "net/http/httptest" "net/netip" @@ -22,6 +23,7 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" "testing" "time" @@ -1361,6 +1363,227 @@ func TestServeGRPCProxy(t *testing.T) { } } +// TestServeProxyHTTP2PreservesContentType is a repro for tailscale/tailscale#19866. +// A client POSTs over HTTP/2 to a serve listener configured to reverse-proxy to a +// plaintext HTTP/1.1 backend; the test asserts the backend sees the Content-Type +// header that the client sent. +func TestServeProxyHTTP2PreservesContentType(t *testing.T) { + var ( + gotHeader http.Header + gotMethod string + gotProto string + gotBody string + ) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Clone() + gotMethod = r.Method + gotProto = r.Proto + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + backendURL := must.Get(url.Parse(backend.URL)) + + lb := newTestBackend(t) + rp := &reverseProxy{ + logf: t.Logf, + url: backendURL, + backend: backend.URL, + lb: lb, + } + + // Mirror serveWebHandler's behavior for a mount of "/" with a non-"/" path: + // it wraps the proxy in http.StripPrefix("", rp). That's a no-op for "/mcp" + // but worth exercising in case StripPrefix interacts weirdly with HTTP/2. + frontHandler := http.StripPrefix("", http.HandlerFunc(rp.ServeHTTP)) + front := httptest.NewUnstartedServer(frontHandler) + front.EnableHTTP2 = true + front.StartTLS() + defer front.Close() + + const reqBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + + type variant struct { + name string + http1Only bool + contentType string + setAccept bool + setContentLen bool // if false, body is a Reader with unknown length (chunked) + dropContentLen bool + emptyBody bool + } + variants := []variant{ + {name: "baseline_http2", contentType: "application/json", setAccept: true, setContentLen: true}, + {name: "http1_baseline", http1Only: true, contentType: "application/json", setAccept: true, setContentLen: true}, + {name: "no_accept", contentType: "application/json", setContentLen: true}, + {name: "charset_param", contentType: "application/json; charset=utf-8", setAccept: true, setContentLen: true}, + {name: "chunked", contentType: "application/json", setAccept: true, setContentLen: false}, + {name: "drop_content_length", contentType: "application/json", setAccept: true, dropContentLen: true}, + {name: "empty_body", contentType: "application/json", setAccept: true, emptyBody: true}, + } + + for _, v := range variants { + t.Run(v.name, func(t *testing.T) { + gotHeader = nil + gotMethod = "" + gotProto = "" + gotBody = "" + + var body io.Reader + switch { + case v.emptyBody: + body = http.NoBody + case v.setContentLen: + body = strings.NewReader(reqBody) + default: + // io.Reader that isn't a *bytes.Reader / *strings.Reader / *bytes.Buffer + // so net/http does not auto-set Content-Length. + body = struct{ io.Reader }{strings.NewReader(reqBody)} + } + + url := front.URL + "/mcp" + req, err := http.NewRequest("POST", url, body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", v.contentType) + if v.setAccept { + req.Header.Set("Accept", "application/json, text/event-stream") + } + if v.dropContentLen { + req.ContentLength = -1 + } + + client := front.Client() + if v.http1Only { + // Force the client onto HTTP/1.1 by removing h2 from ALPN. + tr := client.Transport.(*http.Transport).Clone() + if tr.TLSClientConfig != nil { + tr.TLSClientConfig = tr.TLSClientConfig.Clone() + tr.TLSClientConfig.NextProtos = []string{"http/1.1"} + } + tr.ForceAttemptHTTP2 = false + client = &http.Client{Transport: tr} + } + + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + io.Copy(io.Discard, res.Body) + + wantProtoMajor := 2 + if v.http1Only { + wantProtoMajor = 1 + } + if res.ProtoMajor != wantProtoMajor { + t.Fatalf("front-end proto = %s, want HTTP/%d.x", res.Proto, wantProtoMajor) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("backend returned status %d, want 200", res.StatusCode) + } + if gotMethod != "POST" { + t.Errorf("backend method = %q, want POST", gotMethod) + } + t.Logf("backend saw proto=%q content-length=%q transfer-encoding=%v body=%q", + gotProto, gotHeader.Get("Content-Length"), gotHeader.Values("Transfer-Encoding"), gotBody) + if got, want := gotHeader.Get("Content-Type"), v.contentType; got != want { + t.Errorf("Content-Type at backend = %q, want %q (full headers: %v)", got, want, gotHeader) + } + }) + } +} + +// TestServeWebHandlerHTTP2PreservesContentType drives the full +// b.serveWebHandler entry point (the actual production code path) via a real +// HTTP/2 TLS frontend, with serveHTTPContext.Funnel set to mimic a funnel +// request. Repro probe for tailscale/tailscale#19866. +func TestServeWebHandlerHTTP2PreservesContentType(t *testing.T) { + var gotHeader http.Header + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + b := newTestBackend(t) + + front := httptest.NewUnstartedServer(http.HandlerFunc(b.serveWebHandler)) + front.EnableHTTP2 = true + realFrontAddr := front.Listener.Addr().String() + _, portStr, _ := net.SplitHostPort(realFrontAddr) + p, _ := strconv.ParseUint(portStr, 10, 16) + frontPort := uint16(p) + front.Config.BaseContext = func(_ net.Listener) context.Context { + return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{ + Funnel: &funnelFlow{Host: "example.ts.net"}, + SrcAddr: netip.MustParseAddrPort("1.2.3.4:1234"), + DestPort: frontPort, + }) + } + front.StartTLS() + defer front.Close() + + conf := &ipn.ServeConfig{ + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + ipn.HostPort(net.JoinHostPort("example.ts.net", portStr)): {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: backend.URL}, + }}, + }, + } + if err := b.SetServeConfig(conf, ""); err != nil { + t.Fatal(err) + } + + // Custom client: dial example.ts.net:PORT to the real httptest address, + // skip cert verification, and send SNI "example.ts.net" so the serveConfig + // host lookup works. + tr := front.Client().Transport.(*http.Transport).Clone() + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", realFrontAddr) + } + tr.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + ServerName: "example.ts.net", + NextProtos: []string{"h2", "http/1.1"}, + } + tr.ForceAttemptHTTP2 = true + client := &http.Client{Transport: tr} + + const reqBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + url := "https://example.ts.net:" + portStr + "/mcp" + req, err := http.NewRequest("POST", url, strings.NewReader(reqBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + if res.ProtoMajor != 2 { + t.Fatalf("front-end proto = %s, want HTTP/2", res.Proto) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("status %d, want 200; body=%q", res.StatusCode, body) + } + t.Logf("backend headers (full): %v", gotHeader) + if got, want := gotHeader.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Content-Type at backend = %q, want %q", got, want) + } + if got := gotHeader.Get("Tailscale-Funnel-Request"); got != "?1" { + t.Errorf("Tailscale-Funnel-Request = %q, want %q (sanity check)", got, "?1") + } +} + func TestServeHTTPRedirect(t *testing.T) { b := newTestBackend(t)