mic.git


mic

~ proxy for JA4-TLS fingerprinting evasion
git clone https://github.com/djnnvx/mic.git
log | files | refs | archive | README | LICENSE

commit 28f1125de79d403e838aabb4d8cf0499a4661df8
parent e379aef8c513cbc4094bb64bfe5af017583a3b17
Author: djnn <[email protected]>
Date: Wed, 26 Aug 2026 14:57:32 +0900

fix(cmd): validate CA file and intercept flag pair

Diffstat:
Mcmd/client.go | 27++++++++++++++++++---------
Acmd/client_test.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/root.go | 16++++++++++++++++
Mcmd/server.go | 8++------

4 files changed, 90 insertions(+), 15 deletions(-)

diff --git a/cmd/client.go b/cmd/client.go
@@ -1,16 +1,24 @@
 package cmd
 
 import (
-	"crypto/x509"
 	"fmt"
 	"log"
-	"os"
+	"strings"
 
 	"github.com/djnnvx/mic/fingerprint"
 	"github.com/djnnvx/mic/proxy"
 	"github.com/spf13/cobra"
 )
 
+// proxyURL renders a listen address as something a user can paste into curl -x.
+// A bare ":8080" needs a host.
+func proxyURL(listen string) string {
+	if strings.HasPrefix(listen, ":") {
+		return "localhost" + listen
+	}
+	return listen
+}
+
 func newClientCmd() *cobra.Command {
 	var (
 		listen        string
@@ -27,12 +35,10 @@ func newClientCmd() *cobra.Command {
 			p := &proxy.Proxy{ListenAddr: listen}
 
 			if caPath != "" {
-				caCert, err := os.ReadFile(caPath)
+				pool, err := loadCAPool(caPath)
 				if err != nil {
-					return fmt.Errorf("reading CA certificate: %w", err)
+					return err
 				}
-				pool := x509.NewCertPool()
-				pool.AppendCertsFromPEM(caCert)
 				p.CAPool = pool
 			}
 
@@ -45,14 +51,17 @@ func newClientCmd() *cobra.Command {
 				log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
 			}
 
-			if interceptCert != "" || interceptKey != "" {
+			if (interceptCert == "") != (interceptKey == "") {
+				return fmt.Errorf("--intercept-cert and --intercept-key must be given together")
+			}
+			if interceptCert != "" {
 				ca, err := proxy.LoadOrGenerateCA(interceptCert, interceptKey)
 				if err != nil {
 					return fmt.Errorf("loading/generating intercept CA: %w", err)
 				}
 				p.LocalCA = ca
-				log.Printf("[+] MitM CA ready. Import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
-					interceptCert, interceptCert, listen)
+				log.Printf("[+] MitM CA ready. Import %s, then: curl --cacert %s -x http://%s https://<target>",
+					interceptCert, interceptCert, proxyURL(listen))
 			}
 
 			log.Printf("[+] Mode: client (HTTP CONNECT proxy on %s)", listen)
diff --git a/cmd/client_test.go b/cmd/client_test.go
@@ -0,0 +1,54 @@
+package cmd
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// run executes the client command with args, using an unroutable listen port so
+// that anything reaching p.Run() fails there instead of binding a real socket.
+func runClient(t *testing.T, args ...string) error {
+	t.Helper()
+	c := newClientCmd()
+	c.SetOut(os.NewFile(0, os.DevNull))
+	c.SetErr(os.NewFile(0, os.DevNull))
+	c.SetArgs(append([]string{"--listen", "127.0.0.1:99999"}, args...))
+	return c.Execute()
+}
+
+func TestGarbageCAFileIsRefused(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "ca.pem")
+	if err := os.WriteFile(path, []byte("this is not a certificate"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	err := runClient(t, "--ca", path)
+	if err == nil || !strings.Contains(err.Error(), "no certificates found") {
+		t.Errorf("garbage CA accepted; got err=%v, want one naming the bad file", err)
+	}
+}
+
+func TestHalfSpecifiedInterceptPairIsRefused(t *testing.T) {
+	dir := t.TempDir()
+	for _, flag := range []string{"--intercept-cert", "--intercept-key"} {
+		err := runClient(t, flag, filepath.Join(dir, "x.pem"))
+		if err == nil || !strings.Contains(err.Error(), "must be given together") {
+			t.Errorf("%s alone accepted; got err=%v", flag, err)
+		}
+		if _, statErr := os.Stat(filepath.Join(dir, "x.pem")); statErr == nil {
+			t.Errorf("%s alone wrote a CA file it should have refused to create", flag)
+		}
+	}
+}
+
+func TestProxyURL(t *testing.T) {
+	for in, want := range map[string]string{
+		":8080":          "localhost:8080",
+		"127.0.0.1:8080": "127.0.0.1:8080",
+	} {
+		if got := proxyURL(in); got != want {
+			t.Errorf("proxyURL(%q) = %q; want %q", in, got, want)
+		}
+	}
+}
diff --git a/cmd/root.go b/cmd/root.go
@@ -1,12 +1,28 @@
 package cmd
 
 import (
+	"crypto/x509"
 	"fmt"
 	"os"
 
 	"github.com/spf13/cobra"
 )
 
+// loadCAPool reads a PEM bundle into a cert pool. AppendCertsFromPEM reports
+// failure only through its return value, and an empty pool makes every upstream
+// handshake fail with "unknown authority" instead of naming the real cause.
+func loadCAPool(path string) (*x509.CertPool, error) {
+	pem, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("reading CA certificate: %w", err)
+	}
+	pool := x509.NewCertPool()
+	if !pool.AppendCertsFromPEM(pem) {
+		return nil, fmt.Errorf("no certificates found in %s", path)
+	}
+	return pool, nil
+}
+
 var SKID_ASCII_ART = `
 
 
diff --git a/cmd/server.go b/cmd/server.go
@@ -1,11 +1,9 @@
 package cmd
 
 import (
-	"crypto/x509"
 	"errors"
 	"fmt"
 	"log"
-	"os"
 
 	"github.com/djnnvx/mic/fingerprint"
 	"github.com/djnnvx/mic/proxy"
@@ -39,12 +37,10 @@ func newServerCmd() *cobra.Command {
 			}
 
 			if caPath != "" {
-				caCert, err := os.ReadFile(caPath)
+				pool, err := loadCAPool(caPath)
 				if err != nil {
-					return fmt.Errorf("reading CA certificate: %w", err)
+					return err
 				}
-				pool := x509.NewCertPool()
-				pool.AppendCertsFromPEM(caCert)
 				p.CAPool = pool
 			}