fix: validate "scope" for OAuth2, "state" for OpenID (#39642)

1. Fixes: https://github.com/go-gitea/gitea/issues/39641
* The OAuth2 authorize/grant endpoint accepted and persisted arbitrary
scope strings without validation, unlike personal access token creation
which rejects unknown scopes. Normalize and validate form.Scope before
creating or updating a grant, ignore invalid scopes
* `// TODO: at the moment invalid tokens are treated as "all" to avoid
breaking 1.22 behavior` is fixed.
2. To avoid CSRF attack for OpenID endpoint, add a state check

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Shivansh Gargandwxiaoguang authored and GitHub committed 2026-10-12 20:57:41 +00:00
1 parent 4b5f1f46de
commit f270534e83
9 files changed
+257 -57

No files matched your search

+43 -17
View File
@@ -72,6 +72,7 @@ type AccessTokenResponse struct {
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope,omitempty"`
}
func GeneralScopesSupported() []string {
@@ -83,29 +84,53 @@ func GeneralScopesSupported() []string {
}
}
// GrantAdditionalScopes returns valid scopes coming from grant
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
func splitGrantScopes(grantScopes string) (generalScopes []string, accessScope auth.AccessTokenScope) {
generalScopesSupported := GeneralScopesSupported()
var accessScopes []string // the scopes for access control, but not for general information
var accessScopes []string
hasAccessScopes := false
for scope := range strings.SplitSeq(grantScopes, " ") {
if scope != "" && !slices.Contains(generalScopesSupported, scope) {
accessScopes = append(accessScopes, scope)
if scope == "" {
continue
}
if slices.Contains(generalScopesSupported, scope) {
generalScopes = append(generalScopes, scope)
continue
}
hasAccessScopes = true
for accessScope := range strings.SplitSeq(scope, ",") {
if normalizedScope, err := auth.AccessTokenScope(accessScope).Normalize(); err == nil {
accessScopes = append(accessScopes, normalizedScope.StringSlice()...)
}
}
}
if !hasAccessScopes {
return generalScopes, ""
}
// since version 1.22, access tokens grant full access to the API
// with this access is reduced only if additional scopes are provided
if len(accessScopes) > 0 {
accessTokenScope := auth.AccessTokenScope(strings.Join(accessScopes, ","))
if normalizedAccessTokenScope, err := accessTokenScope.Normalize(); err == nil {
return normalizedAccessTokenScope
}
// TODO: if there are invalid access scopes (err != nil),
// then it is treated as "all", maybe in the future we should make it stricter to return an error
// at the moment, to avoid breaking 1.22 behavior, invalid tokens are also treated as "all"
// Since version 1.22, if no access scope is provided, then "all" access is granted.
//
// OAuth also permits ignoring requested scopes under server policy: https://www.rfc-editor.org/rfc/rfc6749.html#section-3.3
// If after the removal the invalid scopes, there are no valid scopes left, we return no-token-scope to prevent granting "all" access.
accessScope, _ = auth.AccessTokenScope(strings.Join(accessScopes, ",")).Normalize()
if accessScope == "" {
accessScope = auth.AccessTokenScopeNone
}
// fallback, empty access scope is treated as "all" access
return auth.AccessTokenScopeAll
return generalScopes, accessScope
}
// NormalizeGrantScope retains OIDC scopes and uses no-token-scope to prevent discarded scopes from granting full access.
func NormalizeGrantScope(grantScopes string) string {
generalScopes, accessScope := splitGrantScopes(grantScopes)
return strings.Join(append(generalScopes, accessScope.StringSlice()...), " ")
}
// GrantAdditionalScopes returns valid scopes coming from grant.
func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope {
_, accessScope := splitGrantScopes(grantScopes)
if accessScope == "" {
return auth.AccessTokenScopeAll // grants without access scopes retain the legacy default
}
return accessScope
}
func NewJwtRegisteredClaimsFromUser(clientID string, grantUserID int64, exp *jwt.NumericDate) jwt.RegisteredClaims {
@@ -241,6 +266,7 @@ func NewAccessTokenResponse(ctx context.Context, grant *auth.OAuth2Grant, server
return &AccessTokenResponse{
AccessToken: signedAccessToken,
Scope: NormalizeGrantScope(grant.Scope),
TokenType: TokenTypeBearer,
ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
RefreshToken: signedRefreshToken,
@@ -23,10 +23,15 @@ func TestGrantAdditionalScopes(t *testing.T) {
{"read:user read:repository", "read:repository,read:user"},
{"read:user write:issue public-only", "public-only,write:issue,read:user"},
{"openid profile email read:user", "read:user"},
// TODO: at the moment invalid tokens are treated as "all" to avoid breaking 1.22 behavior (more details are in GrantAdditionalScopes)
{"read:invalid_scope", "all"},
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"},
{"no-token-scope", "no-token-scope"},
{"openid no-token-scope", "no-token-scope"},
{"no-token-scope read:user", "read:user"},
{"read:invalid_scope", "no-token-scope"},
{"read:invalid_scope,write:scope_invalid,just-plain-wrong", "no-token-scope"},
{"openid invalid:scope", "no-token-scope"},
{"openid ,,", "no-token-scope"},
{"read:user invalid:scope", "read:user"},
{"read:user,invalid:scope public-only", "public-only,read:user"},
}
for _, test := range tests {