package checker import ( "context" "crypto/tls" "errors" "fmt" "net" "strconv" "strings" "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 lookupSets := []struct { prefix string dst *[]SRVRecord }{ {"_ldap._tcp.", &data.SRV.LDAP}, {"_ldaps._tcp.", &data.SRV.LDAPS}, } for _, ls := range lookupSets { records, err := lookupSRV(ctx, resolver, ls.prefix, domain) if err != nil { data.SRV.Errors[ls.prefix] = err.Error() continue } *ls.dst = records } 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}} } resolveAllInto(ctx, resolver, data.SRV.LDAP) resolveAllInto(ctx, resolver, data.SRV.LDAPS) wantBind := bindDN != "" && bindPassword != "" if wantBind { data.BindTested = true } probeSet(ctx, data, domain, ModePlain, "_ldap._tcp", data.SRV.LDAP, perEndpoint, bindDN, bindPassword, baseDN) probeSet(ctx, data, domain, ModeLDAPS, "_ldaps._tcp", data.SRV.LDAPS, perEndpoint, bindDN, bindPassword, baseDN) computeCoverage(data) data.Issues = deriveIssues(data, wantBind, baseDN != "") return data, nil } func probeSet(ctx context.Context, data *LDAPData, domain string, mode LDAPMode, prefix string, records []SRVRecord, timeout time.Duration, bindDN, bindPassword, baseDN string) { for _, rec := range records { addrs := addressesForProbe(rec) if len(addrs) == 0 { ep := EndpointProbe{ Mode: mode, SRVPrefix: prefix, Target: rec.Target, Port: rec.Port, Error: "no A/AAAA records for target", } data.Endpoints = append(data.Endpoints, ep) continue } for _, a := range addrs { ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, timeout, bindDN, bindPassword, baseDN) data.Endpoints = append(data.Endpoints, ep) } } } 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() _ = rawConn.SetDeadline(time.Now().Add(timeout)) // 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(domain, 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) _ = tlsConn.SetDeadline(time.Now().Add(timeout)) 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. 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 := stringListContains(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 // 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) } // Refresh RootDSE post-TLS: some servers expose more // supported mechanisms after the secure channel is up. readRootDSE(conn, &result) } } else if !result.RootDSERead { result.Error = "rootdse-unreadable: " + firstNonEmpty(result.Error, "RootDSE could not be read") } } // Anonymous bind + search -- we try unconditionally so we can flag // exposure. anonBindOK := simpleBindIs(conn, "", "", 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 err := conn.Bind(bindDN, bindPassword) if err == nil { result.BindOK = true if baseDN != "" { result.BaseReadAttempted = true 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 { result.BaseReadOK = true result.BaseReadEntries = len(sr.Entries) } } } else { result.BindError = err.Error() } } return result } // simpleBindIs runs a simple bind and reports whether it succeeded. It is a // thin wrapper so we can distinguish "bind accepted" from "bind rejected" // without tracking specific LDAP result codes. func simpleBindIs(conn *ldapv3.Conn, user, pass string, classifier *ldapClassifier) bool { err := conn.Bind(user, pass) if classifier != nil { classifier.lastErr = err } return err == nil } type ldapClassifier struct { lastErr error } // probePlaintextBindRefusal opens a short-lived, fresh TCP connection and // attempts a simple bind with a random 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(domain, 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 err = conn.Bind("cn=checker-probe,dc="+domain, "x-not-a-real-password-x") if err == nil { // Unlikely but clear: cleartext bind accepted. return tested, true } // Map LDAP result code 13 (confidentiality required) to "refused". var lerr *ldapv3.Error if errors.As(err, &lerr) && lerr.ResultCode == ldapv3.LDAPResultConfidentialityRequired { return tested, false } // resultCode 49 (invalidCredentials), 32 (noSuchObject), … all mean // the server was willing to *try* the bind over cleartext, which is // the warning-worthy posture. 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 } } 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 } func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { for i := range records { ips, err := r.LookupIPAddr(ctx, records[i].Target) if err != nil { continue } for _, ip := range ips { if v4 := ip.IP.To4(); v4 != nil { records[i].IPv4 = append(records[i].IPv4, v4.String()) } else { records[i].IPv6 = append(records[i].IPv6, ip.IP.String()) } } } } func computeCoverage(data *LDAPData) { anyEncrypted := false anyPlain := false for _, ep := range data.Endpoints { if ep.TCPConnected { if ep.IsIPv6 { data.Coverage.HasIPv6 = true } else { data.Coverage.HasIPv4 = true } if ep.TLSEstablished { anyEncrypted = true } else { anyPlain = true } } } data.Coverage.EncryptedReachable = anyEncrypted data.Coverage.PlainOnlyReachable = anyPlain && !anyEncrypted } func deriveIssues(data *LDAPData, wantBind, wantBaseRead bool) []Issue { var issues []Issue // 1. No SRV published. if data.SRV.FallbackProbed { issues = append(issues, Issue{ Code: CodeNoSRV, Severity: SeverityInfo, Message: "No LDAP SRV records published for " + data.Domain + ".", Fix: "Consider publishing _ldap._tcp." + data.Domain + " and _ldaps._tcp." + data.Domain + " SRV records to let clients discover the directory automatically.", }) } // 2. SRV lookup errors. for prefix, msg := range data.SRV.Errors { issues = append(issues, Issue{ Code: CodeSRVServfail, Severity: SeverityWarn, Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg, Fix: "Check the authoritative DNS servers for this domain.", }) } // 3. Endpoint-level issues. allDown := len(data.Endpoints) > 0 anyEncrypted := false anyLDAPS := false anyLDAPReachable := false sawSASL := false sawStrongSASL := false sawPlainOnly := false for _, ep := range data.Endpoints { if ep.TCPConnected { allDown = false if ep.Mode == ModePlain { anyLDAPReachable = true } if ep.Mode == ModeLDAPS { anyLDAPS = true } if ep.TLSEstablished { anyEncrypted = true } } if !ep.TCPConnected && ep.Error != "" { issues = append(issues, Issue{ Code: CodeTCPUnreachable, Severity: SeverityWarn, Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".", Fix: "Verify firewall rules and that the LDAP server is listening on this address.", Endpoint: ep.Address, }) continue } if ep.Mode == ModePlain && ep.TCPConnected { if !ep.StartTLSOffered { issues = append(issues, Issue{ Code: CodeStartTLSMissing, Severity: SeverityCrit, Message: "StartTLS not advertised on " + ep.Address + ".", Fix: "Configure the LDAP server to accept StartTLS (RFC 2830). On OpenLDAP, set olcTLSCertificateFile / olcTLSCertificateKeyFile and ensure the LDAP listener allows encrypted upgrades.", Endpoint: ep.Address, }) } else if !ep.StartTLSUpgraded { issues = append(issues, Issue{ Code: CodeStartTLSFailed, Severity: SeverityCrit, Message: "StartTLS handshake failed on " + ep.Address + ": " + ep.Error + ".", Fix: "Run the TLS checker on this endpoint for cert and cipher details.", Endpoint: ep.Address, }) } if ep.PlaintextBindTested && ep.PlaintextBindAccepted { issues = append(issues, Issue{ Code: CodePlainBindAccepted, Severity: SeverityCrit, Message: "Cleartext bind attempts are accepted on " + ep.Address + " (server does not reply confidentialityRequired).", Fix: "Require TLS before authentication: set `security simple_bind=` on OpenLDAP (olcSecurity) or enable `require_tls` in 389-ds/Samba AD to force StartTLS before bind.", Endpoint: ep.Address, }) } } if ep.Mode == ModeLDAPS && ep.TCPConnected && !ep.TLSEstablished { issues = append(issues, Issue{ Code: CodeLDAPSHandshakeFailed, Severity: SeverityCrit, Message: "LDAPS TLS handshake failed on " + ep.Address + ": " + ep.Error + ".", Fix: "Check that the LDAPS listener has a valid certificate/key pair and is speaking TLS directly (not LDAP+StartTLS).", Endpoint: ep.Address, }) } if ep.TCPConnected && ep.TLSEstablished && !ep.RootDSERead { issues = append(issues, Issue{ Code: CodeRootDSEUnreadable, Severity: SeverityWarn, Message: "RootDSE is not readable on " + ep.Address + " -- capability discovery is unavailable.", Fix: "Grant anonymous read on the empty base DN for schema introspection (`access to dn.base=\"\" by * read` on OpenLDAP), or expect reduced visibility of supported mechanisms.", Endpoint: ep.Address, }) } // Anonymous exposure. if ep.AnonymousSearchAllowed { issues = append(issues, Issue{ Code: CodeAnonymousSearchAllowed, Severity: SeverityWarn, Message: "Anonymous search against naming context succeeds on " + ep.Address + " -- DIT contents may be enumerable without credentials.", Fix: "Restrict anonymous access: `access to * by anonymous auth by users read` (OpenLDAP) or set `nsslapd-allow-anonymous-access: off` (389-ds).", Endpoint: ep.Address, }) } // SASL posture (from RootDSE). if len(ep.SupportedSASLMechanisms) > 0 { sawSASL = true hasPlain := false hasStrong := false for _, m := range ep.SupportedSASLMechanisms { u := strings.ToUpper(m) switch u { case "PLAIN", "LOGIN": hasPlain = true case "EXTERNAL", "GSSAPI", "GSS-SPNEGO": hasStrong = true default: if strings.HasPrefix(u, "SCRAM-") { hasStrong = true } } } if hasPlain && !hasStrong { sawPlainOnly = true } if hasStrong { sawStrongSASL = true } } // Protocol version. hasV3 := false hasV2 := false for _, v := range ep.SupportedLDAPVersion { switch strings.TrimSpace(v) { case "3": hasV3 = true case "2": hasV2 = true } } _ = hasV3 if hasV2 { issues = append(issues, Issue{ Code: CodeLegacyLDAPv2, Severity: SeverityWarn, Message: "Server still advertises supportedLDAPVersion=2 on " + ep.Address + ".", Fix: "Disable LDAPv2 support -- RFC 3494 deprecated it in 2003. On OpenLDAP, remove `allow bind_v2`.", Endpoint: ep.Address, }) } // Naming context exposure check -- missing it is usually benign, but // on authoritative directories the absence means you cannot route // queries. Report as info. if ep.RootDSERead && len(ep.NamingContexts) == 0 { issues = append(issues, Issue{ Code: CodeNoNamingContext, Severity: SeverityInfo, Message: "RootDSE does not advertise any naming context on " + ep.Address + ".", Fix: "If this server is authoritative, populate the namingContexts attribute so clients can locate the DIT root.", Endpoint: ep.Address, }) } // StartTLS offered on LDAPS port -- not wrong per se (some servers // support both), but usually a configuration smell. if ep.Mode == ModeLDAPS && stringListContains(ep.SupportedExtension, "1.3.6.1.4.1.1466.20037") { issues = append(issues, Issue{ Code: CodeStartTLSOnLDAPS, Severity: SeverityInfo, Message: "Server advertises StartTLS on the LDAPS port " + ep.Address + ".", Fix: "This is not actively harmful, but most directories do not need StartTLS exposed on 636. Consider disabling it to reduce attack surface.", Endpoint: ep.Address, }) } // Authenticated bind result. if ep.BindAttempted { if ep.BindOK { issues = append(issues, Issue{ Code: CodeBindOK, Severity: SeverityInfo, Message: "Bind as " + ep.Address + " succeeded with the provided credentials.", Endpoint: ep.Address, }) if ep.BaseReadAttempted && !ep.BaseReadOK { issues = append(issues, Issue{ Code: CodeBaseReadFailed, Severity: SeverityCrit, Message: "Bind succeeded but baseObject read on " + data.BaseDN + " failed: " + ep.BaseReadError, Fix: "Verify the bind DN has read access to the base DN -- typically granted via an ACL entry such as `access to dn.subtree=\"\" by dn.exact=\"\" read`.", Endpoint: ep.Address, }) } if ep.BaseReadAttempted && ep.BaseReadOK { issues = append(issues, Issue{ Code: CodeBaseReadOK, Severity: SeverityInfo, Message: "Base DN read succeeded on " + ep.Address + " (entries=" + strconv.Itoa(ep.BaseReadEntries) + ").", Endpoint: ep.Address, }) } } else { issues = append(issues, Issue{ Code: CodeBindFailed, Severity: SeverityCrit, Message: "Bind on " + ep.Address + " failed: " + ep.BindError, Fix: "Verify the bind DN exists and the password is current. On AD, check the account is not locked/expired.", Endpoint: ep.Address, }) } } } // Aggregate-level derivations. if allDown { issues = append(issues, Issue{ Code: CodeAllEndpointsDown, Severity: SeverityCrit, Message: "No LDAP endpoint is reachable.", Fix: "Check firewall rules and that the directory is listening on ports 389/636 (or the ports indicated by SRV).", }) } else if !anyEncrypted { issues = append(issues, Issue{ Code: CodeNoEncryptedEndpoint, Severity: SeverityCrit, Message: "None of the reachable endpoints established an encrypted channel (no StartTLS, no LDAPS).", Fix: "Enable either LDAPS (port 636) or StartTLS on 389 -- a bind DN should never travel in cleartext.", }) } if anyLDAPReachable && !anyLDAPS { // Not always a misconfig (some sites run StartTLS-only), info only. // No dedicated issue -- informational. } if sawSASL { if !sawStrongSASL { issues = append(issues, Issue{ Code: CodeSASLNoStrongMech, Severity: SeverityWarn, Message: "No strong SASL mechanism (SCRAM-*, EXTERNAL, GSSAPI) is advertised.", Fix: "Enable SCRAM-SHA-256 or GSSAPI where your identity platform allows it.", }) } if sawPlainOnly { issues = append(issues, Issue{ Code: CodeSASLPlainOnly, Severity: SeverityWarn, Message: "Only PLAIN/LOGIN SASL mechanisms are offered.", Fix: "Add SCRAM-SHA-256 so password hashes aren't sent as password-equivalent tokens.", }) } } else if len(data.Endpoints) > 0 { // We didn't see supportedSASLMechanisms at all -- either the server // doesn't advertise them or we couldn't read RootDSE. issues = append(issues, Issue{ Code: CodeNoSASL, Severity: SeverityInfo, Message: "No supportedSASLMechanisms advertised by the directory.", Fix: "If SASL is in use, ensure the server publishes supportedSASLMechanisms in the RootDSE. Otherwise only simple binds are available.", }) } if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { issues = append(issues, Issue{ Code: CodeNoIPv6, Severity: SeverityInfo, Message: "No IPv6 endpoint reachable.", Fix: "Publish AAAA records for the SRV targets.", }) } return issues } func stringListContains(list []string, want string) bool { for _, s := range list { if strings.EqualFold(s, want) { return true } } return false } func unique(list []string) []string { 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 } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }