Merge commit from fork

* fix(login): prevent external-IDP account pre-hijack in Login V1 (GHSA-738m-7888-jfv8)

The Login V1 endpoint POST /ui/login/externaluser/option
(handleExternalNotFoundOptionCheck) built the new external user's identity and
"verified" flags directly from raw POST fields, without checking the server-side
authReq.LinkingUsers state that a genuine, protocol-verified IDP callback
populates. An unauthenticated attacker could forge that POST to create a real
account bound to an arbitrary (IDPConfigID, ExternalUserID) - enabling account
pre-hijack of a third party whose public external identifier (e.g. a GitHub
numeric user id) was used, so the victim's own future "Sign in with X" logs into
the attacker's pre-created account.

Primary fix:
- Reject the request when authReq.LinkingUsers is empty, mirroring the guarded
  sibling autoCreateExternalUser.
- Source IDPConfigID/ExternalUserID and the email/phone "verified" flags from
  the trusted linkingUser (the real callback data) instead of the form; the
  verified flag only survives when the editable value still matches the IDP's.
  Profile fields (name, nickname, language, edited email/phone) still come from
  the form, which is the page's legitimate purpose.

Secondary hardening:
- Treat the OAuth error/error_description callback params as untrusted so a
  forged GET .../externalidp/callback?error=... can no longer trigger the
  local-auth fallback (RequestLocalAuth) and undermine the IDP-first login
  priority. Genuine code-exchange failures keep their existing handling.

Tests:
- Unit test for mapExternalNotFoundOptionFormDataToLoginUser proving forged
  identity/verified fields are ignored in favor of the trusted callback data.
- A //go:build integration end-to-end reproduction driving the forged
  registration over raw HTTP and asserting the account is not created.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(login): handle IDP error callback for logging, keep local-auth fallback

Addresses review feedback (livio-a) on GHSA-738m-7888-jfv8: the OAuth error
callback is not a security boundary. Blocking only the error= path added no
protection (a garbage code triggers the same fallback), the downgrade only
affects the attacker's own auth request, and a genuine IDP error on the callback
should still fall back to local auth.

- handleExternalLoginCallback: on an IDP error response, log it with context and
  route through externalAuthCallbackFailed so local-auth fallback still happens
  when the login policy allows it (previous behavior), instead of rendering a
  plain login error. The displayed message reuses the existing
  Errors.User.ExternalIDP.LoginFailedSwitchLocal via WrapIdPError.
- Drop the now-unused Errors.ExternalIDP.AuthenticationFailed translation (would
  otherwise need all-language entries) and regenerate the login statik blob.
- Reword the externalIDPCallbackData Error/ErrorDescription comment: logging-only,
  not a security decision.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Marco A.
2026-07-29 05:50:40 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Livio Spring
parent fe935d91b8
commit a17af0fe93
3 changed files with 490 additions and 9 deletions
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/zitadel/internal/domain"
@@ -13,6 +14,184 @@ import (
provideridp "github.com/zitadel/zitadel/internal/repository/idp"
)
// Test_mapExternalNotFoundOptionFormDataToLoginUser is a regression test for
// GHSA-738m-7888-jfv8. The "external account not found" page lets a user edit
// their *profile* (name, email, language, ...) before the account is created,
// but the identity that account is bound to must come from the real, verified
// IDP callback (carried in authReq.LinkingUsers), never from the raw POST body.
//
// Before the fix, this mapping trusted the request's own external-* fields, so an
// attacker could forge an arbitrary (IDPConfigID, ExternalUserID) binding and a
// self-asserted "verified" email. After the fix, the security-critical fields are
// sourced from the trusted linkingUser, and the verified flags only survive when
// the (editable) form value still matches what the IDP actually returned.
func Test_mapExternalNotFoundOptionFormDataToLoginUser(t *testing.T) {
type args struct {
formData *externalNotFoundOptionFormData
linkingUser *domain.ExternalUser
}
tests := []struct {
name string
args args
want *domain.ExternalUser
}{
{
// The attacker sets the external identity fields in the POST body to a
// victim's identifier / a chosen IDP. The result must ignore them and use
// the trusted linkingUser instead.
"forged external identity in form is ignored, trusted linkingUser wins",
args{
formData: &externalNotFoundOptionFormData{
externalRegisterFormData: externalRegisterFormData{
ExternalIDPConfigID: "attacker-chosen-idp",
ExternalIDPExtUserID: "victim-github-id",
Email: domain.EmailAddress("[email protected]"),
Username: "attacker",
Firstname: "At",
Lastname: "Tacker",
Nickname: "a",
Language: "en",
},
},
linkingUser: &domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Email: domain.EmailAddress("[email protected]"),
},
},
&domain.ExternalUser{
IDPConfigID: "real-idp", // from trusted linkingUser, NOT the form
ExternalUserID: "real-callback-user-id", // from trusted linkingUser, NOT the form
PreferredUsername: "attacker",
DisplayName: "[email protected]",
FirstName: "At",
LastName: "Tacker",
NickName: "a",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: false, // linkingUser.IsEmailVerified is false
PreferredLanguage: language.English,
},
},
{
// The attacker sets external-email-verified=true and email==external-email
// in the POST body; before the fix that alone marked the email verified.
"forged verified flag in form cannot mark email verified",
args{
formData: &externalNotFoundOptionFormData{
externalRegisterFormData: externalRegisterFormData{
ExternalEmail: domain.EmailAddress("[email protected]"),
ExternalEmailVerified: true,
Email: domain.EmailAddress("[email protected]"),
Language: "en",
},
},
linkingUser: &domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: false,
},
},
&domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
DisplayName: "[email protected]",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: false, // trusted linkingUser was not verified
PreferredLanguage: language.English,
},
},
{
// Genuine flow: the IDP returned a verified email and the user did not
// change it on the page, so it stays verified.
"genuinely verified email preserved when user keeps it unchanged",
args{
formData: &externalNotFoundOptionFormData{
externalRegisterFormData: externalRegisterFormData{
Email: domain.EmailAddress("[email protected]"),
Language: "en",
},
},
linkingUser: &domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: true,
},
},
&domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
DisplayName: "[email protected]",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: true, // verified by IDP and unchanged by the user
PreferredLanguage: language.English,
},
},
{
// Genuine flow but the user edits the email away from the IDP-verified one:
// the new address is unverified until proven, so the flag must drop.
"verified email drops when user edits it away from the IDP value",
args{
formData: &externalNotFoundOptionFormData{
externalRegisterFormData: externalRegisterFormData{
Email: domain.EmailAddress("[email protected]"),
Language: "en",
},
},
linkingUser: &domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: true,
},
},
&domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
DisplayName: "[email protected]",
Email: domain.EmailAddress("[email protected]"),
IsEmailVerified: false, // edited away from the verified value
PreferredLanguage: language.English,
},
},
{
// Same trust rule for the phone: verified only when the IDP verified it
// and the user kept the value; a forged form phone-verified is ignored.
"phone verified only when IDP-verified and unchanged",
args{
formData: &externalNotFoundOptionFormData{
externalRegisterFormData: externalRegisterFormData{
ExternalPhone: domain.PhoneNumber("+41791234567"),
ExternalPhoneVerified: true,
Phone: domain.PhoneNumber("+41791234567"),
Language: "en",
},
},
linkingUser: &domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Phone: domain.PhoneNumber("+41791234567"),
IsPhoneVerified: true,
},
},
&domain.ExternalUser{
IDPConfigID: "real-idp",
ExternalUserID: "real-callback-user-id",
Phone: domain.PhoneNumber("+41791234567"),
IsPhoneVerified: true,
PreferredLanguage: language.English,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mapExternalNotFoundOptionFormDataToLoginUser(tt.args.formData, tt.args.linkingUser)
assert.Equal(t, tt.want, got)
})
}
}
func Test_hasEmailChanged(t *testing.T) {
type args struct {
user *query.User