VYPR
High severity8.2NVD Advisory· Published Apr 16, 2026· Updated Apr 22, 2026

CVE-2026-40193

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.

PackageAffected versionsPatched versions
github.com/foxcpp/maddyGo
< 0.9.30.9.3

Affected products

1

Patches

1
6a06337eb41f

auth/ldap: Fix GHSA-5835-4gvc-32pc

https://github.com/foxcpp/maddyfox.cppApr 9, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.