diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/proxy/mitm2.go | |
Initial release
Diffstat (limited to 'internal/proxy/mitm2.go')
| -rw-r--r-- | internal/proxy/mitm2.go | 211 |
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, + }) +} |
