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 <bcreane@gmail.com>
This commit is contained in:
Brendan Creane
2026-05-28 09:46:36 -07:00
committed by GitHub
parent f4a280cdbd
commit 8d90a6ab1e
+223
View File
@@ -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)