485 lines
15 KiB
Go
485 lines
15 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
ldapv3 "github.com/go-ldap/ldap/v3"
|
|
"github.com/miekg/dns"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// tlsProbeConfig builds a permissive *tls.Config for probing: hostname
|
|
// verification is skipped because cert validation is the TLS checker's
|
|
// job. We only care that a TLS session can be established at all.
|
|
func tlsProbeConfig(serverName string) *tls.Config {
|
|
return &tls.Config{
|
|
ServerName: serverName,
|
|
InsecureSkipVerify: true, //nolint:gosec -- cert validation is the TLS checker's job
|
|
MinVersion: tls.VersionTLS10,
|
|
}
|
|
}
|
|
|
|
// Collect runs the full LDAP probe for a domain.
|
|
func (p *ldapProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
|
domain, _ := sdk.GetOption[string](opts, "domain")
|
|
domain = strings.TrimSuffix(domain, ".")
|
|
if domain == "" {
|
|
return nil, fmt.Errorf("domain is required")
|
|
}
|
|
|
|
bindDN, _ := sdk.GetOption[string](opts, "bind_dn")
|
|
bindPassword, _ := sdk.GetOption[string](opts, "bind_password")
|
|
baseDN, _ := sdk.GetOption[string](opts, "base_dn")
|
|
|
|
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10)
|
|
if timeoutSecs < 1 {
|
|
timeoutSecs = 10
|
|
}
|
|
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
|
|
|
|
data := &LDAPData{
|
|
Domain: domain,
|
|
BaseDN: baseDN,
|
|
RunAt: time.Now().UTC().Format(time.RFC3339),
|
|
SRV: SRVLookup{Errors: map[string]string{}},
|
|
}
|
|
|
|
resolver := net.DefaultResolver
|
|
|
|
var srvWG sync.WaitGroup
|
|
var srvErrMu sync.Mutex
|
|
srvWG.Add(2)
|
|
lookup := func(prefix string, dst *[]SRVRecord) {
|
|
defer srvWG.Done()
|
|
records, err := lookupSRV(ctx, resolver, prefix, domain)
|
|
if err != nil {
|
|
srvErrMu.Lock()
|
|
data.SRV.Errors[prefix] = err.Error()
|
|
srvErrMu.Unlock()
|
|
return
|
|
}
|
|
*dst = records
|
|
}
|
|
go lookup("_ldap._tcp.", &data.SRV.LDAP)
|
|
go lookup("_ldaps._tcp.", &data.SRV.LDAPS)
|
|
srvWG.Wait()
|
|
|
|
totalSRV := len(data.SRV.LDAP) + len(data.SRV.LDAPS)
|
|
if totalSRV == 0 {
|
|
data.SRV.FallbackProbed = true
|
|
data.SRV.LDAP = []SRVRecord{{Target: domain, Port: 389}}
|
|
data.SRV.LDAPS = []SRVRecord{{Target: domain, Port: 636}}
|
|
}
|
|
|
|
resolveAll(ctx, resolver, data.SRV.LDAP, data.SRV.LDAPS)
|
|
|
|
data.BindTested = bindDN != "" && bindPassword != ""
|
|
|
|
var plainEndpoints, ldapsEndpoints []EndpointProbe
|
|
var probeWG sync.WaitGroup
|
|
probeWG.Add(2)
|
|
go func() {
|
|
defer probeWG.Done()
|
|
plainEndpoints = probeSet(ctx, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN)
|
|
}()
|
|
go func() {
|
|
defer probeWG.Done()
|
|
ldapsEndpoints = probeSet(ctx, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN)
|
|
}()
|
|
probeWG.Wait()
|
|
data.Endpoints = append(plainEndpoints, ldapsEndpoints...)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func probeSet(ctx context.Context, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) []EndpointProbe {
|
|
type task struct {
|
|
rec SRVRecord
|
|
addr *probeAddr // nil means the SRV target has no A/AAAA records
|
|
}
|
|
var tasks []task
|
|
for _, rec := range records {
|
|
addrs := addressesForProbe(rec)
|
|
if len(addrs) == 0 {
|
|
tasks = append(tasks, task{rec: rec})
|
|
continue
|
|
}
|
|
for _, a := range addrs {
|
|
tasks = append(tasks, task{rec: rec, addr: &a})
|
|
}
|
|
}
|
|
|
|
results := make([]EndpointProbe, len(tasks))
|
|
var wg sync.WaitGroup
|
|
for i, t := range tasks {
|
|
wg.Add(1)
|
|
go func(i int, t task) {
|
|
defer wg.Done()
|
|
if t.addr == nil {
|
|
results[i] = EndpointProbe{
|
|
Mode: mode,
|
|
SRVPrefix: prefix,
|
|
Target: t.rec.Target,
|
|
Port: t.rec.Port,
|
|
Error: "no A/AAAA records for target",
|
|
}
|
|
return
|
|
}
|
|
results[i] = probeEndpoint(ctx, domain, mode, prefix, t.rec, t.addr.ip, t.addr.isV6, timeout, bindDN, bindPassword, baseDN)
|
|
}(i, t)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
type probeAddr struct {
|
|
ip string
|
|
isV6 bool
|
|
}
|
|
|
|
func addressesForProbe(rec SRVRecord) []probeAddr {
|
|
var out []probeAddr
|
|
for _, ip := range rec.IPv4 {
|
|
out = append(out, probeAddr{ip: ip, isV6: false})
|
|
}
|
|
for _, ip := range rec.IPv6 {
|
|
out = append(out, probeAddr{ip: ip, isV6: true})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// probeEndpoint runs the full probe on a single (host, ip, port) tuple:
|
|
// TCP connect → optional StartTLS or direct-TLS handshake → RootDSE read →
|
|
// plaintext-bind posture check (only on LDAP:389 before TLS) → optional
|
|
// authenticated bind + base-DN read.
|
|
func probeEndpoint(ctx context.Context, domain string, mode LDAPMode, prefix string, rec SRVRecord, ip string, isV6 bool, timeout time.Duration, bindDN, bindPassword, baseDN string) EndpointProbe {
|
|
start := time.Now()
|
|
result := EndpointProbe{
|
|
Mode: mode,
|
|
SRVPrefix: prefix,
|
|
Target: rec.Target,
|
|
Port: rec.Port,
|
|
Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))),
|
|
IsIPv6: isV6,
|
|
}
|
|
defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }()
|
|
|
|
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
dialer := &net.Dialer{}
|
|
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
|
|
if err != nil {
|
|
result.Error = "tcp: " + err.Error()
|
|
return result
|
|
}
|
|
result.TCPConnected = true
|
|
defer rawConn.Close()
|
|
// renewDeadline keeps the underlying TCP deadline rolling per major
|
|
// step so a slow TLS handshake doesn't starve the RootDSE / bind /
|
|
// base-read calls that follow.
|
|
renewDeadline := func() { _ = rawConn.SetDeadline(time.Now().Add(timeout)) }
|
|
renewDeadline()
|
|
|
|
// For the plaintext-bind posture check on Mode=ldap, we first spin up
|
|
// a separate short-lived connection before upgrading this one to TLS.
|
|
// A single raw connection can't both "test cleartext bind refusal" and
|
|
// "then StartTLS" cleanly -- once we've bound we'd skew RootDSE results.
|
|
if mode == ModePlain {
|
|
result.PlaintextBindTested, result.PlaintextBindAccepted = probePlaintextBindRefusal(result.Address, timeout)
|
|
}
|
|
|
|
// Establish the LDAP session we'll use for the rest of the probe.
|
|
var conn *ldapv3.Conn
|
|
if mode == ModeLDAPS {
|
|
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
result.Error = "tls-handshake: " + err.Error()
|
|
return result
|
|
}
|
|
result.TLSEstablished = true
|
|
state := tlsConn.ConnectionState()
|
|
result.TLSVersion = tls.VersionName(state.Version)
|
|
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
|
renewDeadline()
|
|
conn = ldapv3.NewConn(tlsConn, true)
|
|
} else {
|
|
conn = ldapv3.NewConn(rawConn, false)
|
|
}
|
|
conn.Start()
|
|
defer conn.Close()
|
|
conn.SetTimeout(timeout)
|
|
|
|
// Try RootDSE over the native transport first -- works on LDAPS straight
|
|
// away, and on LDAP it reveals the supported extensions including
|
|
// StartTLS capability before we attempt the upgrade.
|
|
renewDeadline()
|
|
readRootDSE(conn, &result)
|
|
|
|
if mode == ModePlain {
|
|
// Detect StartTLS advertisement in supportedExtension. RFC 4511
|
|
// says the RootDSE MAY advertise "1.3.6.1.4.1.1466.20037".
|
|
offersStartTLS := stringListContainsFold(result.SupportedExtension, "1.3.6.1.4.1.1466.20037")
|
|
if offersStartTLS {
|
|
result.StartTLSOffered = true
|
|
if err := conn.StartTLS(tlsProbeConfig(domain)); err != nil {
|
|
result.Error = "starttls: " + err.Error()
|
|
} else {
|
|
result.StartTLSUpgraded = true
|
|
result.TLSEstablished = true
|
|
renewDeadline()
|
|
// go-ldap doesn't expose the *tls.ConnectionState directly.
|
|
// Fall back to inspecting the underlying conn via TLSConnectionState.
|
|
if state, ok := conn.TLSConnectionState(); ok {
|
|
result.TLSVersion = tls.VersionName(state.Version)
|
|
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
|
}
|
|
// Post-TLS SASL refresh: some servers only publish the
|
|
// strong mechanisms once the channel is encrypted. We only
|
|
// need the mechanisms list -- naming contexts, LDAP
|
|
// version and vendor strings don't change.
|
|
refreshSASLMechanisms(conn, &result)
|
|
}
|
|
} else if !result.RootDSERead {
|
|
result.Error = "rootdse-unreadable: RootDSE could not be read"
|
|
}
|
|
}
|
|
|
|
// Anonymous bind + search -- we try unconditionally so we can flag
|
|
// exposure.
|
|
renewDeadline()
|
|
anonBindOK := conn.Bind("", "") == nil
|
|
result.AnonymousBindAllowed = anonBindOK
|
|
if anonBindOK && len(result.NamingContexts) > 0 {
|
|
// baseObject search returns 0 or 1 entries -- we only want to
|
|
// detect whether an anonymous query can peek at DIT contents.
|
|
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
|
result.NamingContexts[0],
|
|
ldapv3.ScopeBaseObject,
|
|
ldapv3.NeverDerefAliases,
|
|
1, int(timeout.Seconds()), false,
|
|
"(objectClass=*)",
|
|
[]string{"1.1"}, // request no attributes
|
|
nil,
|
|
))
|
|
if err == nil && sr != nil && len(sr.Entries) > 0 {
|
|
result.AnonymousSearchAllowed = true
|
|
}
|
|
}
|
|
|
|
// Authenticated bind + base DN read (only when caller provided creds
|
|
// AND we are on an encrypted channel -- never ship a password over
|
|
// cleartext).
|
|
if bindDN != "" && bindPassword != "" && result.TLSEstablished {
|
|
result.BindAttempted = true
|
|
renewDeadline()
|
|
err := conn.Bind(bindDN, bindPassword)
|
|
if err == nil {
|
|
result.BindOK = true
|
|
if baseDN != "" {
|
|
result.BaseReadAttempted = true
|
|
renewDeadline()
|
|
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
|
baseDN,
|
|
ldapv3.ScopeBaseObject,
|
|
ldapv3.NeverDerefAliases,
|
|
1, int(timeout.Seconds()), false,
|
|
"(objectClass=*)",
|
|
[]string{"1.1"},
|
|
nil,
|
|
))
|
|
if err != nil {
|
|
result.BaseReadError = err.Error()
|
|
} else if sr != nil {
|
|
result.BaseReadOK = true
|
|
result.BaseReadEntries = len(sr.Entries)
|
|
}
|
|
}
|
|
} else {
|
|
result.BindError = err.Error()
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// probePlaintextBindRefusal opens a short-lived, fresh TCP connection and
|
|
// attempts a simple bind with a fixed, syntactically-safe DN over cleartext.
|
|
// We are not probing credentials -- we want to learn whether the server
|
|
// refuses authentication on an unprotected link (RFC 4513 §5.1.2 calls this
|
|
// "confidentialityRequired" / resultCode 13). Any response other than
|
|
// resultCode 13 means the server will accept cleartext bind attempts.
|
|
func probePlaintextBindRefusal(address string, timeout time.Duration) (tested, accepted bool) {
|
|
dialer := &net.Dialer{Timeout: timeout}
|
|
raw, err := dialer.Dial("tcp", address)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
defer raw.Close()
|
|
_ = raw.SetDeadline(time.Now().Add(timeout))
|
|
|
|
conn := ldapv3.NewConn(raw, false)
|
|
conn.Start()
|
|
defer conn.Close()
|
|
conn.SetTimeout(timeout)
|
|
|
|
tested = true
|
|
// Fixed probe DN -- caller-supplied domain is not interpolated to avoid
|
|
// LDAP DN injection. The server is expected to reject this DN regardless
|
|
// of value; we only care whether it returns confidentialityRequired (13)
|
|
// or attempts the bind anyway.
|
|
err = conn.Bind("cn=checker-probe", "x-not-a-real-password-x")
|
|
if err == nil {
|
|
return tested, true
|
|
}
|
|
// resultCode 13 (confidentialityRequired) is the only response that
|
|
// means the server actively refused to authenticate over cleartext.
|
|
// Anything else (49 invalidCredentials, 32 noSuchObject, …) means the
|
|
// server was willing to attempt the bind, which is the insecure
|
|
// posture we want to flag.
|
|
var lerr *ldapv3.Error
|
|
if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired {
|
|
return tested, false
|
|
}
|
|
return tested, true
|
|
}
|
|
|
|
// readRootDSE performs a single RootDSE lookup and fills the matching
|
|
// fields on ep. Failures are not fatal -- many hardened servers refuse
|
|
// anonymous RootDSE reads; we just note that we couldn't read it.
|
|
func readRootDSE(conn *ldapv3.Conn, ep *EndpointProbe) {
|
|
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
|
"",
|
|
ldapv3.ScopeBaseObject,
|
|
ldapv3.NeverDerefAliases,
|
|
1, 5, false,
|
|
"(objectClass=*)",
|
|
[]string{
|
|
"supportedLDAPVersion",
|
|
"supportedSASLMechanisms",
|
|
"supportedControl",
|
|
"supportedExtension",
|
|
"namingContexts",
|
|
"vendorName",
|
|
"vendorVersion",
|
|
},
|
|
nil,
|
|
))
|
|
if err != nil || sr == nil || len(sr.Entries) == 0 {
|
|
return
|
|
}
|
|
ep.RootDSERead = true
|
|
e := sr.Entries[0]
|
|
ep.SupportedLDAPVersion = unique(append(ep.SupportedLDAPVersion, e.GetAttributeValues("supportedLDAPVersion")...))
|
|
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, e.GetAttributeValues("supportedSASLMechanisms")...))
|
|
ep.SupportedControl = unique(append(ep.SupportedControl, e.GetAttributeValues("supportedControl")...))
|
|
ep.SupportedExtension = unique(append(ep.SupportedExtension, e.GetAttributeValues("supportedExtension")...))
|
|
ep.NamingContexts = unique(append(ep.NamingContexts, e.GetAttributeValues("namingContexts")...))
|
|
if v := e.GetAttributeValue("vendorName"); v != "" {
|
|
ep.VendorName = v
|
|
}
|
|
if v := e.GetAttributeValue("vendorVersion"); v != "" {
|
|
ep.VendorVersion = v
|
|
}
|
|
}
|
|
|
|
// refreshSASLMechanisms re-queries the RootDSE after StartTLS to pick up any
|
|
// SASL mechanisms the server only advertises over an encrypted channel.
|
|
func refreshSASLMechanisms(conn *ldapv3.Conn, ep *EndpointProbe) {
|
|
sr, err := conn.Search(ldapv3.NewSearchRequest(
|
|
"",
|
|
ldapv3.ScopeBaseObject,
|
|
ldapv3.NeverDerefAliases,
|
|
1, 5, false,
|
|
"(objectClass=*)",
|
|
[]string{"supportedSASLMechanisms"},
|
|
nil,
|
|
))
|
|
if err != nil || sr == nil || len(sr.Entries) == 0 {
|
|
return
|
|
}
|
|
ep.SupportedSASLMechanisms = unique(append(ep.SupportedSASLMechanisms, sr.Entries[0].GetAttributeValues("supportedSASLMechanisms")...))
|
|
}
|
|
|
|
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
|
name := prefix + dns.Fqdn(domain)
|
|
_, records, err := r.LookupSRV(ctx, "", "", name)
|
|
if err != nil {
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
// RFC 2782: single record "." with port 0 means "service explicitly not
|
|
// available at this domain". Treat that as "no records" for probing.
|
|
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]SRVRecord, 0, len(records))
|
|
for _, r := range records {
|
|
out = append(out, SRVRecord{
|
|
Target: strings.TrimSuffix(r.Target, "."),
|
|
Port: r.Port,
|
|
Priority: r.Priority,
|
|
Weight: r.Weight,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// resolveAll resolves A/AAAA for every record across all sets concurrently.
|
|
// Each goroutine writes only to its own record, so no lock is needed.
|
|
func resolveAll(ctx context.Context, r *net.Resolver, sets ...[]SRVRecord) {
|
|
var wg sync.WaitGroup
|
|
for _, records := range sets {
|
|
for i := range records {
|
|
wg.Add(1)
|
|
go func(rec *SRVRecord) {
|
|
defer wg.Done()
|
|
ips, err := r.LookupIPAddr(ctx, rec.Target)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, ip := range ips {
|
|
if v4 := ip.IP.To4(); v4 != nil {
|
|
rec.IPv4 = append(rec.IPv4, v4.String())
|
|
} else {
|
|
rec.IPv6 = append(rec.IPv6, ip.IP.String())
|
|
}
|
|
}
|
|
}(&records[i])
|
|
}
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func stringListContainsFold(list []string, want string) bool {
|
|
return slices.ContainsFunc(list, func(s string) bool { return strings.EqualFold(s, want) })
|
|
}
|
|
|
|
func unique(list []string) []string {
|
|
if len(list) <= 1 {
|
|
return list
|
|
}
|
|
seen := make(map[string]struct{}, len(list))
|
|
out := make([]string, 0, len(list))
|
|
for _, s := range list {
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|