From 23d8a2718eea1a301cbe0cb1572d09d98cfb6dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0ediv=C3=BD?= Date: Wed, 28 Oct 2026 17:05:56 +0100 Subject: [PATCH] oauth: ignore email when provider reports email_verified=false. An unverified email could match admin_emails/user_emails on providers that let users set their own email. Only an explicit false is rejected; providers that omit the claim are still trusted. Document the behavior. Reported-by: Zachariah Mithani (AploSec) --- server/internal/member/oauth/service.go | 6 ++++++ server/internal/member/oauth/utils.go | 15 +++++++++++++ server/internal/member/oauth/utils_test.go | 22 ++++++++++++++++++++ webpage/docs/configuration/authentication.md | 4 ++++ 4 files changed, 47 insertions(+) create mode 100644 server/internal/member/oauth/utils_test.go diff --git a/server/internal/member/oauth/service.go b/server/internal/member/oauth/service.go index 8910125f..6169f9ce 100644 --- a/server/internal/member/oauth/service.go +++ b/server/internal/member/oauth/service.go @@ -168,6 +168,12 @@ func (service *Service) Complete(ctx context.Context, stateToken, code string) ( email = claim(idClaims, "email") } + // Providers that omit email_verified are trusted; only an explicit false is rejected. + if email != "" && (claimFalse(claims, "email_verified") || claimFalse(idClaims, "email_verified")) { + log.Warn().Msg("OAuth provider reported the email as unverified; ignoring it for admin_emails/user_emails") + email = "" + } + if email == "" && (len(config.AdminEmails) > 0 || len(config.UserEmails) > 0) { log.Warn().Msg("OAuth admin_emails/user_emails are configured but the provider did not return an 'email' claim; request the 'email' scope") } diff --git a/server/internal/member/oauth/utils.go b/server/internal/member/oauth/utils.go index b60809cf..24b170c2 100644 --- a/server/internal/member/oauth/utils.go +++ b/server/internal/member/oauth/utils.go @@ -70,6 +70,21 @@ func boolClaim(claims map[string]any, field string) bool { return false } +// claimFalse reports whether the claim is present and explicitly false. +func claimFalse(claims map[string]any, field string) bool { + value, ok := claims[field] + if !ok { + return false + } + switch value := value.(type) { + case bool: + return !value + case string: + return strings.EqualFold(value, "false") + } + return false +} + func administrator(email string, isAdmin bool, emails []string) bool { if isAdmin { return true diff --git a/server/internal/member/oauth/utils_test.go b/server/internal/member/oauth/utils_test.go new file mode 100644 index 00000000..95c4a85d --- /dev/null +++ b/server/internal/member/oauth/utils_test.go @@ -0,0 +1,22 @@ +package oauth + +import "testing" + +func TestClaimFalse(t *testing.T) { + cases := []struct { + name string + claims map[string]any + want bool + }{ + {"absent", map[string]any{}, false}, + {"true", map[string]any{"email_verified": true}, false}, + {"false", map[string]any{"email_verified": false}, true}, + {"string true", map[string]any{"email_verified": "true"}, false}, + {"string false", map[string]any{"email_verified": "False"}, true}, + } + for _, tc := range cases { + if got := claimFalse(tc.claims, "email_verified"); got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/webpage/docs/configuration/authentication.md b/webpage/docs/configuration/authentication.md index f5922245..8c588d3c 100644 --- a/webpage/docs/configuration/authentication.md +++ b/webpage/docs/configuration/authentication.md @@ -234,6 +234,10 @@ The sign-in endpoint is `GET /api/oauth/login`; the callback endpoint is `GET /a `admin_emails` and `user_emails` are matched against the `email` claim. Most providers only include that claim if the `email` scope is requested, so `scopes` must include `"email"` (in addition to `"openid"`) or the claim will be empty and these lists will never match. ::: +:::warning +If the provider returns `email_verified: false`, Neko ignores the email, so it cannot match `admin_emails` or `user_emails`. If the claim is missing, the email is trusted, because many providers (including generic OAuth2 providers such as GitHub) do not return it at all. In that case `admin_emails` and `user_emails` rely on the identity provider having verified the address. If users can register or edit their email without verification on a provider that does not send `email_verified`, anyone could claim an address listed in `admin_emails` and obtain an administrator session. Require email verification at the provider or do not use these lists. +::: + For OAuth sessions, `GET /api/whoami` includes `extra_data`: the merged userinfo and ID-token claims, with userinfo values taking precedence. It is returned only to the authenticated session owner and is intended for profile-field troubleshooting. Do not place secrets in provider profile claims. For non-OIDC providers, set `authorization_url`, `token_url`, `userinfo_url`, and optionally `redirect_url` directly. For GitHub, for example, use `read:user` as a scope and set `subject_field: id`, `username_field: login`, and `avatar_field: avatar_url`.