The Exploit
An attacker with control over an OAuth2-linked account's login method can trick the logout handler into redirecting to an external OIDC provider's logout endpoint, even when the user signed in via password form instead of OAuth2, creating an open redirect vector.
POST /user/logout HTTP/1.1
Host: target.gitea.local
Cookie: i_like_gitea=session-id-here
Content-Length: 0
When SignOut() processes this request for a user with LoginType: OAuth2 but no session.KeySignInMethod set to SignInMethodOAuth2, the handler incorrectly redirects to the OIDC provider's end_session_endpoint (e.g., https://example.com/oidc-logout?post_logout_redirect_uri=https://attacker.com/phishing). An attacker observes a 303 See Other response redirecting to an external domain under attacker control. The user's browser follows the redirect, enabling credential harvesting or session fixation attacks that appear to originate from a trusted OAuth provider.
What the Patch Did
Before:
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
require.NoError(t, err)
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
assert.Equal(t, expectedValues, u.Query())
After:
t.Run("OAuth2SignInRedirectsToOIDC", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-oauth")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
require.NoError(t, ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2))
SignOut(ctx)
// ... assertions for OIDC redirect
})
t.Run("PasswordSignInSkipsOIDC", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-password")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/", test.RedirectURL(resp))
})
The patch enforces a session-based authentication method check (session.KeySignInMethod) before redirecting to an external OIDC endpoint. The critical addition is ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2) in the OAuth2 test case and its absence in the password login test case, which verifies that the SignOut() implementation now consults session state — not just the account's LoginType field — to decide whether to redirect externally. This decouples the account configuration (which may link to OAuth2) from the current session's authentication method, preventing a mismatch that would enable open redirect.
Root Cause
CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
The vulnerability flows from the SignOut() function trusting ctx.Doer.LoginType == auth_model.OAuth2 as sufficient proof that the current session authenticated via OAuth2. An attacker who owns an OAuth2-linked account can sign in using the password form instead, setting ctx.Doer.LoginType to OAuth2 without setting the session's KeySignInMethod to SignInMethodOAuth2. When SignOut() executes, it observes the account's OAuth2 linkage and immediately constructs a redirect to the OIDC provider's logout endpoint, controlled by an external party. The session-state trust boundary is crossed unchecked: account-level configuration is treated as proof of the current authentication method.
Why It Works
The load-bearing line is ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2). Removing it causes the test to fail because SignOut() now checks ctx.Session.Get(session.KeySignInMethod) before invoking the OIDC redirect path. Without the session store populated, SignOut() treats the logout as a regular password-form sign-out and redirects to / instead of the external provider. The other lines in the patch (the test structure, the second t.Run block) serve defensive depth: they document the expected behavior for the password sign-in case, making regression obvious if a future maintainer accidentally relaxes the session check back to account-only logic. They also serve as a regression test, preventing the same bug from being reintroduced under maintenance pressure.
Hardening Checklist
- Audit all OAuth redirect handlers: Search the codebase for
.LoginType == OAuth2comparisons and verify each consults session-state (e.g.,session.KeySignInMethod) before redirecting to an external endpoint. Do not trust account-level configuration alone. - Test password-plus-oauth linkage explicitly: Add test cases for accounts linked to OAuth2 that sign in via password form, and verify they do not trigger OAuth-specific logout flows. Use table-driven tests to cover all combinations of (account login type, session sign-in method).
- Implement a session invariant assertion: Add a function that asserts
ctx.Doer.LoginTypematchesctx.Session.Get(session.KeySignInMethod)at the start of sensitive handlers like logout and token refresh. Log a warning or fail the request if they mismatch, catching future account-linkage logic errors. - Use a type-safe session store: Wrap session reads (e.g.,
ctx.Session.Get()) in a helper that returns a strongly-typed enum (SignInMethod) rather than a raw string, preventing typos and enabling compiler-checked access.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-60004