mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-06 16:15:52 +00:00
fix: various security fixes (#38406)
Addresses a batch of privately reported security issues, grouped by area: - **SSRF** - migration PR-patch/asset fetches, OAuth2 avatar & OpenID discovery, pull-mirror URL re-validation, and the outbound proxy path. - **Access-token scope** - prevent scope escalation on token creation; keep public-only tokens confined (feeds, packages, Actions listings, star/watch lists, limited/private owners). - **Access control / disclosure** - go-get default-branch leak, webhook authorization-header leak, watch clearing on private transitions, label/attachment scoping. - **Denial of service** - input bounds for npm dist-tags, Debian control files, Arch file lists, and SSH keys. ### 📌 Attention for site admins Not breaking - existing configs keep working - but two changes are worth a look: - **New SSRF protection** Outbound requests (migrations, OAuth2 avatars, OpenID discovery, pull mirrors, proxy path) are now validated against the allow/block host lists. If your instance legitimately reaches internal hosts, you may need to add them to `[security].ALLOWED_HOST_LIST` (and the relevant `ALLOW_LOCALNETWORKS` settings). - **Deprecation** `[webhook].ALLOWED_HOST_LIST` is deprecated and will be removed in a future release. Use `[security].ALLOWED_HOST_LIST` instead; the old key still works for now. --------- Co-authored-by: TheFox0x7 <thefox0x7@gmail.com> Co-authored-by: techknowlogick <techknowlogick@gitea.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
@@ -18,12 +18,16 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/migration"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
migrations_service "gitea.dev/services/migrations"
|
||||
mirror_service "gitea.dev/services/mirror"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
files_service "gitea.dev/services/repository/files"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScheduleUpdate(t *testing.T) {
|
||||
@@ -144,6 +148,11 @@ jobs:
|
||||
|
||||
func testScheduleUpdateMirrorSync(t *testing.T) {
|
||||
doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) {
|
||||
// the mirror sync re-validates the remote URL, which rejects the local test server unless local
|
||||
// networks are allowed; migrations.Init rebuilds the host allow-list from the setting
|
||||
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
|
||||
require.NoError(t, migrations_service.Init())
|
||||
|
||||
// create mirror repo
|
||||
opts := migration.MigrateOptions{
|
||||
RepoName: "actions-schedule-mirror",
|
||||
|
||||
@@ -372,3 +372,45 @@ func testAPIActionsListRepoWorkflows(t *testing.T) {
|
||||
assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not
|
||||
// leak runs/jobs from repos the caller cannot access.
|
||||
func TestAPIOrgActionsRunsAccessControl(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// org3 has action run 802 (and its jobs) in the private repo5; user28 is an org3 member
|
||||
// (teams 12/13) with no access to repo5.
|
||||
token := getUserToken(t, "user28", auth_model.AccessTokenScopeReadOrganization)
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
||||
for _, r := range runs.Entries {
|
||||
assert.NotEqual(t, int64(802), r.ID, "must not leak a run from an inaccessible repo")
|
||||
}
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
||||
for _, j := range jobs.Entries {
|
||||
assert.NotEqual(t, int64(802), j.RunID, "must not leak a job from an inaccessible repo run")
|
||||
}
|
||||
|
||||
// user1 is a site admin: it normally bypasses the per-repo access filter, but a public-only token
|
||||
// must stay confined to public repos, so the run/job in the private repo5 must not be listed.
|
||||
adminPublicOnly := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(adminPublicOnly)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
adminRuns := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
||||
for _, r := range adminRuns.Entries {
|
||||
assert.NotEqual(t, int64(802), r.ID, "a public-only admin token must not list a private repo's run")
|
||||
}
|
||||
|
||||
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(adminPublicOnly)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
adminJobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
||||
for _, j := range adminJobs.Entries {
|
||||
assert.NotEqual(t, int64(802), j.RunID, "a public-only admin token must not list a private repo's job")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,39 @@ func TestAPIAddIssueLabels(t *testing.T) {
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2})
|
||||
}
|
||||
|
||||
// TestAPIDeleteIssueLabelCrossRepo ensures DeleteIssueLabel does not act on a label
|
||||
// belonging to another repository, and that a foreign-but-existing label ID and a
|
||||
// nonexistent label ID return the same status (no cross-repo enumeration oracle).
|
||||
func TestAPIDeleteIssueLabelCrossRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
// label 5 exists but belongs to repo 10, not repo 1
|
||||
foreignLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 5})
|
||||
assert.NotEqual(t, repo.ID, foreignLabel.RepoID)
|
||||
|
||||
session := loginUser(t, owner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
base := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", repo.OwnerName, repo.Name, issue.Index)
|
||||
|
||||
// a foreign-but-existing label ID must not be accepted (was 204 before the fix)
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, foreignLabel.ID)).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// a nonexistent label ID must return the SAME status, so no oracle exists (was 422 before the fix)
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 9999999)).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// a label that belongs to the repo is still removable
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2, RepoID: repo.ID})
|
||||
addReq := NewRequestWithJSON(t, "POST", base, &api.IssueLabelsOption{Labels: []any{2}}).AddTokenAuth(token)
|
||||
MakeRequest(t, addReq, http.StatusOK)
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 2)).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
|
||||
assert.NoError(t, unittest.LoadFixtures())
|
||||
|
||||
|
||||
@@ -195,4 +195,11 @@ func TestAPIAddTrackedTimes(t *testing.T) {
|
||||
assert.EqualValues(t, 33, apiNewTime.Time)
|
||||
assert.Equal(t, user2.ID, apiNewTime.UserID)
|
||||
assert.EqualValues(t, 947688818, apiNewTime.Created.Unix())
|
||||
|
||||
// adding time for a nonexistent user must return 404, not panic with a 500
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.AddTimeOption{
|
||||
Time: 33,
|
||||
User: "nonexistentuser",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
neturl "net/url"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/packages"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -262,3 +263,31 @@ func TestPackageGeneric(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestPackageGenericPublicOnlyTokenLimitedOwner ensures a public-only token cannot
|
||||
// access packages owned by a limited-visibility owner (only genuinely public owners).
|
||||
func TestPackageGenericPublicOnlyTokenLimitedOwner(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user33 has limited visibility (visible only to authenticated users, not public)
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 33})
|
||||
base := fmt.Sprintf("/api/packages/%s/generic/pkg/1.0.0", owner.Name)
|
||||
|
||||
// upload a package into the limited owner's namespace
|
||||
req := NewRequestWithBody(t, "PUT", base+"/file.bin", bytes.NewReader([]byte{1, 2, 3})).
|
||||
AddBasicAuth(owner.Name)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// a public-only read:package token (even the owner's own) must be refused
|
||||
publicOnlyToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequest(t, "GET", base+"/file.bin").AddTokenAuth(publicOnlyToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
// same via the v1 package API surface (checkTokenPublicOnly)
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/pkg/1.0.0", owner.Name)).AddTokenAuth(publicOnlyToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// a normal read:package token still works, proving only public-only is restricted
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage)
|
||||
req = NewRequest(t, "GET", base+"/file.bin").AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ func TestPackageNpm(t *testing.T) {
|
||||
test(t, http.StatusNotFound, packageTag2, "1.2")
|
||||
test(t, http.StatusOK, packageTag, packageVersion)
|
||||
test(t, http.StatusOK, packageTag2, packageVersion)
|
||||
|
||||
// an oversized dist-tag body is rejected instead of being read unbounded
|
||||
oversized := strings.Repeat("a", 5*1024)
|
||||
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s", tagsRoot, packageTag), strings.NewReader(oversized)).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusRequestEntityTooLarge)
|
||||
})
|
||||
|
||||
t.Run("ListTags", func(t *testing.T) {
|
||||
|
||||
@@ -163,3 +163,22 @@ func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIRepoLimitedOwnerPublicOnly ensures a public-only token cannot reach a public repo
|
||||
// owned by a limited-visibility owner (which is not reachable anonymously).
|
||||
func TestAPIRepoLimitedOwnerPublicOnly(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// limited_org (limited visibility) owns the non-private repo public_repo_on_limited_org
|
||||
const url = "/api/v1/repos/limited_org/public_repo_on_limited_org"
|
||||
|
||||
// a public-only token is confined to genuinely public resources
|
||||
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
||||
req := NewRequest(t, "GET", url).AddTokenAuth(publicOnlyToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// a normal token can still reach the limited owner's public repo
|
||||
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
|
||||
req = NewRequest(t, "GET", url).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -40,9 +40,17 @@ func TestAPICreateHook(t *testing.T) {
|
||||
|
||||
apiHook := DecodeJSON(t, resp, &api.Hook{})
|
||||
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
|
||||
assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
|
||||
// the stored authorization header is a secret and must never be returned by the API
|
||||
assert.Empty(t, apiHook.AuthorizationHeader)
|
||||
assert.Equal(t, "CI notifications", apiHook.Name)
|
||||
|
||||
// a read-scoped token must not be able to read back the authorization header
|
||||
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||
getReq := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID)).
|
||||
AddTokenAuth(readToken)
|
||||
getResp := MakeRequest(t, getReq, http.StatusOK)
|
||||
assert.NotContains(t, getResp.Body.String(), "s3cr3t")
|
||||
|
||||
newName := "Deploy hook"
|
||||
patchReq := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID), api.EditHookOption{
|
||||
Name: &newName,
|
||||
|
||||
@@ -30,6 +30,62 @@ func TestAPICreateAndDeleteToken(t *testing.T) {
|
||||
deleteAPIAccessToken(t, newAccessToken, user)
|
||||
}
|
||||
|
||||
// TestAPICreateTokenScopeEscalation ensures a token-authenticated request cannot
|
||||
// mint a new token with a broader scope than the authenticating token.
|
||||
func TestAPICreateTokenScopeEscalation(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// a write:user-scoped token authenticates the create requests below
|
||||
writeUserToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
// requesting a broader scope ("all") than the authenticating token is rejected
|
||||
req := NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
||||
"name": "escalated",
|
||||
"scopes": []string{"all"},
|
||||
})
|
||||
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// requesting a subset scope ("read:user") is allowed
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
||||
"name": "subset",
|
||||
"scopes": []string{"read:user"},
|
||||
})
|
||||
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// password (non-token) auth may still create a token with any scope
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
||||
"name": "by-password",
|
||||
"scopes": []string{"all"},
|
||||
}).AddBasicAuth(user.Name)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// a public-only token must not mint a token that drops the public-only restriction
|
||||
publicOnlyToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopePublicOnly)
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
||||
"name": "still-public-only",
|
||||
"scopes": []string{"write:user"},
|
||||
})
|
||||
req.Request.SetBasicAuth(user.Name, publicOnlyToken)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var createdToken api.AccessToken
|
||||
DecodeJSON(t, resp, &createdToken)
|
||||
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
|
||||
|
||||
// an unrestricted parent token may create a narrower public-only child: public-only is a restriction,
|
||||
// not a grantable permission, so the subset check must not reject it
|
||||
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
||||
"name": "narrower-public-only",
|
||||
"scopes": []string{"write:user", "public-only"},
|
||||
})
|
||||
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &createdToken)
|
||||
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
|
||||
}
|
||||
|
||||
// TestAPIDeleteMissingToken ensures that error is thrown when token not found
|
||||
func TestAPIDeleteMissingToken(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@@ -8,12 +8,34 @@ import (
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestAPIManageEmailsFeatureDisabled ensures the email management API honors the
|
||||
// manage_credentials feature restriction, matching the web UI.
|
||||
func TestAPIManageEmailsFeatureDisabled(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
|
||||
|
||||
addReq := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &api.CreateEmailOption{
|
||||
Emails: []string{"user2-3@example.com"},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, addReq, http.StatusNotFound)
|
||||
|
||||
delReq := NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &api.DeleteEmailOption{
|
||||
Emails: []string{"user2-2@example.com"},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, delReq, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIListEmails(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
||||
@@ -222,24 +222,34 @@ func TestOAuth2CallbackReactivationGating(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, extLink))
|
||||
|
||||
prepareUserExternalLink := func(t *testing.T, refreshToken string) {
|
||||
prepareUserExternalLink := func(t *testing.T, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
err := user_model.UpdateUserCols(t.Context(), &user_model.User{ID: u.ID, IsActive: false}, "is_active")
|
||||
require.NoError(t, err)
|
||||
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("refresh_token").
|
||||
Update(&user_model.ExternalLoginUser{RefreshToken: refreshToken})
|
||||
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("access_token", "refresh_token", "expires_at").
|
||||
Update(&user_model.ExternalLoginUser{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt})
|
||||
require.NoError(t, err)
|
||||
require.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID}).IsActive)
|
||||
}
|
||||
|
||||
t.Run("admin-disabled user is not reactivated", func(t *testing.T) {
|
||||
prepareUserExternalLink(t, "non-empty-refresh-token")
|
||||
prepareUserExternalLink(t, "an-access-token", "non-empty-refresh-token", time.Now().Add(time.Hour))
|
||||
doOIDCSignIn(t, authSource.Name)
|
||||
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
|
||||
assert.False(t, after.IsActive, "OAuth callback must not re-enable an administrator-disabled account")
|
||||
})
|
||||
|
||||
t.Run("admin-disabled user without refresh token is not reactivated", func(t *testing.T) {
|
||||
// GitHub / OIDC-without-offline_access sources never store a refresh token, so a
|
||||
// stored access token (and no refresh token) is the normal admin-disabled state
|
||||
prepareUserExternalLink(t, "an-access-token", "" /* no refresh token */, time.Now().Add(time.Hour))
|
||||
doOIDCSignIn(t, authSource.Name)
|
||||
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
|
||||
assert.False(t, after.IsActive, "OAuth callback must not re-enable an admin-disabled account on a no-refresh-token source")
|
||||
})
|
||||
|
||||
t.Run("auto-sync-disabled user is reactivated", func(t *testing.T) {
|
||||
prepareUserExternalLink(t, "" /* empty refresh token */)
|
||||
// the auto-sync cron clears all three token fields when it disables a user
|
||||
prepareUserExternalLink(t, "", "", time.Time{})
|
||||
doOIDCSignIn(t, authSource.Name)
|
||||
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
|
||||
assert.True(t, after.IsActive, "OAuth callback must reactivate a sync-disabled account on successful login")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -58,3 +59,51 @@ func TestFeedUser(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestFeedUserPublicOnlyToken ensures a public-only API token cannot surface a user's
|
||||
// private activity through their profile feed, even when authenticated as the owner.
|
||||
func TestFeedUserPublicOnlyToken(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2 has activity on the private repo user2/repo2
|
||||
const privateMarker = "user2/repo2"
|
||||
|
||||
// a normal read:user token authenticated as the owner sees private activity
|
||||
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
|
||||
reqFull := NewRequest(t, "GET", "/user2.rss")
|
||||
reqFull.SetBasicAuth("user2", fullToken)
|
||||
respFull := MakeRequest(t, reqFull, http.StatusOK)
|
||||
assert.Contains(t, respFull.Body.String(), privateMarker)
|
||||
|
||||
// a public-only token must not surface the private activity
|
||||
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
|
||||
reqPublicOnly := NewRequest(t, "GET", "/user2.rss")
|
||||
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
|
||||
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
|
||||
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
|
||||
}
|
||||
|
||||
// TestProfileActivityPublicOnlyToken ensures the HTML profile activity tab does not
|
||||
// surface a user's private activity to a public-only API token, mirroring the RSS/Atom
|
||||
// guard. The /{username} route is AllowBasic, so a public-only PAT used as the Basic
|
||||
// password must still be downgraded even for the feed owner.
|
||||
func TestProfileActivityPublicOnlyToken(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2 has activity on the private repo user2/repo2
|
||||
const privateMarker = "user2/repo2"
|
||||
|
||||
// a normal read:user token authenticated as the owner sees private activity
|
||||
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
|
||||
reqFull := NewRequest(t, "GET", "/user2?tab=activity")
|
||||
reqFull.SetBasicAuth("user2", fullToken)
|
||||
respFull := MakeRequest(t, reqFull, http.StatusOK)
|
||||
assert.Contains(t, respFull.Body.String(), privateMarker)
|
||||
|
||||
// a public-only token must not surface the private activity
|
||||
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
|
||||
reqPublicOnly := NewRequest(t, "GET", "/user2?tab=activity")
|
||||
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
|
||||
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
|
||||
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGoGet(t *testing.T) {
|
||||
@@ -55,3 +59,83 @@ func TestGoGetForSSH(t *testing.T) {
|
||||
|
||||
assert.Equal(t, expected, resp.Body.String())
|
||||
}
|
||||
|
||||
// TestGoGetPrivateRepoBranchNotLeaked ensures the go-get meta endpoint does not disclose a
|
||||
// private repository's default branch name to unauthorized callers.
|
||||
func TestGoGetPrivateRepoBranchNotLeaked(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2/repo2 is private; give it a non-default branch name
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
require.True(t, repo.IsPrivate)
|
||||
repo.DefaultBranch = "secretbranch"
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
|
||||
|
||||
// an unauthenticated caller must see the neutral instance default, not the real branch
|
||||
req := NewRequest(t, "GET", "/user2/repo2?go-get=1")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
|
||||
assert.NotContains(t, resp.Body.String(), "secretbranch")
|
||||
|
||||
// the owner may still see the real default branch
|
||||
session := loginUser(t, "user2")
|
||||
req = NewRequest(t, "GET", "/user2/repo2?go-get=1")
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "secretbranch")
|
||||
}
|
||||
|
||||
// TestGoGetPublicRepoUnderLimitedOwnerBranchNotLeaked ensures the go-get meta endpoint does not disclose
|
||||
// the default branch of a public repo whose owner is not visible to the caller (here a limited org, which
|
||||
// is hidden from anonymous callers but visible to authenticated ones).
|
||||
func TestGoGetPublicRepoUnderLimitedOwnerBranchNotLeaked(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// repo id 38 is a public repo owned by a limited org; give it a non-default branch name
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 38})
|
||||
require.False(t, repo.IsPrivate)
|
||||
require.NoError(t, repo.LoadOwner(t.Context()))
|
||||
require.False(t, repo.Owner.Visibility.IsPublic())
|
||||
repo.DefaultBranch = "secretbranch"
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
|
||||
|
||||
url := fmt.Sprintf("/%s/%s?go-get=1", repo.OwnerName, repo.Name)
|
||||
|
||||
// an anonymous caller cannot see the limited owner, so the neutral instance default is returned
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
|
||||
assert.NotContains(t, resp.Body.String(), "secretbranch")
|
||||
|
||||
// an authenticated caller can see a limited org's public repo, so the real branch is shown
|
||||
session := loginUser(t, "user2")
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "secretbranch")
|
||||
}
|
||||
|
||||
// TestGoGetPrivateRepoBranchNotLeakedToTokenWithoutRepoScope ensures a PAT that was not granted repository
|
||||
// read scope cannot learn a private repo's default branch through go-get, even when the account behind the
|
||||
// token could read the repository.
|
||||
func TestGoGetPrivateRepoBranchNotLeakedToTokenWithoutRepoScope(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2/repo2 is private; give it a non-default branch name
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
require.True(t, repo.IsPrivate)
|
||||
repo.DefaultBranch = "secretbranch"
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
|
||||
|
||||
// a token scoped only to read:misc does not grant repository read: the branch must stay hidden.
|
||||
// Web routes authenticate a token via basic auth (username + token), which also records the scope.
|
||||
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
|
||||
req := NewRequest(t, "GET", "/user2/repo2?go-get=1")
|
||||
req.Request.SetBasicAuth("user2", miscToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
|
||||
assert.NotContains(t, resp.Body.String(), "secretbranch")
|
||||
|
||||
// a token that includes repository read scope may see the real branch
|
||||
repoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
|
||||
req = NewRequest(t, "GET", "/user2/repo2?go-get=1")
|
||||
req.Request.SetBasicAuth("user2", repoToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "secretbranch")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tests"
|
||||
)
|
||||
|
||||
// TestUpdateIssueLabelForeignLabel verifies that the web issue-label action rejects a
|
||||
// label owned by a different repo/org with 404 (indistinguishable from a nonexistent
|
||||
// id), closing the cross-repo label enumeration oracle also on the web side.
|
||||
func TestUpdateIssueLabelForeignLabel(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
// label 3 belongs to org3 — foreign to user2/repo1 (issue 1); must be 404, not a 500 oracle
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/labels?issue_ids=1", map[string]string{
|
||||
"action": "attach",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// a label owned by the repo still attaches normally
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/labels?issue_ids=1", map[string]string{
|
||||
"action": "attach",
|
||||
"id": "2",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
@@ -14,6 +17,9 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/migration"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
migrations "gitea.dev/services/migrations"
|
||||
mirror_service "gitea.dev/services/mirror"
|
||||
release_service "gitea.dev/services/release"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
@@ -124,3 +130,48 @@ func TestMirrorPull(t *testing.T) {
|
||||
mirror = unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
|
||||
assert.Equal(t, lastMirrorSync, mirror.LastSyncUnix)
|
||||
}
|
||||
|
||||
// TestMirrorPullSSRFRevalidation ensures a pull mirror re-validates its remote URL against
|
||||
// the migration allow/block list on every sync, so a mirror whose (network) remote now
|
||||
// points at a disallowed internal host is never fetched.
|
||||
func TestMirrorPullSSRFRevalidation(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
ctx := t.Context()
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
repoPath := repo_model.RepoPath(user.Name, repo.Name)
|
||||
|
||||
// an "internal" server that records whether it was reached
|
||||
var reached atomic.Bool
|
||||
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached.Store(true)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer internal.Close()
|
||||
|
||||
mirrorRepo, err := repo_service.CreateRepositoryDirectly(ctx, user, user, repo_service.CreateRepoOptions{
|
||||
Name: "ssrf_mirror",
|
||||
IsMirror: true,
|
||||
Status: repo_model.RepositoryBeingMigrated,
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
_, err = repo_service.MigrateRepositoryGitData(ctx, user, mirrorRepo, migration.MigrateOptions{
|
||||
RepoName: "ssrf_mirror",
|
||||
Mirror: true,
|
||||
CloneAddr: repoPath,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
mirror, err := repo_model.GetMirrorByRepoID(ctx, mirrorRepo.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// repoint the mirror at the loopback server, which is disallowed once local networks are off
|
||||
require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, internal.URL+"/repo.git"))
|
||||
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, false)()
|
||||
require.NoError(t, migrations.Init())
|
||||
t.Cleanup(func() { _ = migrations.Init() })
|
||||
|
||||
assert.False(t, mirror_service.SyncPullMirror(ctx, mirrorRepo.ID))
|
||||
assert.False(t, reached.Load(), "the disallowed internal remote must not be reached")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
func TestOAuth2AvatarFromPicture(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.OAuth2Client.UpdateAvatar, true)()
|
||||
// the SSRF-protected avatar client blocks loopback by default; allow the loopback mock server here
|
||||
defer test.MockVariableValue(&setting.Security.AllowedHostList, "loopback,external")()
|
||||
|
||||
mockServer := createOAuth2MockProvider()
|
||||
defer mockServer.Close()
|
||||
|
||||
@@ -1405,3 +1405,22 @@ func testOAuthSourceSpecialChars(t *testing.T) {
|
||||
testOAuth2(t, "/user/oauth2/test%2Bplus", http.StatusTemporaryRedirect)
|
||||
testOAuth2(t, "/user/oauth2/test%20plus", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestOAuthUserInfoTokenScope verifies the OIDC userinfo endpoint enforces the
|
||||
// read:user token scope, so a restrictively-scoped token cannot read identity claims.
|
||||
func TestOAuthUserInfoTokenScope(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// a token without the user scope must be rejected
|
||||
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
|
||||
req := NewRequest(t, "GET", "/login/oauth/userinfo")
|
||||
req.SetHeader("Authorization", "Bearer "+miscToken)
|
||||
MakeRequest(t, req, http.StatusForbidden)
|
||||
|
||||
// a token with read:user is allowed and returns the identity claims
|
||||
userToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
|
||||
req = NewRequest(t, "GET", "/login/oauth/userinfo")
|
||||
req.SetHeader("Authorization", "Bearer "+userToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "user2@example.com")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/test"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPullView_ReviewerMissed(t *testing.T) {
|
||||
@@ -95,6 +98,14 @@ func TestPullView_CodeOwner(t *testing.T) {
|
||||
unittest.AssertExistsAndLoadBean(t, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
|
||||
assert.NoError(t, pr.LoadIssue(t.Context()))
|
||||
|
||||
// capture the current PR head ref so we can wait for the async
|
||||
// refs/pull/N/head sync triggered by the next push to complete
|
||||
baseGitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
defer baseGitRepo.Close()
|
||||
headRefBefore, err := baseGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
require.NoError(t, err)
|
||||
|
||||
// update the file on the pr branch
|
||||
_, err = files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: "codeowner-basebranch",
|
||||
@@ -108,9 +119,17 @@ func TestPullView_CodeOwner(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// refs/pull/N/head is refreshed asynchronously by the push hook; wait for
|
||||
// it before evaluating code owners, otherwise the changed-file set may not
|
||||
// yet include user8-file.md and the review request would be missed
|
||||
require.Eventually(t, func() bool {
|
||||
headRefAfter, err := baseGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
return err == nil && headRefAfter != headRefBefore
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
|
||||
reviewNotifiers, err := issue_service.PullRequestCodeOwnersReview(t.Context(), pr)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewNotifiers, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reviewNotifiers, 1)
|
||||
assert.EqualValues(t, 8, reviewNotifiers[0].Reviewer.ID)
|
||||
|
||||
err = issue_service.ChangeTitle(t.Context(), pr.Issue, user2, "[WIP] Test Pull Request")
|
||||
|
||||
@@ -61,6 +61,41 @@ func TestAPIPullUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIPullUpdatePublicOnlyToken verifies that a public-only API token cannot
|
||||
// update (push into) a PR whose head repo is private, even when the base repo
|
||||
// named in the route is public.
|
||||
func TestAPIPullUpdatePublicOnlyToken(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
|
||||
pr := createOutdatedPR(t, user, org26)
|
||||
require.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
require.NoError(t, pr.LoadHeadRepo(t.Context()))
|
||||
require.NoError(t, pr.LoadIssue(t.Context()))
|
||||
|
||||
// make the head repo private while the base repo stays public
|
||||
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(),
|
||||
&repo_model.Repository{ID: pr.HeadRepo.ID, IsPrivate: true}, "is_private"))
|
||||
|
||||
// a public-only write token must be refused (404), not perform the push
|
||||
publicOnlyToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopePublicOnly)
|
||||
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
|
||||
AddTokenAuth(publicOnlyToken)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// the head branch must still be outdated (no push happened)
|
||||
diffCount, err := gitrepo.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, diffCount.Behind)
|
||||
|
||||
// a normal write token still works, proving the guard is scope-specific
|
||||
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func updateRepoPullRequestConfig(t *testing.T, repoID int64, update func(*repo_model.PullRequestsConfig)) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/tests"
|
||||
)
|
||||
|
||||
// TestRepoHomeContentTokenScopes ensures the web repository home page enforces the
|
||||
// repository read scope (and public-only confinement) of an API token used via basic
|
||||
// auth, so a wrongly-scoped token cannot read private repository content.
|
||||
func TestRepoHomeContentTokenScopes(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user2/repo2 is a private repository owned by user2
|
||||
const url = "/user2/repo2"
|
||||
|
||||
// a token without repository scope must be denied
|
||||
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
|
||||
reqDenied := NewRequest(t, "GET", url)
|
||||
reqDenied.SetBasicAuth("user2", miscToken)
|
||||
MakeRequest(t, reqDenied, http.StatusForbidden)
|
||||
|
||||
// a public-only token must be denied on a private repo
|
||||
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
||||
reqPublicOnly := NewRequest(t, "GET", url)
|
||||
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
|
||||
MakeRequest(t, reqPublicOnly, http.StatusForbidden)
|
||||
|
||||
// a token with repository read scope is allowed
|
||||
ownerReadToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
|
||||
reqAllowed := NewRequest(t, "GET", url)
|
||||
reqAllowed.SetBasicAuth("user2", ownerReadToken)
|
||||
MakeRequest(t, reqAllowed, http.StatusOK)
|
||||
}
|
||||
Reference in New Issue
Block a user