166 lines
5.4 KiB
Go
166 lines
5.4 KiB
Go
package mitm
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
// h2InterceptRig is the test fixture for HTTP/2 paths. Two things
|
|
// differ from the H1.1 rig:
|
|
// - The client http.Transport has http2.ConfigureTransport called
|
|
// so it negotiates h2 with our proxy.
|
|
// - The upstream httptest server is started via StartTLS *and*
|
|
// manually configured for h2 (httptest does this by default in
|
|
// modern Go but we make it explicit for clarity).
|
|
func h2InterceptRig(interceptHost string, upstream http.Handler) (*http.Client, string, func()) {
|
|
ts := httptest.NewUnstartedServer(upstream)
|
|
ts.EnableHTTP2 = true
|
|
ts.StartTLS()
|
|
upstreamCertPool := x509.NewCertPool()
|
|
upstreamCertPool.AddCert(ts.Certificate())
|
|
upstreamURL, _ := url.Parse(ts.URL)
|
|
|
|
ca, err := NewInMemoryCA()
|
|
ExpectWithOffset(1, err).NotTo(HaveOccurred())
|
|
|
|
srv, err := NewServer(Config{
|
|
Addr: "127.0.0.1:0",
|
|
CA: ca,
|
|
InterceptHosts: []string{interceptHost},
|
|
Handler: passthroughHandler(upstreamCertPool, upstreamURL.Host),
|
|
})
|
|
ExpectWithOffset(1, err).NotTo(HaveOccurred())
|
|
ExpectWithOffset(1, srv.Start()).To(Succeed())
|
|
|
|
// Client with HTTP/2 explicitly enabled (modern net/http does
|
|
// this by default, but configuring the Transport directly makes
|
|
// the test independent of stdlib defaults).
|
|
clientPool := x509.NewCertPool()
|
|
clientPool.AddCert(ca.Cert())
|
|
proxyURL, _ := url.Parse("http://" + srv.Addr())
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyURL(proxyURL),
|
|
TLSClientConfig: &tls.Config{
|
|
RootCAs: clientPool,
|
|
NextProtos: []string{"h2", "http/1.1"},
|
|
},
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
ExpectWithOffset(1, http2.ConfigureTransport(transport)).To(Succeed(), "client h2 configure")
|
|
client := &http.Client{Transport: transport}
|
|
|
|
cleanup := func() {
|
|
srv.Stop()
|
|
ts.Close()
|
|
}
|
|
return client, "https://" + interceptHost, cleanup
|
|
}
|
|
|
|
var _ = Describe("Proxy HTTP/2", func() {
|
|
It("negotiates HTTP/2", func() {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// The upstream side: when serving over h2, r.ProtoMajor == 2.
|
|
w.Header().Set("X-Upstream-Proto", r.Proto)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprint(w, `{"ok":true}`)
|
|
})
|
|
|
|
client, base, cleanup := h2InterceptRig("api.test.local", upstream)
|
|
defer cleanup()
|
|
|
|
resp, err := client.Get(base + "/v1/test")
|
|
Expect(err).NotTo(HaveOccurred(), "Get")
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// The proxy ↔ client leg: client sees h2 because we ALPN-
|
|
// negotiated it. resp.Proto is the protocol the client used.
|
|
Expect(resp.Proto).To(Equal("HTTP/2.0"), "proxy did not serve h2")
|
|
body, _ := io.ReadAll(resp.Body)
|
|
Expect(string(body)).To(ContainSubstring(`"ok":true`))
|
|
})
|
|
|
|
It("streams over HTTP/2", func() {
|
|
// h2 streaming: the proxy must flush each frame promptly. The
|
|
// upstream sends 3 SSE-style chunks; we read them back through
|
|
// a streaming decoder so a buffering bug would surface.
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(200)
|
|
flusher := w.(http.Flusher)
|
|
for _, msg := range []string{"first", "second", "third"} {
|
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", msg)
|
|
flusher.Flush()
|
|
}
|
|
})
|
|
|
|
client, base, cleanup := h2InterceptRig("api.test.local", upstream)
|
|
defer cleanup()
|
|
|
|
resp, err := client.Get(base + "/stream")
|
|
Expect(err).NotTo(HaveOccurred(), "Get")
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
Expect(resp.Proto).To(Equal("HTTP/2.0"), "expected h2 for streaming response")
|
|
body, _ := io.ReadAll(resp.Body)
|
|
for _, msg := range []string{"first", "second", "third"} {
|
|
Expect(strings.Contains(string(body), "data: "+msg)).To(BeTrue(), "missing %q in h2 streamed body: %s", msg, body)
|
|
}
|
|
})
|
|
|
|
It("falls back to HTTP/1.1", func() {
|
|
// Force the client to negotiate h1.1 only, by overriding ALPN.
|
|
// Verifies the fallback path still works for legacy clients.
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = fmt.Fprint(w, `{"ok":true}`)
|
|
})
|
|
|
|
ts := httptest.NewTLSServer(upstream)
|
|
defer ts.Close()
|
|
upstreamCertPool := x509.NewCertPool()
|
|
upstreamCertPool.AddCert(ts.Certificate())
|
|
upstreamURL, _ := url.Parse(ts.URL)
|
|
|
|
ca, _ := NewInMemoryCA()
|
|
srv, _ := NewServer(Config{
|
|
Addr: "127.0.0.1:0",
|
|
CA: ca,
|
|
InterceptHosts: []string{"api.test.local"},
|
|
Handler: passthroughHandler(upstreamCertPool, upstreamURL.Host),
|
|
})
|
|
Expect(srv.Start()).To(Succeed())
|
|
defer srv.Stop()
|
|
|
|
clientPool := x509.NewCertPool()
|
|
clientPool.AddCert(ca.Cert())
|
|
proxyURL, _ := url.Parse("http://" + srv.Addr())
|
|
// ALPN intentionally restricted to http/1.1 to force the
|
|
// fallback path. Most clients will negotiate h2, but the
|
|
// proxy must keep h1 working for the rare case.
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyURL(proxyURL),
|
|
TLSClientConfig: &tls.Config{
|
|
RootCAs: clientPool,
|
|
NextProtos: []string{"http/1.1"},
|
|
},
|
|
},
|
|
}
|
|
resp, err := client.Get("https://api.test.local/v1/test")
|
|
Expect(err).NotTo(HaveOccurred(), "Get")
|
|
defer func() { _ = resp.Body.Close() }()
|
|
Expect(resp.Proto).To(Equal("HTTP/1.1"))
|
|
body, _ := io.ReadAll(resp.Body)
|
|
Expect(string(body)).To(ContainSubstring(`"ok":true`))
|
|
})
|
|
})
|