Skip to content

Instantly share code, notes, and snippets.

@moscowchill
Created February 9, 2026 07:39
Show Gist options
  • Select an option

  • Save moscowchill/9566c79c76c0b64c57f8bd0716f97c48 to your computer and use it in GitHub Desktop.

Select an option

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
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