Compare commits

..

5 Commits

Author SHA1 Message Date
Chris F Ravenscroft 1c497d76ba ci: release 2.5.1 (#472)
* ci: release 2.5.1

* chore: set version to v2.5.1
2026-07-18 15:20:33 -07:00
Chris F Ravenscroft cf0eb7f43f Pr 470 (#473)
* fix: reject disabled users on all backends in Bind

A user with disabled = true could still bind on the config backend, since
configHandler.FindUser and LDAPOpsHelper.Bind never checked Disabled while the
SQL backends drop such users at lookup. Enforce it in Bind so every backend
behaves the same.

* fix: use configHandler.Bind rather than helper.Bind in test harness

---------

Co-authored-by: kanywst <niwatakuma@icloud.com>
2026-07-18 14:59:42 -07:00
kt fb65713fbd fix: reject disabled users on all backends in Bind (#470)
A user with disabled = true could still bind on the config backend, since
configHandler.FindUser and LDAPOpsHelper.Bind never checked Disabled while the
SQL backends drop such users at lookup. Enforce it in Bind so every backend
behaves the same.
2026-07-18 14:53:08 -07:00
Chris F Ravenscroft cb121cdc2c fix: team atlanta arbitrary password (#471) 2026-07-18 14:32:05 -07:00
Jörn Friedrich Dreyer 4b2b1398b8 Fix typo in contributing section of README (#468) 2026-07-18 13:31:54 -07:00
9 changed files with 367 additions and 10 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"v2": "2.5.0"
"v2": "2.5.1"
}
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [2.5.1](https://github.com/glauth/glauth/compare/GLAuth-v2.5.0...GLAuth-v2.5.1) (2026-07-18)
### Bug Fixes
* reject disabled users on all backends in Bind ([#470](https://github.com/glauth/glauth/issues/470)) ([fb65713](https://github.com/glauth/glauth/commit/fb65713fbdfd626cb033675fb42724a5fb3d06ff))
* team atlanta arbitrary password ([#471](https://github.com/glauth/glauth/issues/471)) ([cb121cd](https://github.com/glauth/glauth/commit/cb121cdc2c9b831abdf7831835ec30ead2536a7a))
## [2.5.0](https://github.com/glauth/glauth/compare/GLAuth-v2.5.0...GLAuth-v2.5.0) (2026-04-19)
+2 -3
View File
@@ -58,10 +58,9 @@ mocks:
vet:
-go vet ./...
.PHONY: vet
#test: @ runs the integration test on linuxamd64 (eventually allow the binary to be set)
#test: @ runs package, root integration, and shell integration tests
test: mocks vet
# also run unit tests for packages, skip glauth_test.go for now
go test -v -cover -coverprofile coverage.out ./pkg/... ./internal/...
go test -v -cover -coverprofile coverage.out ./pkg/... ./internal/... .
$(MAKE) runtest
#all: @ run build process for all binaries
all: setup binaries verify
+117 -5
View File
@@ -1,8 +1,12 @@
package main
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -23,10 +27,22 @@ type testEnv struct {
checkemployeetype string
}
func glauthBinary() string {
if binary := os.Getenv("GLAUTH_TEST_BINARY"); binary != "" {
return binary
}
qualified := filepath.Join("bin", runtime.GOOS+runtime.GOARCH, "glauth")
if _, err := os.Stat(qualified); err == nil {
return qualified
}
return "bin/glauth"
}
func TestProperBuild(t *testing.T) {
info, err := os.Stat("bin/glauth")
info, err := os.Stat(glauthBinary())
if err != nil {
t.Error(err)
t.Fatal(err)
}
mode := uint32(info.Mode())
if mode&0b001001001 == 0 {
@@ -34,6 +50,102 @@ func TestProperBuild(t *testing.T) {
}
}
func TestConfigBackendRejectsPasswordlessUserBinds(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
configPath := filepath.Join(t.TempDir(), "passwordless.cfg")
config := fmt.Sprintf(`debug = false
[ldap]
enabled = true
listen = "127.0.0.1:%d"
tls = false
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=glauth,dc=com"
[behaviors]
LimitFailedBinds = false
[[groups]]
name = "svc"
gidnumber = 5501
[[users]]
name = "nopass"
uidnumber = 5001
primarygroup = 5501
[[users.capabilities]]
action = "search"
object = "*"
`, port)
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
t.Fatal(err)
}
svc := startSvc(SD, glauthBinary(), "-c", configPath)
defer stopSvc(svc)
bindDN := "cn=nopass,dc=glauth,dc=com"
for _, tc := range []struct {
name string
password string
want string
}{
{name: "arbitrary password", password: "definitely-wrong", want: "exit status 49"},
{name: "empty password", password: "", want: "exit status 53"},
} {
t.Run(tc.name, func(t *testing.T) {
got := doRunGetFirst(0, "ldapsearch", "-LLL", "-H", fmt.Sprintf("ldap://127.0.0.1:%d", port), "-D", bindDN, "-w", tc.password, "-x", "-b", "dc=glauth,dc=com", "(cn=nopass)")
if got != tc.want {
t.Fatalf("bind result = %q, want %q", got, tc.want)
}
})
}
}
func ldapOnlyConfig(t *testing.T, source string) string {
t.Helper()
data, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
contents := string(data)
sectionMarker := "\n[ldaps]\n"
markerStart := strings.Index(contents, sectionMarker)
if markerStart < 0 {
t.Fatalf("%s has no [ldaps] section", source)
}
start := markerStart + 1
relEnd := strings.Index(contents[start+len("[ldaps]"):], "\n[")
if relEnd < 0 {
t.Fatalf("%s has no section after [ldaps]", source)
}
end := start + len("[ldaps]") + relEnd
section := contents[start:end]
sectionWithoutLDAPS := strings.Replace(section, "enabled = true", "enabled = false", 1)
if section == sectionWithoutLDAPS {
t.Fatalf("%s [ldaps] section does not enable LDAPS", source)
}
contents = contents[:start] + sectionWithoutLDAPS + contents[end:]
path := filepath.Join(t.TempDir(), filepath.Base(source))
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
return path
}
func batteryOfTests(t *testing.T, env *testEnv) {
tests := []struct {
Name string
@@ -195,7 +307,7 @@ func TestSampleSimple(t *testing.T) {
checkemployeetype: "cn=hackers",
}
svc := startSvc(SD, "bin/glauth", "-c", "sample-simple.cfg")
svc := startSvc(SD, glauthBinary(), "-c", ldapOnlyConfig(t, "sample-simple.cfg"))
batteryOfTests(t, &env)
stopSvc(svc)
}
@@ -221,7 +333,7 @@ func TestSQLitePlugin(t *testing.T) {
checkemployeetype: "",
}
svc := startSvc(SD, "bin/glauth", "-c", "pkg/plugins/glauth-sqlite/sample-database.cfg")
svc := startSvc(SD, glauthBinary(), "-c", "pkg/plugins/glauth-sqlite/sample-database.cfg")
batteryOfTests(t, &env)
stopSvc(svc)
}
@@ -247,7 +359,7 @@ func TestLdapInjection(t *testing.T) {
checkemployeetype: "",
}
svc := startSvc(SD, "bin/glauth", "-c", "sample-ldap-injection.cfg")
svc := startSvc(SD, glauthBinary(), "-c", "sample-ldap-injection.cfg")
batteryOfTests(t, &env)
stopSvc(svc)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package version
// Version is the tag of the latest release, it gets changed by the ci
// process when a release is happening
const Version = "v2.5.0" // x-release-please-version
const Version = "v2.5.1" // x-release-please-version
+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"encoding/hex"
"net"
"testing"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/trace/noop"
"golang.org/x/crypto/bcrypt"
"github.com/glauth/glauth/v2/pkg/config"
"github.com/glauth/ldap"
)
type disabledBindTestMonitor struct{}
func (disabledBindTestMonitor) SetResponseTimeMetric(map[string]string, float64) error {
return nil
}
func (disabledBindTestMonitor) SetLDAPMetric(map[string]string, float64) error {
return nil
}
func TestConfigBackendDisabledUserCannotBind(t *testing.T) {
pw := "correct horse"
raw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
hexHash := hex.EncodeToString(raw)
cfg := &config.Config{}
cfg.Users = []config.User{
{Name: "activeuser", PrimaryGroup: 5501, PassBcrypt: hexHash, Disabled: false},
{Name: "disableduser", PrimaryGroup: 5501, PassBcrypt: hexHash, Disabled: true},
}
logger := zerolog.Nop()
tracer := noop.NewTracerProvider().Tracer("f001")
helper := NewLDAPOpsHelper(tracer)
h := configHandler{
backend: config.Backend{
BaseDN: "dc=example,dc=com",
NameFormatAsArray: []string{"cn"},
GroupFormatAsArray: []string{"ou"},
},
log: &logger,
cfg: cfg,
ldohelper: helper,
monitor: disabledBindTestMonitor{},
tracer: tracer,
}
_, server := net.Pipe()
defer server.Close()
bind := func(dn, pass string) ldap.LDAPResultCode {
code, _ := h.Bind(dn, pass, server)
return code
}
if code := bind("cn=activeuser,dc=example,dc=com", pw); code != ldap.LDAPResultSuccess {
t.Fatalf("active user with correct pw should bind, got %d", code)
}
if code := bind("cn=disableduser,dc=example,dc=com", pw); code != ldap.LDAPResultInvalidCredentials {
t.Errorf("disabled user must be rejected; got code %d, want InvalidCredentials(49)", code)
}
}
+2
View File
@@ -121,6 +121,8 @@ func (h ldapHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (result ld
validotp := false
// Local records augment proxy authentication with OTP/YubiKey checks;
// password authentication remains delegated to the upstream LDAP server.
// Find the user
// We are going to go through all backends and ask
// until we find our user or die of boredom.
+26
View File
@@ -65,6 +65,10 @@ func NewLDAPOpsHelper(tracer trace.Tracer) LDAPOpsHelper {
return helper
}
func isUnauthenticatedSimpleBind(bindDN, bindSimplePw string) bool {
return bindDN != "" && bindSimplePw == ""
}
func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindSimplePw string, conn net.Conn) (resultCode ldap.LDAPResultCode, err error) {
ctx, span := l.tracer.Start(ctx, "handler.LDAPOpsHelper.Bind")
defer span.End()
@@ -86,11 +90,24 @@ func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindS
return ldap.LDAPResultSuccess, nil
}
// A non-empty DN with an empty password is LDAP's unauthenticated bind
// mechanism. It must not establish the named user's authorization state.
if isUnauthenticatedSimpleBind(bindDN, bindSimplePw) {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("unauthenticated bind rejected")
return ldap.LDAPResultUnwillingToPerform, nil
}
user, ldapcode := l.findUser(ctx, h, bindDN, true /* checkGroup */)
if ldapcode != ldap.LDAPResultSuccess {
return ldapcode, nil
}
// a disabled account must never authenticate, on any backend
if user.Disabled {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("Bind attempt on disabled account")
return ldap.LDAPResultInvalidCredentials, nil
}
validotp := false
if len(user.Yubikey) == 0 && len(user.OTPSecret) == 0 {
@@ -175,6 +192,15 @@ func (l LDAPOpsHelper) Bind(ctx context.Context, h LDAPOpsHandler, bindDN, bindS
return ldap.LDAPResultInvalidCredentials, nil
}
// Reaching this point without a configured primary password verifier must
// not be treated as successful authentication. App-password matches and
// custom authentication return above when they succeed.
if user.PassBcrypt == "" && user.PassSHA256 == "" {
h.GetLog().Info().Str("binddn", bindDN).Str("src", conn.RemoteAddr().String()).Msg("invalid credentials")
l.maybePutInTimeout(ctx, h, conn, true)
return ldap.LDAPResultInvalidCredentials, nil
}
// Now, check the pasword hash
if user.PassBcrypt != "" {
decoded, err := hex.DecodeString(user.PassBcrypt)
+141
View File
@@ -0,0 +1,141 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net"
"testing"
"github.com/GeertJohan/yubigo"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/trace"
"github.com/glauth/glauth/v2/pkg/config"
"github.com/glauth/ldap"
)
type bindTestHandler struct {
backend config.Backend
cfg config.Config
log zerolog.Logger
user config.User
}
func (h *bindTestHandler) GetBackend() config.Backend { return h.backend }
func (h *bindTestHandler) GetLog() *zerolog.Logger { return &h.log }
func (h *bindTestHandler) GetCfg() *config.Config { return &h.cfg }
func (h *bindTestHandler) GetYubikeyAuth() *yubigo.YubiAuth {
return nil
}
func (h *bindTestHandler) FindUser(_ context.Context, userName string, _ bool) (bool, config.User, error) {
if userName == h.user.Name {
return true, h.user, nil
}
return false, config.User{}, nil
}
func (h *bindTestHandler) FindGroup(context.Context, string) (bool, config.Group, error) {
return false, config.Group{}, nil
}
func (h *bindTestHandler) FindPosixAccounts(context.Context, string) ([]*ldap.Entry, error) {
return nil, nil
}
func (h *bindTestHandler) FindPosixGroups(context.Context, string) ([]*ldap.Entry, error) {
return nil, nil
}
func sha256Password(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
func TestLDAPOpsHelperBindRejectsUnauthenticatedAndPasswordlessBinds(t *testing.T) {
tests := []struct {
name string
password string
user config.User
wantResult ldap.LDAPResultCode
}{
{
name: "named empty password is unauthenticated bind",
password: "",
wantResult: ldap.LDAPResultUnwillingToPerform,
},
{
name: "arbitrary password without verifier",
password: "definitely-wrong",
wantResult: ldap.LDAPResultInvalidCredentials,
},
{
name: "failed app password does not fall through",
user: config.User{
Name: "nopass",
PassAppSHA256: []string{sha256Password("expected")},
},
password: "definitely-wrong",
wantResult: ldap.LDAPResultInvalidCredentials,
},
{
name: "matching primary password succeeds",
user: config.User{
Name: "nopass",
PassSHA256: sha256Password("correct"),
},
password: "correct",
wantResult: ldap.LDAPResultSuccess,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := tt.user
if user.Name == "" {
user.Name = "nopass"
}
h := &bindTestHandler{
backend: config.Backend{
BaseDN: "dc=glauth,dc=com",
NameFormatAsArray: []string{"cn"},
GroupFormatAsArray: []string{"ou"},
},
user: user,
log: zerolog.Nop(),
}
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
helper := NewLDAPOpsHelper(trace.NewNoopTracerProvider().Tracer("bind-test"))
got, err := helper.Bind(context.Background(), h, "cn=nopass,dc=glauth,dc=com", tt.password, conn)
if err != nil {
t.Fatalf("Bind returned error: %v", err)
}
if got != tt.wantResult {
t.Fatalf("Bind result = %v, want %v", got, tt.wantResult)
}
})
}
}
func TestLDAPOpsHelperBindAllowsAnonymousBind(t *testing.T) {
h := &bindTestHandler{
backend: config.Backend{BaseDN: "dc=glauth,dc=com"},
log: zerolog.Nop(),
}
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
helper := NewLDAPOpsHelper(trace.NewNoopTracerProvider().Tracer("bind-test"))
got, err := helper.Bind(context.Background(), h, "", "", conn)
if err != nil {
t.Fatalf("Bind returned error: %v", err)
}
if got != ldap.LDAPResultSuccess {
t.Fatalf("Bind result = %v, want %v", got, ldap.LDAPResultSuccess)
}
}