mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
Compare commits
3
Commits
bb4cccc6e9
...
f6e2ca4388
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e2ca4388 | ||
|
|
61c58b4b65 | ||
|
|
5facdcc7fd |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+13
-13
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user