diff options
| author | srdusr <[email protected]> | 2026-06-16 22:57:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-16 22:57:00 +0200 |
| commit | 23c8ab359c2108654d57176e233d2c099b398f31 (patch) | |
| tree | 3e2d1c66d987b4b771da69e67bd8a0c9ad2fc3db /internal | |
| parent | 6114567258bcad0517a0d881168711aaacdba5d5 (diff) | |
| download | mitmux-23c8ab359c2108654d57176e233d2c099b398f31.tar.gz mitmux-23c8ab359c2108654d57176e233d2c099b398f31.zip | |
Client (mutual-TLS) certificates
Adds internal/clientcert: a cert/key pair matched to hosts by the same
substring-or-regex pattern model as scope.Rule, so mitmux can present
a client certificate on an upstream TLS handshake that requires one -
the previous behavior was a hard handshake failure with no way to
authenticate. Wired into both places mitmux dials an https:// upstream
over its own TLS client connection: proxy.go's handleConnect (live
proxied traffic) and repeat.go's dialForRepeat (Repeater/Intruder
resends), both through a new Server.clientCertFor(host) helper.
Stored in a new client_certs table, mirroring the existing scope_rules
persistence pattern. The TUI (`t` from history) is add-only like
scope, for the same reason: delete and re-add covers changing
anything, and it's a rarely-touched, low-cardinality list. The add
form takes cert/key file paths and reads them once at save time - PEM
content, not the path, is what's stored and later presented, so a
cert keeps working even if the original file moves afterward.
Verified live against a real mutual-TLS-requiring origin server:
without a matching cert the handshake correctly fails; with one
configured, the origin receives it and the request succeeds; toggling
it off reproduces the failure, confirming the enable/disable path
works end to end.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/clientcert/clientcert.go | 62 | ||||
| -rw-r--r-- | internal/clientcert/clientcert_test.go | 111 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 75 | ||||
| -rw-r--r-- | internal/ipc/server.go | 47 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 43 | ||||
| -rw-r--r-- | internal/proxy/repeat.go | 15 | ||||
| -rw-r--r-- | internal/store/store.go | 64 |
7 files changed, 401 insertions, 16 deletions
diff --git a/internal/clientcert/clientcert.go b/internal/clientcert/clientcert.go new file mode 100644 index 0000000..4bba835 --- /dev/null +++ b/internal/clientcert/clientcert.go @@ -0,0 +1,62 @@ +// Package clientcert manages client (mutual-TLS) certificates: which +// certificate mitmux presents to an upstream server that requires one, +// selected by matching the request's hostname the same way scope rules +// do (see internal/scope) - substring match by default, or a regex - so +// the "which rule applies to this host" mental model stays identical +// throughout the tool. +package clientcert + +import ( + "crypto/tls" + "fmt" + "regexp" + "strings" +) + +// Cert is one client certificate, scoped to hosts matching Pattern. +type Cert struct { + ID int64 + Enabled bool + Name string + Pattern string + IsRegex bool + CertPEM []byte + KeyPEM []byte +} + +func (c Cert) matches(host string) bool { + if c.IsRegex { + re, err := regexp.Compile(c.Pattern) + if err != nil { + return false + } + return re.MatchString(host) + } + return strings.Contains(strings.ToLower(host), strings.ToLower(c.Pattern)) +} + +// FindFor returns the first enabled cert whose pattern matches host, or +// nil if none applies - mitmux then just doesn't present a client +// certificate for that connection, same as if mutual TLS weren't +// configured at all. First-match-wins on ID order, same convention as +// match-and-replace rules' Position ordering, minus the extra field: +// client certs are keyed by host, not layered edits, so insertion order +// is a reasonable enough tiebreaker without adding one. +func FindFor(certs []Cert, host string) *Cert { + for i := range certs { + if certs[i].Enabled && certs[i].matches(host) { + return &certs[i] + } + } + return nil +} + +// TLSCertificate parses c's PEM-encoded cert/key pair into the form +// crypto/tls needs to present it during a handshake. +func (c Cert) TLSCertificate() (tls.Certificate, error) { + cert, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM) + if err != nil { + return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", c.Name, err) + } + return cert, nil +} diff --git a/internal/clientcert/clientcert_test.go b/internal/clientcert/clientcert_test.go new file mode 100644 index 0000000..712562b --- /dev/null +++ b/internal/clientcert/clientcert_test.go @@ -0,0 +1,111 @@ +package clientcert + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// selfSignedPEM generates a throwaway self-signed cert/key pair entirely +// in memory (crypto/tls + crypto/x509 stdlib only) rather than shelling +// out to openssl or checking in a fixture - keeps the test hermetic and +// portable. +func selfSignedPEM(t *testing.T) (certPEM, keyPEM []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + var certBuf, keyBuf bytes.Buffer + pem.Encode(&certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) + pem.Encode(&keyBuf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return certBuf.Bytes(), keyBuf.Bytes() +} + +func TestFindForExactMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "internal.example.com"}} + got := FindFor(certs, "internal.example.com") + if got == nil || got.ID != 1 { + t.Errorf("got %+v, want match on id 1", got) + } +} + +func TestFindForSubstringMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}} + if FindFor(certs, "api.example.com") == nil { + t.Error("expected subdomain to match by substring, same convention as scope.Rule") + } +} + +func TestFindForRegex(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: `^api-\d+\.example\.com$`, IsRegex: true}} + if FindFor(certs, "api-42.example.com") == nil { + t.Error("expected regex pattern to match") + } + if FindFor(certs, "api-x.example.com") != nil { + t.Error("expected regex pattern not to match a non-numeric suffix") + } +} + +func TestFindForSkipsDisabled(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: false, Pattern: "example.com"}} + if FindFor(certs, "example.com") != nil { + t.Error("expected a disabled cert not to match") + } +} + +func TestFindForNoMatch(t *testing.T) { + certs := []Cert{{ID: 1, Enabled: true, Pattern: "example.com"}} + if FindFor(certs, "other.org") != nil { + t.Error("expected no match for an unrelated host") + } +} + +func TestFindForFirstMatchWins(t *testing.T) { + certs := []Cert{ + {ID: 1, Enabled: true, Pattern: "example.com"}, + {ID: 2, Enabled: true, Pattern: "example.com"}, + } + got := FindFor(certs, "example.com") + if got == nil || got.ID != 1 { + t.Errorf("got %+v, want the first matching cert (id 1)", got) + } +} + +func TestTLSCertificateValidPair(t *testing.T) { + certPEM, keyPEM := selfSignedPEM(t) + c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: keyPEM} + if _, err := c.TLSCertificate(); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestTLSCertificateMismatchedPairErrors(t *testing.T) { + certPEM, _ := selfSignedPEM(t) + _, otherKeyPEM := selfSignedPEM(t) + c := Cert{Name: "test", CertPEM: certPEM, KeyPEM: otherKeyPEM} + if _, err := c.TLSCertificate(); err == nil { + t.Error("expected an error pairing a cert with a key that doesn't match it") + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index d581313..68217c2 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "mitmux/internal/clientcert" "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" @@ -69,6 +70,13 @@ type Request struct { ScopeRule *scope.Rule `json:"scope_rule,omitempty"` ScopeRuleID int64 `json:"scope_rule_id,omitempty"` + // For "clientcert_add": the new certificate (always an add, same + // reasoning as scope rules above). For "clientcert_delete"/ + // "clientcert_toggle": ClientCertID (and RuleEnabled for toggle) + // identify the target. + ClientCert *clientcert.Cert `json:"client_cert,omitempty"` + ClientCertID int64 `json:"client_cert_id,omitempty"` + // For "set_flagged" and "delete_entry": ID identifies the history // entry. "clear_history" needs no fields at all. Flagged bool `json:"flagged,omitempty"` @@ -99,13 +107,14 @@ type ImportEntry struct { // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error" - Entries []store.Summary `json:"entries,omitempty"` // for "list" - Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" - New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) - Rules []rules.Rule `json:"rules,omitempty"` // for "rules" - ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules" - Status *StatusMsg `json:"status,omitempty"` // for "status" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "scope_rules", "intrude_result", "intrude_done", "import_done", "status", "flagged", "deleted", "cleared", or "error" + Entries []store.Summary `json:"entries,omitempty"` // for "list" + Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" + New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) + Rules []rules.Rule `json:"rules,omitempty"` // for "rules" + ScopeRules []scope.Rule `json:"scope_rules,omitempty"` // for "scope_rules" + ClientCerts []clientcert.Cert `json:"client_certs,omitempty"` // for "client_certs" + Status *StatusMsg `json:"status,omitempty"` // for "status" // For "import_done": how many entries were actually inserted (a // per-entry insert failure is skipped, not fatal to the batch). @@ -444,6 +453,58 @@ func (c *Client) scopeRoundTrip(req Request) ([]scope.Rule, error) { return resp.ScopeRules, nil } +// ListClientCerts returns every client certificate, enabled or not. +func (c *Client) ListClientCerts() ([]clientcert.Cert, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.clientCertRoundTrip(Request{Type: "clientcert_list"}) +} + +// AddClientCert adds cert and returns its assigned ID. +func (c *Client) AddClientCert(cert clientcert.Cert) (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + saved, err := c.clientCertRoundTrip(Request{Type: "clientcert_add", ClientCert: &cert}) + if err != nil { + return 0, err + } + if len(saved) == 0 { + return 0, errors.New("clientcert_add: daemon returned no certificate") + } + return saved[0].ID, nil +} + +// DeleteClientCert removes a client certificate. +func (c *Client) DeleteClientCert(id int64) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.clientCertRoundTrip(Request{Type: "clientcert_delete", ClientCertID: id}) + return err +} + +// SetClientCertEnabled toggles a client cert without touching its +// content. +func (c *Client) SetClientCertEnabled(id int64, enabled bool) error { + c.mu.Lock() + defer c.mu.Unlock() + _, err := c.clientCertRoundTrip(Request{Type: "clientcert_toggle", ClientCertID: id, RuleEnabled: enabled}) + return err +} + +func (c *Client) clientCertRoundTrip(req Request) ([]clientcert.Cert, error) { + if err := c.enc.Encode(req); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.ClientCerts, nil +} + // Subscribe opens a dedicated connection that streams newly captured // history entries as they happen. The returned channel is closed when // the connection ends; call the returned close func to stop early. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index c890a54..4db27b9 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -9,6 +9,7 @@ import ( "regexp" "sync" + "mitmux/internal/clientcert" "mitmux/internal/proxy" "mitmux/internal/rules" "mitmux/internal/scope" @@ -347,6 +348,52 @@ func (s *Server) handleConn(conn net.Conn) { } enc.Encode(Response{Type: "scope_rules"}) + case "clientcert_list": + certs, err := s.db.ListClientCerts() + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs", ClientCerts: certs}) + + case "clientcert_add": + if req.ClientCert == nil { + enc.Encode(Response{Type: "error", Error: "clientcert_add: missing certificate"}) + continue + } + cert := *req.ClientCert + if cert.IsRegex { + if _, err := regexp.Compile(cert.Pattern); err != nil { + enc.Encode(Response{Type: "error", Error: "invalid regex: " + err.Error()}) + continue + } + } + if _, err := cert.TLSCertificate(); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + id, err := s.db.AddClientCert(cert) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + cert.ID = id + enc.Encode(Response{Type: "client_certs", ClientCerts: []clientcert.Cert{cert}}) + + case "clientcert_delete": + if err := s.db.DeleteClientCert(req.ClientCertID); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs"}) + + case "clientcert_toggle": + if err := s.db.SetClientCertEnabled(req.ClientCertID, req.RuleEnabled); err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "client_certs"}) + case "subscribe": sub := s.hub.subscribe() defer s.hub.unsubscribe(sub) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index aec724a..bc22bcf 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -39,6 +39,7 @@ import ( "golang.org/x/net/http2" "mitmux/internal/ca" + "mitmux/internal/clientcert" "mitmux/internal/rules" "mitmux/internal/scope" "mitmux/internal/store" @@ -228,7 +229,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { client.SetDeadline(time.Time{}) dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy) + return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy, s.clientCertFor(hostname)) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.forward(dial, "https", hostname, w, r) @@ -256,16 +257,22 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { // HTTP/2 and HTTP/1.1 over ALPN and letting the server pick. Chaining // through another proxy is transparent to everything from here on: once // the CONNECT tunnel is up, TLS and the request/response on top of it -// look identical to a direct connection. -func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (net.Conn, string, error) { +// look identical to a direct connection. cert, if non-nil, is presented +// during the handshake for servers that require mutual TLS - see +// clientCertFor. +func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string, cert *tls.Certificate) (net.Conn, string, error) { raw, err := dialViaProxy(ctx, hostPort, upstreamProxy) if err != nil { return nil, "", err } - conn := tls.Client(raw, &tls.Config{ + cfg := &tls.Config{ ServerName: sni, NextProtos: []string{http2.NextProtoTLS, "http/1.1"}, - }) + } + if cert != nil { + cfg.Certificates = []tls.Certificate{*cert} + } + conn := tls.Client(raw, cfg) if err := conn.HandshakeContext(ctx); err != nil { raw.Close() return nil, "", err @@ -273,6 +280,32 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) ( return conn, conn.ConnectionState().NegotiatedProtocol, nil } +// clientCertFor returns the client certificate configured for host, if +// any - see clientcert.FindFor. Errors (a bad DB read, an unparseable +// PEM pair) are logged and treated as "no certificate configured" rather +// than failing the connection outright: a broken client-cert config +// shouldn't take down otherwise-working proxying for that host. +func (s *Server) clientCertFor(host string) *tls.Certificate { + if s.store == nil { + return nil + } + certs, err := s.store.ListClientCerts() + if err != nil { + log.Printf("list client certs: %v", err) + return nil + } + c := clientcert.FindFor(certs, host) + if c == nil { + return nil + } + tc, err := c.TLSCertificate() + if err != nil { + log.Printf("client cert %q: %v", c.Name, err) + return nil + } + return &tc +} + // dialUpstreamPlain connects to a plain (non-TLS) upstream for the // non-CONNECT proxy path, which is always HTTP/1.1. Unlike the TLS/ // CONNECT path, chaining here means dialing the upstream proxy's own diff --git a/internal/proxy/repeat.go b/internal/proxy/repeat.go index f682b99..8e7836d 100644 --- a/internal/proxy/repeat.go +++ b/internal/proxy/repeat.go @@ -36,7 +36,7 @@ func (s *Server) sendRaw(ctx context.Context, scheme, host string, raw []byte, s started := time.Now() method, path := parseRequestLine(raw) - conn, err := dialForRepeat(ctx, scheme, host) + conn, err := s.dialForRepeat(ctx, scheme, host) if err != nil { return s.recordRaw(started, time.Since(started), scheme, host, method, path, raw, nil, false, false, 0, err.Error(), source) } @@ -101,8 +101,11 @@ func (s *Server) recordRaw(started time.Time, duration time.Duration, scheme, ho } // dialForRepeat connects to host for scheme, forcing HTTP/1.1 over ALPN -// when TLS is involved (see Repeat's doc comment for why). -func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { +// when TLS is involved (see Repeat's doc comment for why), presenting a +// client certificate if one is configured for hostname (see +// clientCertFor) - a resent or fuzzed request against a mutual-TLS host +// needs one just as much as a live proxied request does. +func (s *Server) dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { nd := &net.Dialer{Timeout: 10 * time.Second} if scheme != "https" { hostPort := host @@ -122,7 +125,11 @@ func dialForRepeat(ctx context.Context, scheme, host string) (net.Conn, error) { if err != nil { return nil, err } - conn := tls.Client(raw, &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}}) + cfg := &tls.Config{ServerName: hostname, NextProtos: []string{"http/1.1"}} + if cert := s.clientCertFor(hostname); cert != nil { + cfg.Certificates = []tls.Certificate{*cert} + } + conn := tls.Client(raw, cfg) if err := conn.HandshakeContext(ctx); err != nil { raw.Close() return nil, err diff --git a/internal/store/store.go b/internal/store/store.go index befd5a6..0e57c3c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,7 @@ import ( _ "modernc.org/sqlite" + "mitmux/internal/clientcert" "mitmux/internal/rules" "mitmux/internal/scope" ) @@ -63,6 +64,16 @@ CREATE TABLE IF NOT EXISTS scope_rules ( pattern TEXT NOT NULL, is_regex INTEGER NOT NULL DEFAULT 0 ); + +CREATE TABLE IF NOT EXISTS client_certs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + enabled INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL DEFAULT '', + pattern TEXT NOT NULL, + is_regex INTEGER NOT NULL DEFAULT 0, + cert_pem BLOB NOT NULL, + key_pem BLOB NOT NULL +); ` // Store is a handle to the history database. Safe for concurrent use. @@ -619,6 +630,59 @@ func (s *Store) DeleteScopeRule(id int64) error { return nil } +// ListClientCerts returns every client certificate, including disabled +// ones (the management view needs to show and let you re-enable those +// too). +func (s *Store) ListClientCerts() ([]clientcert.Cert, error) { + rows, err := s.db.Query(`SELECT id, enabled, name, pattern, is_regex, cert_pem, key_pem FROM client_certs ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list client certs: %w", err) + } + defer rows.Close() + + var out []clientcert.Cert + for rows.Next() { + var c clientcert.Cert + var enabled, isRegex int + if err := rows.Scan(&c.ID, &enabled, &c.Name, &c.Pattern, &isRegex, &c.CertPEM, &c.KeyPEM); err != nil { + return nil, fmt.Errorf("scan client cert row: %w", err) + } + c.Enabled = enabled != 0 + c.IsRegex = isRegex != 0 + out = append(out, c) + } + return out, rows.Err() +} + +// AddClientCert stores c and returns its assigned ID. +func (s *Store) AddClientCert(c clientcert.Cert) (int64, error) { + res, err := s.db.Exec( + `INSERT INTO client_certs (enabled, name, pattern, is_regex, cert_pem, key_pem) VALUES (?, ?, ?, ?, ?, ?)`, + boolToInt(c.Enabled), c.Name, c.Pattern, boolToInt(c.IsRegex), c.CertPEM, c.KeyPEM, + ) + if err != nil { + return 0, fmt.Errorf("add client cert: %w", err) + } + return res.LastInsertId() +} + +// SetClientCertEnabled toggles a client cert without touching its +// content. +func (s *Store) SetClientCertEnabled(id int64, enabled bool) error { + if _, err := s.db.Exec(`UPDATE client_certs SET enabled = ? WHERE id = ?`, boolToInt(enabled), id); err != nil { + return fmt.Errorf("set client cert %d enabled: %w", id, err) + } + return nil +} + +// DeleteClientCert removes a client certificate. +func (s *Store) DeleteClientCert(id int64) error { + if _, err := s.db.Exec(`DELETE FROM client_certs WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete client cert %d: %w", id, err) + } + return nil +} + // DeleteEntry removes a single history entry and its search index row. func (s *Store) DeleteEntry(id int64) error { tx, err := s.db.Begin() |