mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd)!: restrict OIDC email fallback to first-time account linking (#25712)
## Problem `findLinkedUser` in `coderd/userauth.go` falls back to email-based user lookup when no `linked_id` match is found. This fallback was used for **all logins**, not just first-time linking. An attacker who registers the victim's email at the IdP (with a different OIDC subject) bypasses the `linked_id` check and gets matched to the victim's Coder account. Combined with the `email_verified` type assertion bypass (PLAT-228), this creates a chained account-takeover vector. ## Fix Restrict the email fallback in `findLinkedUser` so that when a user found by email already has a `user_link` with a non-empty `linked_id` that **differs** from the current login's `linked_id`, the function returns no user. This blocks account takeover while preserving: - **First-time linking**: No existing `user_link` exists, email fallback works as before. - **Legacy links**: Empty `linked_id` (pre-migration), email fallback still works. - **Normal logins**: Matching `linked_id` resolves via the primary path, no fallback needed. Also adds a `UpdateUserLinkedID` query to backfill `linked_id` on legacy links (only when currently empty) during login, gradually migrating them to the secure path. The `findLinkedUser` signature now accepts `loginType` explicitly instead of relying on `user.LoginType`, ensuring the correct link is checked in the legacy lookup. ## Breaking change Marked `release/breaking`. An account whose `user_link` already has a populated `linked_id` that does not match the subject the IdP presents will now be denied login (403) instead of silently resolving via the email fallback. The most likely trigger is changing `CODER_OIDC_ISSUER_URL` (the `linked_id` is `issuer||subject`), or two identities sharing one email. Accounts with an empty (legacy) `linked_id` are unaffected and are backfilled on their next login. Fixes: https://linear.app/codercom/issue/PLAT-229 <details><summary>Implementation details</summary> ### Files changed - `coderd/userauth.go`: Core fix in `findLinkedUser` + backfill logic in `oauthLogin` - `coderd/database/queries/user_links.sql`: New `UpdateUserLinkedID` query - `coderd/database/dbauthz/dbauthz.go`: Authorization for new query (`ActionUpdate` on the user object, matching `InsertUserLink`) - `coderd/userauth_test.go`: New OIDC and GitHub tests - Generated files: `queries.sql.go`, `querier.go`, `dbmock.go`, `querymetrics.go` ### New tests - `TestUserOIDC/OIDCEmailFallbackBlockedByExistingLink`: Attacker with a different `sub` but the same email is rejected (403) when the victim has an existing link (covers signups enabled and disabled). - `TestUserOIDC/OIDCFirstTimeLinkByEmailAllowed`: User created via SCIM/API (no `user_link`) can still link via email on first OIDC login, and the `linked_id` is populated. - `TestUserOIDC/OIDCLegacyLinkBackfill`: User with empty `linked_id` can login and their `linked_id` is backfilled with the correct value. - `TestUserOIDC/OIDCEmailFallbackBlockedByIssuerChange`: Existing link recorded under a previous issuer is rejected (403) after the issuer changes (documents the breaking behavior). - `TestUserOAuth2Github/EmailFallbackBlockedByExistingLink`: GitHub attacker with a different user ID but the victim's email is rejected (403). </details> > [!NOTE] > This PR was authored by Coder Agents on behalf of @f0ssel. --------- Co-authored-by: Coder Agents <agents@coder.com>
This commit is contained in:
co-authored by
Coder Agents
parent
76bf462bbf
commit
53d287a139
@@ -7573,6 +7573,13 @@ func (q *querier) UpdateUserLink(ctx context.Context, arg database.UpdateUserLin
|
||||
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateUserLink)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil {
|
||||
return database.UserLink{}, err
|
||||
}
|
||||
return q.db.UpdateUserLinkedID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return database.User{}, err
|
||||
|
||||
@@ -3170,6 +3170,12 @@ func (s *MethodTestSuite) TestUser() {
|
||||
dbm.EXPECT().UpdateUserLink(gomock.Any(), arg).Return(link, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link)
|
||||
}))
|
||||
s.Run("UpdateUserLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
link := testutil.Fake(s.T(), faker, database.UserLink{})
|
||||
arg := database.UpdateUserLinkedIDParams{LinkedID: link.LinkedID, UserID: link.UserID, LoginType: link.LoginType}
|
||||
dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(link, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(link, policy.ActionUpdate).Returns(link)
|
||||
}))
|
||||
s.Run("UpdateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}})
|
||||
o := u
|
||||
|
||||
Reference in New Issue
Block a user