CVE-2026-40193
Description
maddy is a composable, all-in-one mail server. Versions prior to 0.9.3 contain an LDAP injection vulnerability in the auth.ldap module where user-supplied usernames are interpolated into LDAP search filters and DN strings via strings.ReplaceAll() without any LDAP filter escaping, despite the go-ldap/ldap/v3 library's ldap.EscapeFilter() function being available in the same import. This affects three code paths: the Lookup() filter, the AuthPlain() DN template, and the AuthPlain() filter. An attacker with network access to the SMTP submission or IMAP interface can inject arbitrary LDAP filter expressions through the username field in AUTH PLAIN or LOGIN commands. This enables identity spoofing by manipulating filter results to authenticate as another user, LDAP directory enumeration via wildcard filters, and blind extraction of LDAP attribute values using authentication responses as a boolean oracle or via timing side-channels between the two distinct failure paths. This issue has been fixed in version 0.9.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/foxcpp/maddyGo | < 0.9.3 | 0.9.3 |
Affected products
1Patches
16a06337eb41fauth/ldap: Fix GHSA-5835-4gvc-32pc
6 files changed · +198 −8
docs/reference/auth/netauth.md+6 −4 modified@@ -7,10 +7,12 @@ maddy needs to know the Entity ID to use for authentication. It must match the string the user provides for the Local Atom part of their mail address. -Note that storage backends conventionally use email addresses. Since -NetAuth recommends *nix compatible usernames, you will need to map the -email identifiers to NetAuth Entity IDs using `auth_map` (see -documentation page for used storage backend). +Note that storage backends conventionally use email addresses. Since NetAuth +recommends *nix compatible usernames. You will need to either map email +identifiers specified by user to NetAuth Entity IDs using `auth_map` in +endpoint.smtp/imap configuration (recommended) or you would need to use +`storage_map` in storage backend configuration to map NetAuth Entity ID +specified by user back to appropriate storage backend account names. auth.netauth also can be used as a table module. This way you can check whether the account exists.
go.mod+1 −0 modified@@ -107,6 +107,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jimlambrt/gldap v0.1.14 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.17.11 // indirect
go.sum+2 −0 modified@@ -488,6 +488,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jimlambrt/gldap v0.1.14 h1:InG9kldhIu6OoQK0hvfkW1Lqpc5eLJhxiiDTNmRnrDM= +github.com/jimlambrt/gldap v0.1.14/go.mod h1:yobW9JIAmqe23dVNOaMWewPaff6jGaHgYjspPIIgYmg= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
internal/auth/ldap/ldap.go+3 −3 modified@@ -225,7 +225,7 @@ func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) req := ldap.NewSearchRequest( a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 0, false, - strings.ReplaceAll(a.filterTemplate, "{username}", username), + strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)), []string{"dn"}, nil) res, err := conn.Search(req) if err != nil { @@ -252,12 +252,12 @@ func (a *Auth) AuthPlain(username, password string) error { var userDN string if a.dnTemplate != "" { - userDN = strings.ReplaceAll(a.dnTemplate, "{username}", username) + userDN = strings.ReplaceAll(a.dnTemplate, "{username}", ldap.EscapeDN(username)) } else { req := ldap.NewSearchRequest( a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 0, false, - strings.ReplaceAll(a.filterTemplate, "{username}", username), + strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)), []string{"dn"}, nil) res, err := conn.Search(req) if err != nil {
tests/conn.go+8 −1 modified@@ -211,7 +211,7 @@ func (c *Conn) SMTPPlainAuth(username, password string, expectOk bool) { if expectOk { c.ExpectPattern("235 *") } else { - c.ExpectPattern("*") + c.ExpectPattern("5*") } } @@ -282,6 +282,13 @@ func (c *Conn) Close() error { return c.Conn.Close() } +func (c *Conn) MustClose() { + c.T.Helper() + if err := c.Close(); err != nil { + c.fatal("Close: %v", err) + } +} + func (c *Conn) Rebind(subtest *T) *Conn { cpy := *c cpy.T = subtest
tests/ghsa_5835_4gvc_32pc_test.go+178 −0 added@@ -0,0 +1,178 @@ +//go:build integration + +package tests_test + +import ( + "strconv" + "testing" + "time" + + "github.com/foxcpp/maddy/tests" + "github.com/jimlambrt/gldap" + "github.com/stretchr/testify/require" +) + +type searchEntry struct { + dn string + options []gldap.Option +} + +type MockLDAP struct { + T *testing.T + SearchEntries map[string][]searchEntry + AllowedBinds map[string]string +} + +func (ml *MockLDAP) HandleBind(w *gldap.ResponseWriter, r *gldap.Request) { + resp := r.NewBindResponse( + gldap.WithResponseCode(gldap.ResultInvalidCredentials), + ) + + m, err := r.GetSimpleBindMessage() + if err != nil { + require.NoError(ml.T, w.Write(resp)) + return + } + + pass, ok := ml.AllowedBinds[m.UserName] + if ok && pass == string(m.Password) { + resp.SetResultCode(gldap.ResultSuccess) + require.NoError(ml.T, w.Write(resp)) + } + + require.NoError(ml.T, w.Write(resp)) +} + +func (ml *MockLDAP) HandleSearch(w *gldap.ResponseWriter, r *gldap.Request) { + resp := r.NewSearchDoneResponse() + m, err := r.GetSearchMessage() + if err != nil { + ml.T.Logf("not a search message: %s", err) + require.NoError(ml.T, w.Write(resp)) + return + } + ml.T.Logf("search base dn: %s", m.BaseDN) + ml.T.Logf("search scope: %d", m.Scope) + ml.T.Logf("search filter: %s", m.Filter) + + entries := ml.SearchEntries[m.Filter] + for _, entry := range entries { + ldapEntry := r.NewSearchResponseEntry(entry.dn, entry.options...) + require.NoError(ml.T, w.Write(ldapEntry)) + } + + resp.SetResultCode(gldap.ResultSuccess) + require.NoError(ml.T, w.Write(resp)) +} + +func (ml *MockLDAP) Run(address string) { + s, err := gldap.NewServer() + if err != nil { + ml.T.Fatalf("unable to create server: %s", err.Error()) + } + + // create a router and add a bind handler + r, err := gldap.NewMux() + if err != nil { + ml.T.Fatalf("unable to create router: %s", err.Error()) + } + require.NoError(ml.T, r.Bind(ml.HandleBind)) + require.NoError(ml.T, r.Search(ml.HandleSearch)) + require.NoError(ml.T, s.Router(r)) + go func() { + require.NoError(ml.T, s.Run(address)) + }() + ml.T.Cleanup(func() { + require.NoError(ml.T, s.Stop()) + }) + + for !s.Ready() { + ml.T.Log("Waiting for server to start") + time.Sleep(100 * time.Millisecond) + } +} + +func TestLDAPInjectionFilter(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + + ldapPort := t.Port("ldap") + + ldapSrv := &MockLDAP{ + T: tt, + AllowedBinds: map[string]string{ + "DC=com,CN=bob": "bob_pass", + "DC=com,CN=alice": "alice_pass", + }, + SearchEntries: map[string][]searchEntry{ + "(&(objectClass=inetOrgPerson)(uid=alice))": { + { + dn: "DC=com,CN=alice", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"alice"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + "(&(objectClass=inetOrgPerson)(uid=bob))": { + { + dn: "DC=com,CN=bob", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"bob"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + "(&(objectClass=inetOrgPerson)(uid=bob)(description=prefix*))": { + { + dn: "DC=com,CN=bob", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"bob"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + }, + } + ldapSrv.Run(":" + strconv.Itoa(int(ldapPort))) + + t.Port("smtp") + t.DNS(nil) + t.Config(` + hostname mx.maddy.test + tls off + + auth.ldap ldap_auth { + urls ldap://127.0.0.1:{env:TEST_PORT_ldap} + bind plain "DC=com,CN=bob" "bob_pass" + base_dn "DC=com" + filter "(&(objectClass=inetOrgPerson)(uid={username}))" + } + + submission tcp://0.0.0.0:{env:TEST_PORT_smtp} { + auth &ldap_auth + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + smtpConn := t.Conn("smtp") + defer smtpConn.MustClose() + smtpConn.SMTPNegotation("clieht.maddy.test", nil, nil) + smtpConn.SMTPPlainAuth("alice", "alice_pass", true) + + smtpConn2 := t.Conn("smtp") + defer smtpConn2.MustClose() + smtpConn2.SMTPNegotation("clieht.maddy.test", nil, nil) + smtpConn2.SMTPPlainAuth("bob)(description=prefix*", "bob_pass", false) +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/foxcpp/maddy/commit/6a06337eb41fa87a35697366bcb71c3c962c44banvdPatchWEB
- github.com/foxcpp/maddy/security/advisories/GHSA-5835-4gvc-32pcnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-5835-4gvc-32pcghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40193ghsaADVISORY
- github.com/foxcpp/maddy/releases/tag/v0.9.3nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.