summaryrefslogtreecommitdiff
path: root/internal/proxy/mitm2.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/proxy/mitm2.go')
-rw-r--r--internal/proxy/mitm2.go211
1 files changed, 211 insertions, 0 deletions
diff --git a/internal/proxy/mitm2.go b/internal/proxy/mitm2.go
new file mode 100644
index 0000000..fcf8e0f
--- /dev/null
+++ b/internal/proxy/mitm2.go
@@ -0,0 +1,211 @@
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "time"
+
+ "golang.org/x/net/http2"
+
+ "github.com/shiv/internal/events"
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/logger"
+)
+
+const (
+ h2UpstreamDialTimeout = 10 * time.Second
+ h2UpstreamHeaderTimeout = 30 * time.Second
+ // h2StreamRequestTimeout is a per-stream deadline, not a connection-level
+ // timeout. Using a context timeout per stream instead of a client-level
+ // Timeout means a single slow stream cannot kill the entire H2 connection.
+ h2StreamRequestTimeout = 60 * time.Second
+ // h2MaxConcurrentStreams matches the HTTP/2 spec default (RFC 7540 §6.5.2).
+ h2MaxConcurrentStreams = 250
+)
+
+func (p *Proxy) handleConnectH2(browserTLS *tls.Conn, connectReq *http.Request, bareHost string) {
+ defer recoverPanic("handleConnectH2 " + bareHost)
+
+ // Use the port-aware host from the original CONNECT request for upstream
+ // dialling so non-443 HTTPS ports are preserved.
+ upstreamHost := connectReq.Host
+
+ upstreamTransport := &http.Transport{
+ DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ conn, err := (&net.Dialer{Timeout: h2UpstreamDialTimeout}).DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("mitm/h2: dial %s: %w", addr, err)
+ }
+ tlsConn := tls.Client(conn, &tls.Config{
+ ServerName: bareHost,
+ InsecureSkipVerify: true, //nolint:gosec
+ NextProtos: []string{"h2", "http/1.1"},
+ VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
+ if len(rawCerts) == 0 {
+ return nil
+ }
+ cert, err := x509.ParseCertificate(rawCerts[0])
+ if err != nil {
+ return nil
+ }
+ now := time.Now()
+ if now.After(cert.NotAfter) {
+ logger.Info("mitm/h2: EXPIRED cert for %s (expired %s, issuer: %s)",
+ bareHost, cert.NotAfter.Format("2006-01-02"), cert.Issuer.CommonName)
+ } else if now.Before(cert.NotBefore) {
+ logger.Info("mitm/h2: NOT YET VALID cert for %s (valid from %s, issuer: %s)",
+ bareHost, cert.NotBefore.Format("2006-01-02"), cert.Issuer.CommonName)
+ }
+ if err := cert.VerifyHostname(bareHost); err != nil {
+ logger.Info("mitm/h2: hostname mismatch for %s: %v (CN: %s, SANs: %v)",
+ bareHost, err, cert.Subject.CommonName, cert.DNSNames)
+ }
+ return nil
+ },
+ })
+ if err := tlsConn.Handshake(); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("mitm/h2: upstream TLS handshake for %s: %w", addr, err)
+ }
+ return tlsConn, nil
+ },
+ ResponseHeaderTimeout: h2UpstreamHeaderTimeout,
+ }
+ if err := http2.ConfigureTransport(upstreamTransport); err != nil {
+ logger.Error("mitm/h2: configure upstream transport: %v", err)
+ }
+ defer upstreamTransport.CloseIdleConnections()
+
+ // No client-level Timeout — that would kill the entire H2 connection on
+ // any slow stream. Per-stream timeouts are applied via request contexts.
+ upstreamClient := &http.Client{
+ Transport: upstreamTransport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ streamHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ defer recoverPanic("h2 stream " + bareHost)
+
+ req.URL.Scheme = "https"
+ req.URL.Host = upstreamHost
+
+ reqBody, err := io.ReadAll(io.LimitReader(req.Body, maxBodySize))
+ if err != nil {
+ logger.Error("mitm/h2: read request body for %s: %v", bareHost, err)
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ req.Body.Close()
+
+ start := time.Now()
+
+ result := p.bus.EmitRequest(events.RequestEvent{Request: req, Body: reqBody})
+ if result.Drop {
+ // Stream is dropped by intercept rules; the H2 connection stays alive.
+ http.Error(w, "request dropped", http.StatusForbidden)
+ return
+ }
+ interceptedReq, reqBody := result.Request, result.Body
+
+ streamCtx, cancel := context.WithTimeout(req.Context(), h2StreamRequestTimeout)
+ defer cancel()
+
+ outReq, err := http.NewRequestWithContext(streamCtx, interceptedReq.Method, interceptedReq.URL.String(), bytes.NewReader(reqBody))
+ if err != nil {
+ logger.Error("mitm/h2: build upstream request for %s: %v", bareHost, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ outReq.Header = interceptedReq.Header.Clone()
+ outReq.Host = internalhttp.NormalizeHost(bareHost, true)
+ // Strip H2-illegal headers before forwarding.
+ outReq.Header.Del("Transfer-Encoding")
+ internalhttp.StripRequestCacheHeaders(outReq.Header)
+
+ resp, err := upstreamClient.Do(outReq)
+ if err != nil {
+ // context.Canceled means the browser cancelled the stream (RST_STREAM)
+ // or we hit our per-stream deadline. This is normal browser behaviour
+ // (prefetch, autocomplete, navigation away) — log at Debug, not Error.
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ logger.Debug("mitm/h2: stream cancelled/timed out for %s: %v", bareHost, err)
+ } else {
+ logger.Error("mitm/h2: upstream request for %s: %v", bareHost, err)
+ }
+ http.Error(w, "bad gateway", http.StatusBadGateway)
+ return
+ }
+
+ var respBuf bytes.Buffer
+ reader := io.TeeReader(io.LimitReader(resp.Body, maxBodySize), &respBuf)
+
+ // Strip Transfer-Encoding — illegal in HTTP/2 responses (RFC 7540 §8.1.2.2).
+ // Strip caching headers so the browser always fetches fresh content.
+ resp.Header.Del("Transfer-Encoding")
+ internalhttp.StripResponseCacheHeaders(resp.Header)
+
+ for k, vals := range resp.Header {
+ for _, v := range vals {
+ w.Header().Add(k, v)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+
+ if _, err := io.Copy(w, reader); err != nil {
+ logger.Debug("mitm/h2: write response to browser for %s: %v", bareHost, err)
+ // Drain whatever remains into respBuf so the log body is as complete
+ // as possible even if the browser disconnected mid-stream.
+ _, _ = io.Copy(&respBuf, resp.Body)
+ }
+
+ respBody := respBuf.Bytes()
+ resp.Body.Close()
+
+ if len(respBody) >= int(maxBodySize) {
+ logger.Debug("mitm/h2: response body truncated at %d bytes for %s %s",
+ maxBodySize, interceptedReq.Method, interceptedReq.URL)
+ }
+
+ elapsed := time.Since(start).Milliseconds()
+ logger.Info("%s %s %d %db %dms (h2)", interceptedReq.Method, interceptedReq.URL, resp.StatusCode, len(respBody), elapsed)
+
+ logBody := internalhttp.Decompress(resp.Header, respBody)
+ if internalhttp.IsBinary(resp.Header) {
+ logBody = nil
+ }
+
+ p.bus.EmitResponse(events.ResponseEvent{
+ Timestamp: start,
+ Host: connectReq.Host,
+ Proto: "HTTP/2",
+ Method: interceptedReq.Method,
+ URL: interceptedReq.URL.String(),
+ ReqHeaders: interceptedReq.Header,
+ ReqBody: reqBody,
+ StatusCode: resp.StatusCode,
+ RespHeaders: resp.Header,
+ RespBody: logBody,
+ DurationMs: elapsed,
+ TLS: true,
+ })
+ })
+
+ h2srv := &http2.Server{
+ MaxConcurrentStreams: h2MaxConcurrentStreams,
+ }
+ // ServeConn blocks until the browser closes the underlying TLS connection.
+ // It handles all stream multiplexing internally. No idle timer is needed —
+ // the connection lifetime is naturally tied to browserTLS.
+ h2srv.ServeConn(browserTLS, &http2.ServeConnOpts{
+ Handler: streamHandler,
+ })
+}