diff options
| author | srdusr <[email protected]> | 2024-01-27 21:47:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-01-27 21:47:00 +0200 |
| commit | f2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a (patch) | |
| tree | 3850e9de9355e8ab3b99b68fb69e1ef6df50e0cf | |
| parent | 1125afc47b9d6e68d95d0ffdbb74514f4618e624 (diff) | |
| download | mitmux-f2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a.tar.gz mitmux-f2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a.zip | |
TLS interception: per-host leaf certs, terminate-and-resign MITM, native HTTP/2
Implements build-order step 2. CA gains LeafFor(host), signing and
caching per-host leaf certificates on demand. The proxy's CONNECT
handler now terminates TLS with the client using a matching leaf cert
instead of tunneling raw bytes, and forwards each request upstream
over its own independently negotiated TLS connection.
Client-side and upstream-side ALPN are negotiated separately rather
than one being forced to mirror the other: an http.Transport configured
via http2.ConfigureTransport auto-bridges HTTP/1.1 and HTTP/2 on each
side independently, so e.g. an HTTP/1.1-only client reaching an
HTTP/2-preferring origin still works instead of failing the handshake
(caught by testing curl --http1.1 against example.com before this fix).
Verified live: plain HTTP passthrough, HTTPS with default (H2) and
forced HTTP/1.1 clients, and that requests without the mitmux CA
trusted are correctly rejected.
| -rw-r--r-- | cmd/mitmuxd/main.go | 4 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | internal/ca/ca.go | 85 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 183 |
5 files changed, 250 insertions, 31 deletions
diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go index 400ca82..f79f2cf 100644 --- a/cmd/mitmuxd/main.go +++ b/cmd/mitmuxd/main.go @@ -34,9 +34,9 @@ func main() { if err != nil { log.Fatalf("load CA: %v", err) } - log.Printf("CA ready: %s", root.Cert.Subject.CommonName) + log.Printf("CA ready: %s (install %s/ca.pem in your client's trust store to avoid TLS warnings)", root.Cert.Subject.CommonName, dir) - srv := proxy.New(*listen) + srv := proxy.New(*listen, root) errCh := make(chan error, 1) go func() { @@ -1,3 +1,8 @@ module mitmux go 1.26.5 + +require ( + golang.org/x/net v0.58.0 // indirect + golang.org/x/text v0.41.0 // indirect +) @@ -0,0 +1,4 @@ +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= diff --git a/internal/ca/ca.go b/internal/ca/ca.go index 949c2fd..fc227a5 100644 --- a/internal/ca/ca.go +++ b/internal/ca/ca.go @@ -1,20 +1,23 @@ -// Package ca manages mitmux's root CA: generating it on first run and -// loading it on subsequent runs. Leaf certificate signing for TLS -// interception is added in a later build step. +// Package ca manages mitmux's root CA: generating it on first run, +// loading it on subsequent runs, and signing per-host leaf certificates +// on demand for TLS interception. package ca import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "fmt" "math/big" + "net" "os" "path/filepath" + "sync" "time" ) @@ -30,6 +33,82 @@ type CA struct { Key *ecdsa.PrivateKey CertPEM []byte KeyPEM []byte + + leafMu sync.Mutex + leafCache map[string]*tls.Certificate +} + +// leafLifetime is kept well under the ~398 day limit modern browsers +// enforce on leaf certificates. +const leafLifetime = 300 * 24 * time.Hour + +// LeafFor returns a TLS certificate for host (a DNS name or IP address, +// no port), signed by the CA. Certificates are generated once and cached +// in memory for the life of the process. +func (c *CA) LeafFor(host string) (*tls.Certificate, error) { + c.leafMu.Lock() + defer c.leafMu.Unlock() + + if c.leafCache == nil { + c.leafCache = make(map[string]*tls.Certificate) + } + if cert, ok := c.leafCache[host]; ok { + return cert, nil + } + + cert, err := c.signLeaf(host) + if err != nil { + return nil, err + } + c.leafCache[host] = cert + return cert, nil +} + +func (c *CA) signLeaf(host string) (*tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: host, + Organization: []string{"mitmux"}, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(leafLifetime), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: false, + } + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{host} + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, &key.PublicKey, c.Key) + if err != nil { + return nil, err + } + + leaf, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + + return &tls.Certificate{ + Certificate: [][]byte{der, c.Cert.Raw}, + PrivateKey: key, + Leaf: leaf, + }, nil } // Dir returns the directory mitmux stores its CA material in diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c9b6195..4fe9a4f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,16 +1,26 @@ -// Package proxy is the mitmux proxy engine: a forward HTTP proxy that, -// at this build stage, passes traffic through unmodified. CONNECT -// requests (HTTPS) are tunneled raw rather than intercepted - TLS -// interception is a later build step. +// Package proxy is the mitmux proxy engine: a forward HTTP proxy. +// Plain HTTP requests pass through unmodified. CONNECT requests (HTTPS) +// are intercepted: mitmux terminates TLS with the client using a leaf +// certificate signed by its own CA, and separately terminates TLS with +// the real server, forwarding requests between the two. ALPN is +// negotiated with the real server first and mirrored to the client so +// HTTP/2 connections stay HTTP/2 end to end rather than being downgraded. package proxy import ( "context" + "crypto/tls" + "errors" "io" "log" "net" "net/http" + "sync" "time" + + "golang.org/x/net/http2" + + "mitmux/internal/ca" ) // hopByHopHeaders are stripped before forwarding a request or response, @@ -32,14 +42,17 @@ var hopByHopHeaders = []string{ type Server struct { Addr string + ca *ca.CA transport *http.Transport server *http.Server } -// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"). -func New(addr string) *Server { +// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"), +// signing intercepted TLS connections with root. +func New(addr string, root *ca.CA) *Server { s := &Server{ Addr: addr, + ca: root, transport: &http.Transport{ Proxy: nil, DialContext: (&net.Dialer{ @@ -78,43 +91,161 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } -// handleConnect tunnels a CONNECT request raw, byte for byte, without -// terminating TLS. This is the passthrough behavior for HTTPS traffic -// until TLS interception is implemented. +// handleConnect intercepts a CONNECT request: it terminates TLS with the +// client using a leaf certificate signed by mitmux's CA, then forwards +// each request upstream over its own independently negotiated TLS +// connection. Client-side and upstream-side ALPN are negotiated +// separately (each offering both HTTP/2 and HTTP/1.1) rather than one +// being forced to match the other, so e.g. an HTTP/1.1-only client +// reaching an HTTP/2-only-preferring server doesn't fail to connect - +// http.Transport (via http2.ConfigureTransport) bridges the two sides +// independently per request. func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { - dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second) + hostPort := r.Host + hostname, _, err := net.SplitHostPort(hostPort) if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return + hostname = hostPort + hostPort = net.JoinHostPort(hostPort, "443") } - defer dst.Close() hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking not supported", http.StatusInternalServerError) return } - src, _, err := hijacker.Hijack() + client, _, err := hijacker.Hijack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - defer src.Close() - if _, err := src.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + client.Close() + return + } + + clientTLS := tls.Server(client, &tls.Config{ + GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := hello.ServerName + if name == "" { + name = hostname + } + return s.ca.LeafFor(name) + }, + NextProtos: []string{http2.NextProtoTLS, "http/1.1"}, + MinVersion: tls.VersionTLS12, + }) + if err := clientTLS.Handshake(); err != nil { + log.Printf("mitm handshake with client for %s: %v", hostname, err) + client.Close() + return + } + + tr := &http.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialUpstreamTLS(ctx, hostPort, hostname) + }, + } + if err := http2.ConfigureTransport(tr); err != nil { + log.Printf("configure h2 transport for %s: %v", hostname, err) + } + defer tr.CloseIdleConnections() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.forward(tr, "https", hostname, w, r) + }) + + if clientTLS.ConnectionState().NegotiatedProtocol == http2.NextProtoTLS { + (&http2.Server{}).ServeConn(clientTLS, &http2.ServeConnOpts{Handler: handler}) return } - done := make(chan struct{}, 2) - go func() { - io.Copy(dst, src) - done <- struct{}{} - }() - go func() { - io.Copy(src, dst) - done <- struct{}{} - }() - <-done + err = http.Serve(newSingleConnListener(clientTLS), handler) + if err != nil && !errors.Is(err, io.EOF) { + log.Printf("h1 serve for %s: %v", hostname, err) + } +} + +// dialUpstreamTLS connects to the real server, offering both HTTP/2 and +// HTTP/1.1 over ALPN and letting the server pick. +func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (*tls.Conn, error) { + dialer := &net.Dialer{Timeout: 10 * time.Second} + raw, err := dialer.DialContext(ctx, "tcp", hostPort) + if err != nil { + return nil, err + } + conn := tls.Client(raw, &tls.Config{ + ServerName: sni, + NextProtos: []string{http2.NextProtoTLS, "http/1.1"}, + }) + if err := conn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, err + } + return conn, nil +} + +// forward sends r upstream via rt and copies the response back to w, +// rewriting r's URL from origin-form (as read off the terminated TLS +// connection) to absolute-form for the round trip. +func (s *Server) forward(rt http.RoundTripper, scheme, hostname string, w http.ResponseWriter, r *http.Request) { + outReq := r.Clone(r.Context()) + outReq.URL.Scheme = scheme + outReq.URL.Host = hostname + outReq.RequestURI = "" + stripHopByHop(outReq.Header) + + resp, err := rt.RoundTrip(outReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + stripHopByHop(resp.Header) + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +// singleConnListener adapts one already-accepted net.Conn into a +// net.Listener so http.Serve can drive it, returning io.EOF from the +// second Accept once the connection closes. +type singleConnListener struct { + ch chan net.Conn + addr net.Addr +} + +func newSingleConnListener(c net.Conn) *singleConnListener { + ch := make(chan net.Conn, 1) + ch <- &closeSignalConn{Conn: c, onClose: sync.OnceFunc(func() { close(ch) })} + return &singleConnListener{ch: ch, addr: c.LocalAddr()} +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + c, ok := <-l.ch + if !ok { + return nil, io.EOF + } + return c, nil +} + +func (l *singleConnListener) Close() error { return nil } +func (l *singleConnListener) Addr() net.Addr { return l.addr } + +type closeSignalConn struct { + net.Conn + onClose func() +} + +func (c *closeSignalConn) Close() error { + err := c.Conn.Close() + c.onClose() + return err } // handleHTTP forwards a plain (non-CONNECT) proxy request and copies the |