fix(ldap): apply configured Root CA on StartTLS connections

LDAP IdP Root CA was only wired for ldaps:// dials, so StartTLS ignored
the uploaded PEM and private-PKI LDAP setups failed. Reuse the same
cert pool for StartTLS and cover both paths with connection tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Livio SpringandCursor committed 2026-07-15 10:07:52 +02:00
1 parent 305579dd07
commit 1308b81f92
2 files changed
+258 -6

No files matched your search

+22 -6
View File
@@ -107,12 +107,12 @@ func getConnection(
return nil, err
}
if u.Scheme == "ldaps" && len(rootCA) > 0 {
rootCAs := x509.NewCertPool()
if ok := rootCAs.AppendCertsFromPEM(rootCA); !ok {
return nil, ErrUnableToAppendRootCA
}
rootCAs, err := rootCAPool(rootCA)
if err != nil {
return nil, err
}
if u.Scheme == "ldaps" && rootCAs != nil {
dialer = append(dialer, ldap.DialWithTLSConfig(&tls.Config{
RootCAs: rootCAs,
}))
@@ -124,7 +124,10 @@ func getConnection(
}
if u.Scheme == "ldap" && startTLS {
err = conn.StartTLS(&tls.Config{ServerName: u.Host})
err = conn.StartTLS(&tls.Config{
ServerName: u.Hostname(),
RootCAs: rootCAs,
})
if err != nil {
return nil, err
}
@@ -132,6 +135,19 @@ func getConnection(
return conn, nil
}
// rootCAPool builds a certificate pool from a PEM-encoded root CA.
// Returns nil when rootCA is empty so callers keep the system trust store.
func rootCAPool(rootCA []byte) (*x509.CertPool, error) {
if len(rootCA) == 0 {
return nil, nil
}
rootCAs := x509.NewCertPool()
if ok := rootCAs.AppendCertsFromPEM(rootCA); !ok {
return nil, ErrUnableToAppendRootCA
}
return rootCAs, nil
}
func trySearchAndUserBind(
conn *ldap.Conn,
baseDN string,
@@ -0,0 +1,236 @@
package ldap
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"sync"
"testing"
"time"
ber "github.com/go-asn1-ber/asn1-ber"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRootCAPool(t *testing.T) {
t.Parallel()
_, caPEM, err := generateTestCert("localhost")
require.NoError(t, err)
t.Run("empty returns nil", func(t *testing.T) {
pool, err := rootCAPool(nil)
require.NoError(t, err)
assert.Nil(t, pool)
})
t.Run("valid pem", func(t *testing.T) {
pool, err := rootCAPool(caPEM)
require.NoError(t, err)
require.NotNil(t, pool)
})
t.Run("invalid pem", func(t *testing.T) {
pool, err := rootCAPool([]byte("not-a-cert"))
assert.ErrorIs(t, err, ErrUnableToAppendRootCA)
assert.Nil(t, pool)
})
}
func TestGetConnection_RootCA(t *testing.T) {
t.Parallel()
serverCert, caPEM, err := generateTestCert("127.0.0.1")
require.NoError(t, err)
t.Run("ldaps with rootCA succeeds", func(t *testing.T) {
t.Parallel()
addr := startMockLDAPS(t, serverCert)
conn, err := getConnection("ldaps://"+addr, false, time.Second, caPEM)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
})
t.Run("ldaps without rootCA fails for private CA", func(t *testing.T) {
t.Parallel()
addr := startMockLDAPS(t, serverCert)
conn, err := getConnection("ldaps://"+addr, false, time.Second, nil)
require.Error(t, err)
assert.Nil(t, conn)
})
t.Run("startTLS with rootCA succeeds", func(t *testing.T) {
t.Parallel()
addr := startMockLDAPStartTLS(t, serverCert)
conn, err := getConnection("ldap://"+addr, true, time.Second, caPEM)
require.NoError(t, err)
require.NotNil(t, conn)
conn.Close()
})
t.Run("startTLS without rootCA fails for private CA", func(t *testing.T) {
t.Parallel()
addr := startMockLDAPStartTLS(t, serverCert)
conn, err := getConnection("ldap://"+addr, true, time.Second, nil)
require.Error(t, err)
assert.Nil(t, conn)
})
}
func generateTestCert(commonName string) (tls.Certificate, []byte, error) {
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, nil, err
}
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, nil, err
}
now := time.Now()
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
return tls.Certificate{}, nil, err
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
return tls.Certificate{}, nil, err
}
serverTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: commonName},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{commonName},
}
if ip := net.ParseIP(commonName); ip != nil {
serverTemplate.IPAddresses = []net.IP{ip}
serverTemplate.DNSNames = nil
}
serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey)
if err != nil {
return tls.Certificate{}, nil, err
}
cert, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}),
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: mustMarshalEC(serverKey)}),
)
if err != nil {
return tls.Certificate{}, nil, err
}
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
return cert, caPEM, nil
}
func mustMarshalEC(key *ecdsa.PrivateKey) []byte {
b, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
return b
}
func startMockLDAPS(t *testing.T, cert tls.Certificate) string {
t.Helper()
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
require.NoError(t, err)
var once sync.Once
closeLn := func() { once.Do(func() { _ = ln.Close() }) }
t.Cleanup(closeLn)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
// Keep the TLS connection open briefly so the client handshake can finish.
buf := make([]byte, 1)
_, _ = c.Read(buf)
}(conn)
}
}()
return ln.Addr().String()
}
func startMockLDAPStartTLS(t *testing.T, cert tls.Certificate) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
var once sync.Once
closeLn := func() { once.Do(func() { _ = ln.Close() }) }
t.Cleanup(closeLn)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleStartTLSUpgrade(conn, cert)
}
}()
return ln.Addr().String()
}
func handleStartTLSUpgrade(conn net.Conn, cert tls.Certificate) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
packet, err := ber.ReadPacket(conn)
if err != nil {
return
}
if len(packet.Children) == 0 {
return
}
messageID := packet.Children[0].Value.(int64)
response := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
response.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "MessageID"))
extended := ber.Encode(ber.ClassApplication, ber.TypeConstructed, 24, nil, "Extended Response")
extended.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, 0, "Result Code"))
extended.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "Matched DN"))
extended.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "Diagnostic Message"))
response.AppendChild(extended)
if _, err := conn.Write(response.Bytes()); err != nil {
return
}
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
if err := tlsConn.Handshake(); err != nil {
return
}
defer tlsConn.Close()
buf := make([]byte, 1)
_, _ = tlsConn.Read(buf)
}