Compare commits

...
3 Commits
Author SHA1 Message Date
f6e2ca4388 fix(auth): ignore stale OIDC external login links to organizations (#37875) (#38141)
Backport #37875

This fixes an OIDC sign-in edge case where a stale `external_login_user`
record can still point to an organization or a deleted user.

In that situation, Gitea may keep resolving the external login to the
wrong account during sign-in. For affected instances, this matches the
behavior reported in #36439 and #37812, where a user signing in with
OIDC/Entra ID could appear as an organization, or hit a 404 after that
organization was removed.

- validate the user resolved from `external_login_user` during
OAuth2/OIDC login
- ignore stale links when the linked user no longer exists
- ignore stale links when the linked user is not an individual user
- remove the stale external login row so the sign-in flow can relink the
external account to the correct user

- Fixes #37812
- Related to #36439

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.8) <noreply@anthropic.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-06-17 20:19:24 +00:00
silverwindandGitHub 61c58b4b65 fix(deps): update @playwright/test to 1.60.0 (#38144)
Backports only the `@playwright/test` bump (1.59.0 → 1.60.0) to
`release/v1.26`.

### Why

The `test-e2e` workflow's `make playwright` step downloads the browser,
then hangs indefinitely during zip extraction on Node 24.16.0 (the
version GitHub's `node-version: 24` resolves to). This is the upstream
Node regression https://github.com/nodejs/node/issues/63487, which
Playwright `<=1.59` is vulnerable to and `1.60.0` works around
(https://github.com/microsoft/playwright/issues/41000).

Since `make playwright` has no `timeout-minutes`, the hang burns a
runner until GitHub's 6h job cap, e.g.
https://github.com/go-gitea/gitea/actions/runs/27661523385/job/81806711998

`main` already ships `1.60.0`; this backports only that single
dependency, nothing else.

---
Assisted-by: claude-code:opus-4.8
2026-06-17 19:45:15 +00:00
5facdcc7fd fix: Various sec fixes (#38108) (#38147)
Backport #38108

- Enforce repository token scope on RSS/Atom feed endpoints so a PAT
without repo scope can no longer read private repo commit data.
- Block HTTP redirects during repository migration clones to prevent
SSRF reaching internal addresses via an attacker-controlled redirect.
- Redact the notification subject after repo access is revoked so
private issue/PR metadata is no longer leaked through the notification
API.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-17 21:43:22 +02:00
16 changed files with 338 additions and 48 deletions
+11 -2
View File
@@ -92,8 +92,11 @@ func init() {
}
// GetExternalLogin checks if a externalID in loginSourceID scope already exists
func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) {
return db.GetEngine(ctx).Get(externalLoginUser)
func GetExternalLogin(ctx context.Context, loginSourceID int64, externalID string) (*ExternalLoginUser, bool, error) {
return db.Get[ExternalLoginUser](ctx, builder.Eq{
"external_id": externalID,
"login_source_id": loginSourceID,
})
}
// LinkExternalToUser link the external user to the user
@@ -130,6 +133,12 @@ func RemoveAllAccountLinks(ctx context.Context, user *User) error {
return err
}
// RemoveExternalLoginByExternalID removes a specific external login link by its provider-side identifier.
func RemoveExternalLoginByExternalID(ctx context.Context, loginSourceID int64, externalID string) error {
_, err := db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", externalID, loginSourceID).Delete(new(ExternalLoginUser))
return err
}
// GetUserIDByExternalUserID get user id according to provider and userID
func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) {
var id int64
+3
View File
@@ -121,6 +121,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
cmd.AddArguments("-c", "http.followRedirects=false")
if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false")
}
+23
View File
@@ -4,7 +4,10 @@
package git
import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
@@ -19,3 +22,23 @@ func TestRepoIsEmpty(t *testing.T) {
assert.NoError(t, err)
assert.True(t, isEmpty)
}
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) {
var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true)
w.WriteHeader(http.StatusNotFound)
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
}))
defer redirect.Close()
err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{})
assert.Error(t, err)
assert.False(t, targetHit.Load(), "git must not follow the redirect to the target")
}
+1 -1
View File
@@ -78,7 +78,7 @@
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "4.7.1",
"@eslint/json": "1.2.0",
"@playwright/test": "1.59.0",
"@playwright/test": "1.60.0",
"@stylistic/eslint-plugin": "5.10.0",
"@stylistic/stylelint-plugin": "5.1.0",
"@types/codemirror": "5.60.17",
+13 -13
View File
@@ -238,8 +238,8 @@ importers:
specifier: 1.2.0
version: 1.2.0
'@playwright/test':
specifier: 1.59.0
version: 1.59.0
specifier: 1.60.0
version: 1.60.0
'@stylistic/eslint-plugin':
specifier: 5.10.0
version: 5.10.0(eslint@10.1.0(jiti@2.6.1))
@@ -1076,8 +1076,8 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.59.0':
resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
hasBin: true
@@ -3610,13 +3610,13 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
playwright-core@1.59.0:
resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.0:
resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
hasBin: true
@@ -5216,9 +5216,9 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@playwright/test@1.59.0':
'@playwright/test@1.60.0':
dependencies:
playwright: 1.59.0
playwright: 1.60.0
'@popperjs/core@2.11.8': {}
@@ -7974,11 +7974,11 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
playwright-core@1.59.0: {}
playwright-core@1.60.0: {}
playwright@1.59.0:
playwright@1.60.0:
dependencies:
playwright-core: 1.59.0
playwright-core: 1.60.0
optionalDependencies:
fsevents: 2.3.2
+23 -7
View File
@@ -514,17 +514,33 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
}
// search in external linked users
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID,
}
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
externalLoginUser, hasUser, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
return nil, goth.User{}, err
}
if hasUser {
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
return user, gothUser, err
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, goth.User{}, err
}
if err == nil && user.IsIndividual() {
return user, gothUser, nil
}
// The external login record is stale: the linked user no longer exists, or it exists but is
// not an individual user (only individual users can sign in, so a link pointing at an
// organization, bot or remote user can never resolve). Remove it so the next sign-in can
// relink the external account to the correct user. Nothing is lost, because the link is
// recreated automatically on the next sign-in.
reason := "linked user does not exist"
if err == nil {
reason = fmt.Sprintf("linked user type is %d", user.Type)
}
log.Warn("Ignoring stale external login link [external-id=%s login-source-id=%d user-id=%d]: %s", externalLoginUser.ExternalID, externalLoginUser.LoginSourceID, externalLoginUser.UserID, reason)
if err := user_model.RemoveExternalLoginByExternalID(ctx, externalLoginUser.LoginSourceID, externalLoginUser.ExternalID); err != nil {
return nil, goth.User{}, err
}
}
// no user found to login
+3
View File
@@ -16,6 +16,9 @@ import (
// ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
var commits []*git.Commit
var err error
if ctx.Repo.Commit != nil {
+3
View File
@@ -17,6 +17,9 @@ import (
// ShowFileFeed shows tags and/or releases on the repo as RSS / Atom feed
func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
fileName := ctx.Repo.TreePath
if len(fileName) == 0 {
return
+3
View File
@@ -15,6 +15,9 @@ import (
// shows tags and/or releases on the repo as RSS / Atom feed
func ShowReleaseFeed(ctx *context.Context, repo *repo_model.Repository, isReleasesOnly bool, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
IncludeTags: !isReleasesOnly,
RepoID: ctx.Repo.Repository.ID,
+9
View File
@@ -4,9 +4,18 @@
package feed
import (
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/services/context"
)
// checkRepoFeedTokenScope ensures an API token has repository read scope before a
// feed serves private repository content, mirroring checkDownloadTokenScope for
// downloads. Returns false (and writes the response) when the token is denied.
func checkRepoFeedTokenScope(ctx *context.Context) bool {
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
return !ctx.Written()
}
// RenderBranchFeed render format for branch or file
func RenderBranchFeed(ctx *context.Context, feedType string) {
if ctx.Repo.TreePath == "" {
+3
View File
@@ -16,6 +16,9 @@ import (
// ShowRepoFeed shows user activity on the repo as RSS / Atom feed
func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
if !checkRepoFeedTokenScope(ctx) {
return
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedRepo: repo,
Actor: ctx.Doer,
@@ -57,12 +57,7 @@ func TestSource(t *testing.T) {
err := source.refresh(t.Context(), provider, e)
assert.NoError(t, err)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
assert.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "refresh", e.RefreshToken)
@@ -82,12 +77,7 @@ func TestSource(t *testing.T) {
})
assert.NoError(t, err)
e := &user_model.ExternalLoginUser{
ExternalID: e.ExternalID,
LoginSourceID: e.LoginSourceID,
}
ok, err := user_model.GetExternalLogin(t.Context(), e)
e, ok, err := user_model.GetExternalLogin(t.Context(), e.LoginSourceID, e.ExternalID)
assert.NoError(t, err)
assert.True(t, ok)
assert.Empty(t, e.RefreshToken)
+16 -13
View File
@@ -24,19 +24,22 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
}
// since user only get notifications when he has access to use minimal access mode
if n.Repository != nil {
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
if perm.HasAnyUnitAccessOrPublicAccess() { // if user has been revoked access to repo, do not show repo info
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
}
if n.Repository == nil {
return result
}
perm, err := access_model.GetIndividualUserRepoPermission(ctx, n.Repository, n.User)
if err != nil {
log.Error("GetIndividualUserRepoPermission failed: %v", err)
return result
}
// if the user has been revoked access to the repo, do not leak repo or subject info
if !perm.HasAnyUnitAccessOrPublicAccess() {
return result
}
result.Repository = ToRepo(ctx, n.Repository, perm)
// This permission is not correct and we should not be reporting it
for repository := result.Repository; repository != nil; repository = repository.Parent {
repository.Permissions = nil
}
// handle Subject
+30
View File
@@ -39,6 +39,36 @@ func TestToNotificationThreadOmitsRepoWhenAccessRevoked(t *testing.T) {
assert.Nil(t, thread.Repository)
}
func TestToNotificationThreadOmitsSubjectWhenAccessRevoked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// repo 2 is private; user 4 has no access to it
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
assert.NoError(t, repo.LoadOwner(ctx))
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4, RepoID: repo.ID})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
n := &activities_model.Notification{
ID: 12345,
UserID: user.ID,
RepoID: repo.ID,
Status: activities_model.NotificationStatusUnread,
Source: activities_model.NotificationSourceIssue,
IssueID: issue.ID,
UpdatedUnix: timeutil.TimeStampNow(),
Issue: issue,
Repository: repo,
User: user,
}
thread := ToNotificationThread(ctx, n)
// must not leak private issue metadata once access is revoked
assert.Nil(t, thread.Repository)
assert.Nil(t, thread.Subject)
}
func TestToNotificationThread(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOIDCIgnoresStaleExternalLoginLinks(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameEmail)()
setup := func(t *testing.T, sourceName, sub, userName, email string) (*auth_model.Source, *user_model.User) {
t.Helper()
srv := newFakeOIDCServerWithProfile(t, sub, sub+"-oid", email, "OIDC Test User")
addOAuth2Source(t, sourceName, oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), sourceName)
require.NoError(t, err)
correctUser := &user_model.User{Name: userName, Email: email}
require.NoError(t, user_model.CreateUser(t.Context(), correctUser, &user_model.Meta{}))
return authSource, correctUser
}
// assertRelinked signs in via OIDC and asserts the stale link now points at the correct individual user.
assertRelinked := func(t *testing.T, authSource *auth_model.Source, sub string, correctUser *user_model.User) {
t.Helper()
doOIDCSignIn(t, authSource.Name)
// external_login_user has no "id" column, so order by the primary key instead
externalLink := unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: sub, LoginSourceID: authSource.ID}, unittest.OrderBy("external_id ASC"))
assert.Equal(t, correctUser.ID, externalLink.UserID)
assert.Equal(t, correctUser.Email, externalLink.Email)
assert.Equal(t, "OIDC Test User", externalLink.Name)
}
t.Run("organization", func(t *testing.T) {
const sub, userName, email = "oidc-stale-org-link-sub", "guizar_m", "guizar_m@example.com"
authSource, correctUser := setup(t, "test-oidc-stale-org-link", sub, userName, email)
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
require.NoError(t, user_model.LinkExternalToUser(t.Context(), org, &user_model.ExternalLoginUser{
ExternalID: sub,
UserID: org.ID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
}))
assertRelinked(t, authSource, sub, correctUser)
})
t.Run("deleted user", func(t *testing.T) {
const sub, userName, email = "oidc-stale-deleted-link-sub", "guizar_d", "guizar_d@example.com"
const deletedUserID = 999999
authSource, correctUser := setup(t, "test-oidc-stale-deleted", sub, userName, email)
// link the external account to a user id that does not exist, simulating a deleted user
require.NoError(t, user_model.LinkExternalToUser(t.Context(), &user_model.User{ID: deletedUserID}, &user_model.ExternalLoginUser{
ExternalID: sub,
UserID: deletedUserID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
}))
assertRelinked(t, authSource, sub, correctUser)
})
}
func newFakeOIDCServerWithProfile(t *testing.T, sub, oid, email, name string) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/.well-known/openid-configuration": // discovery document
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"userinfo_endpoint": srv.URL + "/userinfo",
})
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
claims := map[string]any{
"iss": srv.URL,
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"sub": sub,
"oid": oid,
}
payload, _ := json.Marshal(claims)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
// build a JWT-shaped string whose payload encodes claims.
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "fake-access-token",
"token_type": "Bearer",
"id_token": idToken,
})
case "/userinfo":
// sub MUST match the id_token sub; goth rejects mismatches.
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": sub,
"email": email,
"name": name,
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
// doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source.
func doOIDCSignIn(t *testing.T, sourceName string) {
t.Helper()
session := emptyTestSession(t)
// Step 1: initiate login
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/oauth2/"+sourceName), http.StatusTemporaryRedirect)
// Step 2: extract the UUID state that Gitea embedded in the redirect URL.
location := resp.Header().Get("Location")
u, err := url.Parse(location)
require.NoError(t, err)
state := u.Query().Get("state")
require.NotEmpty(t, state, "redirect to OIDC provider must include state")
// Step 3: simulate the provider redirecting back.
callbackURL := fmt.Sprintf("/user/oauth2/%s/callback?code=test-code&state=%s", sourceName, url.QueryEscape(state))
session.MakeRequest(t, NewRequest(t, "GET", callbackURL), http.StatusSeeOther)
}
+39
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@@ -33,3 +34,41 @@ func TestFeedRepo(t *testing.T) {
assert.NotEmpty(t, rss.Channel.Items[0].PubDate)
})
}
// TestFeedRepoContentTokenScopes ensures repository feed endpoints enforce the
// repository token scope, so a PAT without repository scope cannot read private
// repository commit/activity data through RSS/Atom feeds.
func TestFeedRepoContentTokenScopes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is a private repository owned by user2
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
urls := []string{
"/user2/repo2.rss",
"/user2/repo2.atom",
"/user2/repo2/rss/branch/master",
"/user2/repo2/atom/branch/master",
"/user2/repo2/rss/branch/master/README.md",
"/user2/repo2/tags.rss",
"/user2/repo2/tags.atom",
"/user2/repo2/releases.rss",
"/user2/repo2/releases.atom",
}
for _, url := range urls {
t.Run(url, func(t *testing.T) {
// feed routes only accept basic auth, so authenticate as the advisory PoC does (user:token)
reqDenied := NewRequest(t, "GET", url)
reqDenied.SetBasicAuth("user2", miscToken)
// a token without repository scope must be denied
MakeRequest(t, reqDenied, http.StatusForbidden)
reqAllowed := NewRequest(t, "GET", url)
reqAllowed.SetBasicAuth("user2", ownerReadToken)
// a token with repository read scope is allowed
MakeRequest(t, reqAllowed, http.StatusOK)
})
}
}