internal/ldap: Validate the domain and make SetDomain repeatable

This commit is contained in:
Michael Aldridge 2026-11-01 19:16:18 -06:00
parent bdc0d85b53
commit 9a270abbf2
3 changed files with 142 additions and 31 deletions

View file

@ -0,0 +1,68 @@
package ldap
import (
"strings"
"testing"
ldap "github.com/ps78674/ldapserver"
)
func TestSetDomainValidation(t *testing.T) {
s := New(WithLogger(testLogger()))
for _, bad := range []string{"", "example..com", ".example.com", "example.com."} {
if err := s.SetDomain(bad); err == nil {
t.Errorf("SetDomain(%q) accepted an invalid domain", bad)
}
}
if err := s.SetDomain("example.com"); err != nil {
t.Fatalf("SetDomain(example.com): %s", err)
}
want := []string{"dc=netauth", "dc=example", "dc=com"}
if strings.Join(s.nc, ",") != strings.Join(want, ",") {
t.Errorf("naming context is %v, want %v", s.nc, want)
}
}
// Search routes depend on the naming context, so a search must not be
// routed at all until a domain is configured.
func TestSearchBeforeSetDomain(t *testing.T) {
s := New(WithLogger(testLogger()), WithNetAuth(&fakeClient{}))
w := &recordingWriter{}
s.ServeLDAP(w, mustDecode(t, berSearchRequest("ou=entities,dc=netauth")))
code := resultCode(t, w.responses[len(w.responses)-1])
if code == ldap.LDAPResultSuccess {
t.Error("a search was served without a configured domain")
}
}
// Reconfiguring the domain must replace the old routes rather than
// adding to them, otherwise a renamed server keeps serving its
// previous base DN forever.
func TestSetDomainReplacesRoutes(t *testing.T) {
s := New(WithLogger(testLogger()), WithNetAuth(&fakeClient{}))
if err := s.SetDomain("example.com"); err != nil {
t.Fatal(err)
}
if err := s.SetDomain("other.org"); err != nil {
t.Fatal(err)
}
// The old base DN must no longer route to the entity handler.
old := &recordingWriter{}
s.ServeLDAP(old, mustDecode(t, berSearchRequest("ou=entities,dc=netauth,dc=example,dc=com")))
if code := resultCode(t, old.responses[len(old.responses)-1]); code != ldap.LDAPResultUnwillingToPerform {
t.Errorf("stale base DN returned %d, want %d (unwillingToPerform)", code, ldap.LDAPResultUnwillingToPerform)
}
// The new base DN must route.
fresh := &recordingWriter{}
s.ServeLDAP(fresh, mustDecode(t, berSearchRequest("ou=entities,dc=netauth,dc=other,dc=org")))
if code := resultCode(t, fresh.responses[len(fresh.responses)-1]); code == ldap.LDAPResultUnwillingToPerform {
t.Error("the current base DN does not route to the entity handler")
}
}

View file

@ -1,6 +1,7 @@
package ldap package ldap
import ( import (
"errors"
"strings" "strings"
ldap "github.com/ps78674/ldapserver" ldap "github.com/ps78674/ldapserver"
@ -14,20 +15,47 @@ func New(opts ...Option) *server {
} }
x.Server = ldap.NewServer() x.Server = ldap.NewServer()
x.routes = ldap.NewRouteMux() // The underlying server only permits a single handler and aborts
x.routes.NotFound(x.guard(x.handleNotFound, failOperationError)) // the process on a second registration, so a stable delegating
x.routes.Abandon(x.guard(x.handleAbandon, nil)) // handler is registered once and SetDomain swaps the routed mux
x.routes.Bind(x.guard(x.handleBind, failBindError)) // underneath it.
x.Handle(x)
x.routes.Search(x.guard(x.handleSearchDSE, failSearchError)). x.buildRoutes()
return x
}
// buildRoutes (re)creates the route mux. It is called by New and again
// by SetDomain so that the naming-convention dependent routes always
// reflect the current domain without ever leaving duplicates behind.
func (s *server) buildRoutes() {
routes := ldap.NewRouteMux()
routes.NotFound(s.guard(s.handleNotFound, failOperationError))
routes.Abandon(s.guard(s.handleAbandon, nil))
routes.Bind(s.guard(s.handleBind, failBindError))
routes.Search(s.guard(s.handleSearchDSE, failSearchError)).
BaseDn(""). BaseDn("").
Scope(ldap.SearchRequestScopeBaseObject). Scope(ldap.SearchRequestScopeBaseObject).
Filter("(objectclass=*)"). Filter("(objectclass=*)").
Label("Search - ROOT DSE") Label("Search - ROOT DSE")
x.Handle(x.routes) if len(s.nc) > 0 {
base := strings.Join(s.nc, ",")
return x routes.Search(s.guard(s.handleSearchEntities, failSearchError)).
BaseDn("ou=entities," + base).
Scope(ldap.SearchRequestHomeSubtree).
Label("Search - Entities")
routes.Search(s.guard(s.handleSearchGroups, failSearchError)).
BaseDn("ou=groups," + base).
Scope(ldap.SearchRequestHomeSubtree).
Label("Search - Groups")
}
s.routes = routes
} }
// Serve serves a plaintext DSA on the provided bind string. // Serve serves a plaintext DSA on the provided bind string.
@ -68,30 +96,42 @@ func (s *server) handleAbandon(w ldap.ResponseWriter, m *ldap.Message) {
} }
} }
func (s *server) SetDomain(domain string) { // SetDomain configures the naming context from a plain domain name
nc := "dc=netauth," // such as "example.com". NetAuth has no notion of a base DN, so it is
parts := strings.Split(domain, ".") // supplied here: the domain is split on dots, each label becomes a
for i := range parts { // dc component, and dc=netauth is prepended. It must be called after
nc += "dc=" + parts[i] + "," // New and is safe to call more than once.
} func (s *server) SetDomain(domain string) error {
nc = strings.TrimSuffix(nc, ",") if domain == "" {
return errors.New("a domain must be configured")
s.nc = strings.Split(nc, ",")
for i := range s.nc {
s.nc[i] = strings.TrimSpace(s.nc[i])
} }
// Register routes that are dependent on the namingConvention labels := strings.Split(domain, ".")
entitySearchDN := "ou=entities," + strings.Join(s.nc, ",") nc := []string{"dc=netauth"}
s.routes.Search(s.guard(s.handleSearchEntities, failSearchError)). for _, l := range labels {
BaseDn(entitySearchDN). l = strings.TrimSpace(l)
Scope(ldap.SearchRequestHomeSubtree). if l == "" {
Label("Search - Entities") return errors.New("domain contains an empty label")
}
groupSearchDN := "ou=groups," + strings.Join(s.nc, ",") nc = append(nc, "dc="+l)
s.routes.Search(s.guard(s.handleSearchGroups, failSearchError)). }
BaseDn(groupSearchDN).
Scope(ldap.SearchRequestHomeSubtree).
Label("Search - Groups")
s.nc = nc
s.buildRoutes()
return nil
}
// ServeLDAP implements the underlying server's Handler interface. It
// is registered once with the server and forwards to whatever route
// mux is current, which lets SetDomain rebuild routing without
// re-registering a handler (the server aborts on a second
// registration).
func (s *server) ServeLDAP(w ldap.ResponseWriter, r *ldap.Message) {
if s.routes == nil {
res := ldap.NewResponse(ldap.LDAPResultUnavailable)
res.SetDiagnosticMessage("server is not configured with a domain")
w.Write(res)
return
}
s.routes.ServeLDAP(w, r)
} }

View file

@ -72,7 +72,10 @@ func main() {
ldap.WithAnonBind(viper.GetBool("ldap.allow_anon")), ldap.WithAnonBind(viper.GetBool("ldap.allow_anon")),
) )
ls.SetDomain(viper.GetString("ldap.domain")) if err := ls.SetDomain(viper.GetString("ldap.domain")); err != nil {
appLogger.Error("Error configuring the base DN", "error", err)
os.Exit(3)
}
if !viper.GetBool("ldap.tls") { if !viper.GetBool("ldap.tls") {
if !strings.HasPrefix(viper.GetString("ldap.bind"), "localhost") { if !strings.HasPrefix(viper.GetString("ldap.bind"), "localhost") {