Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/16783-sso-invite-password-acceptance
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 117 additions & 0 deletions server/service/integration_core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Comment thread
juan-fdz-hawa marked this conversation as resolved.

func (s *integrationTestSuite) TestGetHostSummary() {
t := s.T()
ctx := context.Background()
Expand Down
19 changes: 18 additions & 1 deletion server/service/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading