From 430a2f48bb8243966e8d35ad1fa37da60845887e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 6 Apr 2026 04:48:11 +0200 Subject: [PATCH] proxy: support HTTP/2 via ALPN, tunnel h2 connections transparently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client (e.g. the Cursor CLI, gRPC-based tools) negotiates HTTP/2 via ALPN during the TLS handshake, the existing MITM proxy fails with "malformed HTTP request/response" errors because it tries to parse HTTP/2 binary frames as HTTP/1.1 text. Fix: - Advertise both "h2" and "http/1.1" in NextProtos on the server-side TLS config so that ALPN negotiation completes successfully. - After the handshake, check NegotiatedProtocol. If "h2", hand off to the new tunnelH2() function instead of the HTTP/1.1 MITM path. - tunnelH2() opens a fresh TLS connection to the origin (also with h2 in NextProtos), then pipes the two sides together with io.Copy — a transparent tunnel that preserves all HTTP/2 semantics. Policy enforcement (connect allow/deny) is unchanged: it still happens in handleTransparentHTTPS before the tunnel is established, so h2 traffic is still subject to Cedar policy decisions. Full h2 MITM (header inspection, rewriting) is left as a future improvement; transparent tunneling is sufficient to unblock h2 clients. Made-with: Cursor --- internal/proxy/proxy.go | 79 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index b0271c9..254ed65 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -512,8 +512,14 @@ func (p *MITMProxy) handleTransparentHTTPS(clientConn net.Conn, originalDest str var actualHostname string - // Wrap the connection with TLS using dynamic certificate generation + // Wrap the connection with TLS using dynamic certificate generation. + // NextProtos advertises both h2 and http/1.1 so that clients using ALPN + // (e.g. the Cursor CLI, gRPC) complete the handshake successfully. After + // the handshake we inspect NegotiatedProtocol and tunnel h2 connections + // transparently instead of attempting HTTP/1.1 MITM (which would fail with + // "malformed HTTP request/response" errors on the HTTP/2 binary framing). tlsConfig := &tls.Config{ + NextProtos: []string{"h2", "http/1.1"}, GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { // Use the SNI (Server Name Indication) to get the correct hostname actualHostname = hello.ServerName @@ -540,6 +546,15 @@ func (p *MITMProxy) handleTransparentHTTPS(clientConn net.Conn, originalDest str return } + // If the client negotiated HTTP/2 via ALPN, tunnel the connection + // transparently to the origin. Full h2 MITM (framing, HPACK, flow control) + // is complex; a transparent tunnel preserves all h2 semantics while still + // enforcing the connect policy that was already checked above. + if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { + p.tunnelH2(tlsConn, originalDest, actualHostname) + return + } + // Use the hostname from SNI if available, otherwise fall back to originalDest targetHost := actualHostname if targetHost == "" || net.ParseIP(targetHost) != nil { @@ -635,6 +650,68 @@ func (p *MITMProxy) handleTransparentHTTPS(clientConn net.Conn, originalDest str } } +// tunnelH2 transparently tunnels an HTTP/2 connection to the origin without +// MITM. TLS is terminated on the client side (so the Leash CA cert is still +// presented to the client), then a fresh TLS connection is opened to the origin +// with h2 in NextProtos, and the two sides are piped together. +// +// Policy enforcement (connect allow/deny) has already happened in +// handleTransparentHTTPS before this function is called. +func (p *MITMProxy) tunnelH2(clientConn net.Conn, originalDest, targetHostname string) { + targetHost := originalDest + if targetHostname != "" { + _, port, err := net.SplitHostPort(originalDest) + if err == nil { + targetHost = net.JoinHostPort(targetHostname, port) + } + } + + markedDialer := createMarkedDialer() + rawConn, err := markedDialer.DialContext(context.Background(), "tcp", targetHost) + if err != nil { + log.Printf("h2 tunnel: failed to connect to %s: %v", targetHost, err) + return + } + defer rawConn.Close() + + serverName := targetHostname + if serverName == "" { + if h, _, err := net.SplitHostPort(targetHost); err == nil { + serverName = strings.Trim(h, "[]") + } + } + + originConn := tls.Client(rawConn, &tls.Config{ + ServerName: serverName, + NextProtos: []string{"h2", "http/1.1"}, + }) + if err := originConn.Handshake(); err != nil { + log.Printf("h2 tunnel: TLS handshake to origin %s failed: %v", targetHost, err) + return + } + defer originConn.Close() + + log.Printf("h2 tunnel: %s <-> %s (origin negotiated: %s)", + clientConn.RemoteAddr(), targetHost, + originConn.ConnectionState().NegotiatedProtocol) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + io.Copy(originConn, clientConn) //nolint:errcheck + originConn.CloseWrite() + }() + go func() { + defer wg.Done() + io.Copy(clientConn, originConn) //nolint:errcheck + if tc, ok := clientConn.(*tls.Conn); ok { + tc.CloseWrite() + } + }() + wg.Wait() +} + func (p *MITMProxy) forwardTransparentHTTPS(clientConn net.Conn, req *http.Request, targetHost string, mcpCtx *mcpRequestContext) (int, string, string, error) { // Create marked TLS connection to target to avoid proxy loops dialer := p.tlsDialer