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
+69
-5
@@ -1036,7 +1036,16 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
|
||||
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail())
|
||||
if errors.Is(err, errLinkedIDAlreadyBound) {
|
||||
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
|
||||
slog.F("email", verifiedEmail.GetEmail()),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "This account is already linked to a different identity provider subject.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("gh_user", ghUser.Name), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -1436,7 +1445,22 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))
|
||||
|
||||
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
|
||||
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email)
|
||||
if errors.Is(err, errLinkedIDAlreadyBound) {
|
||||
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
|
||||
slog.F("email", email),
|
||||
)
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusForbidden,
|
||||
HideStatus: true,
|
||||
Title: "Account already linked",
|
||||
Description: "This account is already linked to a different identity provider subject. Contact your administrator.",
|
||||
Actions: []site.Action{
|
||||
{URL: "/login", Text: "Back to login"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("email", email), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@@ -1870,6 +1894,31 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update user link: %w", err)
|
||||
}
|
||||
|
||||
// Defense-in-depth: if a concurrent transaction backfilled
|
||||
// linked_id between findLinkedUser and this point, reject the
|
||||
// login with a 403 instead of letting it bubble up as a 500.
|
||||
if link.LinkedID != "" && link.LinkedID != params.LinkedID {
|
||||
return &idpsync.HTTPError{
|
||||
Code: http.StatusForbidden,
|
||||
Msg: "Account already linked",
|
||||
Detail: "This account is already linked to a different identity provider subject. Contact your administrator.",
|
||||
RenderStaticPage: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill linked_id for legacy links.
|
||||
if link.LinkedID == "" && params.LinkedID != "" {
|
||||
//nolint:gocritic // System needs to update the user link.
|
||||
link, err = tx.UpdateUserLinkedID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkedIDParams{
|
||||
LinkedID: params.LinkedID,
|
||||
UserID: user.ID,
|
||||
LoginType: params.LoginType,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("backfill user linked id: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = api.IDPSync.SyncOrganizations(ctx, tx, user, params.OrganizationSync)
|
||||
@@ -2090,9 +2139,17 @@ func oidcLinkedID(tok *oidc.IDToken) string {
|
||||
return strings.Join([]string{tok.Issuer, tok.Subject}, "||")
|
||||
}
|
||||
|
||||
// errLinkedIDAlreadyBound is returned by findLinkedUser when the user
|
||||
// found by email already has a user_link with a different linked_id.
|
||||
var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a different identity provider subject")
|
||||
|
||||
// findLinkedUser tries to find a user by their unique OAuth-linked ID.
|
||||
// If it doesn't not find it, it returns the user by their email.
|
||||
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, emails ...string) (database.User, database.UserLink, error) {
|
||||
// If it does not find a match, it falls back to email-based lookup.
|
||||
// The email fallback is restricted to first-time account linking and
|
||||
// legacy links (empty linked_id) only. If the user found by email
|
||||
// already has a link with a different linked_id, errLinkedIDAlreadyBound
|
||||
// is returned to prevent account takeover via IdP email reuse.
|
||||
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) {
|
||||
var (
|
||||
user database.User
|
||||
link database.UserLink
|
||||
@@ -2137,12 +2194,19 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, ema
|
||||
// possible that a user_link exists without a populated 'linked_id'.
|
||||
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: user.ID,
|
||||
LoginType: user.LoginType,
|
||||
LoginType: loginType,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return database.User{}, database.UserLink{}, xerrors.Errorf("get user link by user id and login type: %w", err)
|
||||
}
|
||||
|
||||
// Block email fallback when an existing link has a different linked_id.
|
||||
// Prevents account takeover via IdP email reuse; first-time and legacy
|
||||
// (empty linked_id) links pass through.
|
||||
if err == nil && link.LinkedID != "" && link.LinkedID != linkedID {
|
||||
return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound
|
||||
}
|
||||
|
||||
return user, link, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user