Harden user creation from invite
Fixed an issue where an SSO-only invitation could be accepted with a password, creating a local password-authenticated account and bypassing SSO enforcement. The authentication mode is now derived solely from the invite. Derive the authentication mode from the invite record instead of client input during invite acceptance.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Fixed an issue where an SSO-only invitation could be accepted with a password, creating a local password-authenticated account and bypassing SSO enforcement. The authentication mode is now derived solely from the invite.
|
||||
@@ -3023,6 +3023,123 @@ func (s *integrationTestSuite) TestCreateUserFromInviteErrors() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestCreateUserFromSSOInvite() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
createInvite := func(email string, ssoEnabled bool) *fleet.Invite {
|
||||
createInviteReq := createInviteRequest{InvitePayload: fleet.InvitePayload{
|
||||
Email: new(email),
|
||||
Name: new("SSO Invitee"),
|
||||
GlobalRole: null.StringFrom(fleet.RoleObserver),
|
||||
SSOEnabled: new(ssoEnabled),
|
||||
}}
|
||||
createInviteResp := createInviteResponse{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/invites", createInviteReq, http.StatusOK, &createInviteResp)
|
||||
t.Cleanup(func() {
|
||||
// Ignore the error: an accepted invite is consumed (deleted) by the
|
||||
// acceptance flow, so it may no longer exist at cleanup time.
|
||||
_ = s.ds.DeleteInvite(ctx, createInviteResp.Invite.ID)
|
||||
})
|
||||
// the token is not returned via the response's json, must get it from the db
|
||||
invite, err := s.ds.Invite(ctx, createInviteResp.Invite.ID)
|
||||
require.NoError(t, err)
|
||||
return invite
|
||||
}
|
||||
|
||||
// An SSO-only invite must not be acceptable through the password flow.
|
||||
t.Run("sso invite rejects password payload", func(t *testing.T) {
|
||||
email := "sso-password-attack@b.c"
|
||||
invite := createInvite(email, true)
|
||||
|
||||
var resp createUserResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
|
||||
Name: new("Attacker"),
|
||||
Email: new(email),
|
||||
Password: &test.GoodPassword,
|
||||
InviteToken: new(invite.Token),
|
||||
}, http.StatusUnprocessableEntity, &resp)
|
||||
|
||||
// no user should have been created
|
||||
_, err := s.ds.UserByEmail(ctx, email)
|
||||
require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err)
|
||||
})
|
||||
|
||||
// An empty password field on an SSO invite must also be rejected: the
|
||||
// presence of the field at all is not allowed.
|
||||
t.Run("sso invite rejects empty password field", func(t *testing.T) {
|
||||
email := "sso-empty-password@b.c"
|
||||
invite := createInvite(email, true)
|
||||
|
||||
var resp createUserResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
|
||||
Name: new("Attacker"),
|
||||
Email: new(email),
|
||||
Password: new(""),
|
||||
SSOInvite: new(true),
|
||||
InviteToken: new(invite.Token),
|
||||
}, http.StatusUnprocessableEntity, &resp)
|
||||
|
||||
_, err := s.ds.UserByEmail(ctx, email)
|
||||
require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err)
|
||||
})
|
||||
|
||||
// The legitimate SSO acceptance flow must create an SSO-enabled user.
|
||||
t.Run("sso invite accepted via sso flow", func(t *testing.T) {
|
||||
email := "sso-legit@b.c"
|
||||
invite := createInvite(email, true)
|
||||
|
||||
var resp createUserResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
|
||||
Name: new("SSO User"),
|
||||
Email: new(email),
|
||||
SSOInvite: new(true),
|
||||
InviteToken: new(invite.Token),
|
||||
}, http.StatusOK, &resp)
|
||||
require.NotNil(t, resp.User)
|
||||
require.True(t, resp.User.SSOEnabled)
|
||||
t.Cleanup(func() { require.NoError(t, s.ds.DeleteUser(ctx, resp.User.ID)) })
|
||||
})
|
||||
|
||||
// A non-SSO invite accepted with SSO flags but no password must be rejected
|
||||
// (and must not panic on a nil password).
|
||||
t.Run("password invite rejects sso payload without password", func(t *testing.T) {
|
||||
email := "password-as-sso@b.c"
|
||||
invite := createInvite(email, false)
|
||||
|
||||
var resp createUserResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
|
||||
Name: new("No Password"),
|
||||
Email: new(email),
|
||||
SSOInvite: new(true),
|
||||
InviteToken: new(invite.Token),
|
||||
}, http.StatusUnprocessableEntity, &resp)
|
||||
|
||||
_, err := s.ds.UserByEmail(ctx, email)
|
||||
require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err)
|
||||
})
|
||||
|
||||
// A non-SSO invite accepted with SSOInvite falsely set must still enforce
|
||||
// password complexity: setting the SSO flag must not let a weak password
|
||||
// slip past validation.
|
||||
t.Run("password invite rejects sso payload with weak password", func(t *testing.T) {
|
||||
email := "password-as-sso-weak@b.c"
|
||||
invite := createInvite(email, false)
|
||||
|
||||
var resp createUserResponse
|
||||
s.DoJSON("POST", "/api/latest/fleet/users", fleet.UserPayload{
|
||||
Name: new("Weak Password"),
|
||||
Email: new(email),
|
||||
Password: new("weak"), // too short, no number or symbol
|
||||
SSOInvite: new(true),
|
||||
InviteToken: new(invite.Token),
|
||||
}, http.StatusUnprocessableEntity, &resp)
|
||||
|
||||
_, err := s.ds.UserByEmail(ctx, email)
|
||||
require.True(t, fleet.IsNotFound(err), "expected no user to be created, got err: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestGetHostSummary() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
+18
-1
@@ -431,9 +431,26 @@ func (svc *Service) CreateUserFromInvite(ctx context.Context, p fleet.UserPayloa
|
||||
// set the payload role property based on an existing invite.
|
||||
p.GlobalRole = invite.GlobalRole.Ptr()
|
||||
p.Teams = &invite.Teams
|
||||
p.MFAEnabled = ptr.Bool(invite.MFAEnabled)
|
||||
p.MFAEnabled = new(invite.MFAEnabled)
|
||||
// Invite ID is only used as a uniq index to prevent a double invite acceptance race condition
|
||||
p.InviteID = &invite.ID
|
||||
p.SSOEnabled = new(invite.SSOEnabled)
|
||||
p.SSOInvite = new(invite.SSOEnabled)
|
||||
if invite.SSOEnabled {
|
||||
// SSO invites must not create local password credentials. Reject the
|
||||
// payload if it carries a password field at all, even an empty one.
|
||||
if p.Password != nil {
|
||||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", "not allowed for SSO invitations"))
|
||||
}
|
||||
} else {
|
||||
// Non-SSO invites require a valid password.
|
||||
if p.Password == nil || *p.Password == "" {
|
||||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", "Password missing required argument"))
|
||||
}
|
||||
if err := fleet.ValidatePasswordRequirements(*p.Password); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("password", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
user, err := svc.NewUser(ctx, p)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user