ZITADEL race condition in lockout policy execution
Description
ZITADEL provides identity infrastructure. ZITADEL provides administrators the possibility to define a Lockout Policy with a maximum amount of failed password check attempts. On every failed password check, the amount of failed checks is compared against the configured maximum. Exceeding the limit, will lock the user and prevent further authentication. In the affected implementation it was possible for an attacker to start multiple parallel password checks, giving him the possibility to try out more combinations than configured in the Lockout Policy. This vulnerability has been patched in versions 2.40.5 and 2.38.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/zitadel/zitadelGo | >= 2.39.0, < 2.40.5 | 2.40.5 |
github.com/zitadel/zitadelGo | < 2.38.3 | 2.38.3 |
Affected products
1Patches
122e2d5599918Merge pull request from GHSA-7h8m-vrxx-vr4m
4 files changed · +178 −4
internal/command/user_human_password.go+14 −3 modified@@ -233,18 +233,30 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo if wm.UserState == domain.UserStateUnspecified || wm.UserState == domain.UserStateDeleted { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") } + if wm.UserState == domain.UserStateLocked { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JLK35", "Errors.User.Locked") + } if wm.EncodedHash == "" { - return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.Password.NotSet") + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3nJ4t", "Errors.User.Password.NotSet") } userAgg := UserAggregateFromWriteModel(&wm.WriteModel) ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") updated, err := c.userPasswordHasher.Verify(wm.EncodedHash, password) spanPasswordComparison.EndWithError(err) err = convertPasswapErr(err) - commands := make([]eventstore.Command, 0, 2) + + // recheck for additional events (failed password checks or locks) + recheckErr := c.eventstore.FilterToQueryReducer(ctx, wm) + if recheckErr != nil { + return recheckErr + } + if wm.UserState == domain.UserStateLocked { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SFA3t", "Errors.User.Locked") + } + if err == nil { commands = append(commands, user.NewHumanPasswordCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) if updated != "" { @@ -259,7 +271,6 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo if wm.PasswordCheckFailedCount+1 >= lockoutPolicy.MaxPasswordAttempts { commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) } - } _, pushErr := c.eventstore.Push(ctx, commands...) logging.OnError(pushErr).Error("error create password check failed event")
internal/command/user_human_password_model.go+9 −0 modified@@ -65,8 +65,13 @@ func (wm *HumanPasswordWriteModel) Reduce() error { wm.PasswordCheckFailedCount += 1 case *user.HumanPasswordCheckSucceededEvent: wm.PasswordCheckFailedCount = 0 + case *user.UserLockedEvent: + wm.UserState = domain.UserStateLocked case *user.UserUnlockedEvent: wm.PasswordCheckFailedCount = 0 + if wm.UserState != domain.UserStateDeleted { + wm.UserState = domain.UserStateActive + } case *user.UserRemovedEvent: wm.UserState = domain.UserStateDeleted case *user.HumanPasswordHashUpdatedEvent: @@ -92,6 +97,7 @@ func (wm *HumanPasswordWriteModel) Query() *eventstore.SearchQueryBuilder { user.HumanPasswordCheckSucceededType, user.HumanPasswordHashUpdatedType, user.UserRemovedType, + user.UserLockedType, user.UserUnlockedType, user.UserV1AddedType, user.UserV1RegisteredType, @@ -108,5 +114,8 @@ func (wm *HumanPasswordWriteModel) Query() *eventstore.SearchQueryBuilder { if wm.ResourceOwner != "" { query.ResourceOwner(wm.ResourceOwner) } + if wm.WriteModel.ProcessedSequence != 0 { + query.SequenceGreater(wm.WriteModel.ProcessedSequence) + } return query }
internal/command/user_human_password_test.go+149 −1 modified@@ -1222,6 +1222,68 @@ func TestCommandSide_CheckPassword(t *testing.T) { err: caos_errs.IsPreconditionFailed, }, }, + { + name: "user locked, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + password: "password", + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, { name: "existing password empty, precondition error", fields: fields{ @@ -1336,6 +1398,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { false, "")), ), + expectFilter(), expectPush( user.NewHumanPasswordCheckFailedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1417,8 +1480,10 @@ func TestCommandSide_CheckPassword(t *testing.T) { &user.NewAggregate("user1", "org1").Aggregate, "$plain$x$password", false, - "")), + ""), + ), ), + expectFilter(), expectPush( user.NewHumanPasswordCheckFailedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1507,6 +1572,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { false, "")), ), + expectFilter(), expectPush( user.NewHumanPasswordCheckSucceededEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -1587,6 +1653,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { false, "")), ), + expectFilter(), expectPush( user.NewHumanPasswordCheckSucceededEvent( context.Background(), @@ -1616,6 +1683,86 @@ func TestCommandSide_CheckPassword(t *testing.T) { }, res: res{}, }, + { + name: "check password ok, locked in the mean time", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "")), + ), + expectFilter( + eventFromEventPusher( + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + password: "password", + authReq: &domain.AuthRequest{ + ID: "request1", + AgentID: "agent1", + }, + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, { name: "regression test old version event", fields: fields{ @@ -1682,6 +1829,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { }, ), ), + expectFilter(), expectPush( user.NewHumanPasswordCheckSucceededEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate,
internal/query/user_password.go+6 −0 modified@@ -100,8 +100,13 @@ func (wm *HumanPasswordReadModel) Reduce() error { wm.PasswordCheckFailedCount += 1 case *user.HumanPasswordCheckSucceededEvent: wm.PasswordCheckFailedCount = 0 + case *user.UserLockedEvent: + wm.UserState = domain.UserStateLocked case *user.UserUnlockedEvent: wm.PasswordCheckFailedCount = 0 + if wm.UserState != domain.UserStateDeleted { + wm.UserState = domain.UserStateActive + } case *user.UserRemovedEvent: wm.UserState = domain.UserStateDeleted case *user.HumanPasswordHashUpdatedEvent: @@ -129,6 +134,7 @@ func (wm *HumanPasswordReadModel) Query() *eventstore.SearchQueryBuilder { user.HumanPasswordCheckSucceededType, user.HumanPasswordHashUpdatedType, user.UserRemovedType, + user.UserLockedType, user.UserUnlockedType, user.UserV1AddedType, user.UserV1RegisteredType,
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
6- github.com/advisories/GHSA-7h8m-vrxx-vr4mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-47111ghsaADVISORY
- github.com/zitadel/zitadel/commit/22e2d5599918864877e054ebe82fb834a5aa1077ghsax_refsource_MISCWEB
- github.com/zitadel/zitadel/releases/tag/v2.38.3ghsax_refsource_MISCWEB
- github.com/zitadel/zitadel/releases/tag/v2.40.5ghsax_refsource_MISCWEB
- github.com/zitadel/zitadel/security/advisories/GHSA-7h8m-vrxx-vr4mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.