fix: Various security fixes (#38103)

- Enforce org visibility on organization label read endpoints (private
org labels no longer leak to non-members).
- Block fork sync (`merge-upstream`) when the base repo is no longer
readable (stops pulling commits after a parent goes private).
- Remove `REVERSE_PROXY_LIMIT` / `REVERSE_PROXY_TRUSTED_PROXIES` from
the Docker `app.ini` templates (the `= *` default allowed
`X-WEBAUTH-USER` impersonation; reverse-proxy auth is now opt-in and
admin-configured).
- Enforce single-use TOTP passcodes across web login, password-reset,
and Basic-Auth `X-Gitea-OTP` (fixes a TOCTOU race and a stateless
replay).
- Re-check branch write permission for every ref in a push (the
pre-receive hook cached the first ref's result, letting a per-branch
maintainer-edit grant escalate to full repo write).

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni
2026-06-17 18:06:51 +02:00
committed by GitHub
parent c68925152b
commit 68692e19d4
16 changed files with 280 additions and 48 deletions
-2
View File
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
[security] [security]
INSTALL_LOCK = $INSTALL_LOCK INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service] [service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION DISABLE_REGISTRATION = $DISABLE_REGISTRATION
-2
View File
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
[security] [security]
INSTALL_LOCK = $INSTALL_LOCK INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service] [service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION DISABLE_REGISTRATION = $DISABLE_REGISTRATION
+28 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
) )
// //
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil return nil
} }
// ValidateTOTP validates the provided passcode. // validateTOTP validates the provided passcode. It does not consume the passcode; all login
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { // surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret) decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
if err != nil { if err != nil {
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err) return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
} }
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret) secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
if err != nil { if err != nil {
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err) return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
} }
secretStr := string(secretBytes) secretStr := string(secretBytes)
return totp.Validate(passcode, secretStr), nil return totp.Validate(passcode, secretStr), nil
} }
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
ok, err := t.validateTOTP(passcode)
if err != nil || !ok {
return false, err
}
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return false, err
}
return n == 1, nil
}
// NewTwoFactor creates a new two-factor authentication token. // NewTwoFactor creates a new two-factor authentication token.
func NewTwoFactor(ctx context.Context, t *TwoFactor) error { func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
_, err := db.GetEngine(ctx).Insert(t) _, err := db.GetEngine(ctx).Insert(t)
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth_test
import (
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: 1}
require.NoError(t, tfa.SetSecret(key.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
require.NoError(t, err)
assert.False(t, ok)
}
+16 -1
View File
@@ -505,6 +505,21 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
} }
} }
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
func reqOrgVisible() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
if ctx.Org.Organization == nil {
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
return
}
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
ctx.APIErrorNotFound()
return
}
}
}
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) { func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
if ctx.IsUserSiteAdmin() { if ctx.IsUserSiteAdmin() {
return 0, true, true return 0, true, true
@@ -1728,7 +1743,7 @@ func Routes() *web.Router {
m.Combo("/{id}").Get(reqToken(), org.GetLabel). m.Combo("/{id}").Get(reqToken(), org.GetLabel).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel). Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel) Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
}) }, reqOrgVisible())
m.Group("/hooks", func() { m.Group("/hooks", func() {
m.Combo("").Get(org.ListHooks). m.Combo("").Get(org.ListHooks).
Post(bind(api.CreateHookOption{}), org.CreateHook) Post(bind(api.CreateHookOption{}), org.CreateHook)
+3
View File
@@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) {
} else if errors.Is(err, util.ErrNotExist) { } else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err.Error()) ctx.APIError(http.StatusNotFound, err.Error())
return return
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
return
} }
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+27 -19
View File
@@ -40,9 +40,6 @@ type preReceiveContext struct {
canCreatePullRequest bool canCreatePullRequest bool
checkedCanCreatePullRequest bool checkedCanCreatePullRequest bool
canWriteCode bool
checkedCanWriteCode bool
protectedTags []*git_model.ProtectedTag protectedTags []*git_model.ProtectedTag
gotProtectedTags bool gotProtectedTags bool
@@ -50,24 +47,36 @@ type preReceiveContext struct {
opts *private.HookOptions opts *private.HookOptions
branchName string // this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
} }
// CanWriteCode returns true if pusher can write code func (ctx *preReceiveContext) canWriteCodeUnit() bool {
func (ctx *preReceiveContext) CanWriteCode() bool { if ctx.canWriteCodeUnitCached == nil {
if !ctx.checkedCanWriteCode { var canWrite bool
if !ctx.loadPusherAndPermission() { if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
ctx.canWriteCodeUnitCached = &canWrite
}
return *ctx.canWriteCodeUnitCached
}
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if ctx.canWriteCodeUnit() {
return true
}
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
if !refFullName.IsBranch() {
return false return false
} }
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
ctx.checkedCanWriteCode = true
}
return ctx.canWriteCode
} }
// AssertCanWriteCode returns true if pusher can write code // assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
func (ctx *preReceiveContext) AssertCanWriteCode() bool { func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
if !ctx.CanWriteCode() { if !ctx.canWriteCodeRef(refFullName) {
if ctx.Written() { if ctx.Written() {
return false return false
} }
@@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor(): case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName) preReceiveFor(ourCtx, refFullName)
default: default:
ourCtx.AssertCanWriteCode() ourCtx.assertCanWriteRef(refFullName)
} }
if ctx.Written() { if ctx.Written() {
return return
@@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName() branchName := refFullName.BranchName()
ctx.branchName = branchName
if !ctx.AssertCanWriteCode() { if !ctx.assertCanWriteRef(refFullName) {
return return
} }
@@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) { func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
if !ctx.AssertCanWriteCode() { if !ctx.assertCanWriteRef(refFullName) {
return return
} }
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
require.NoError(t, baseRepo.LoadOwner(t.Context()))
require.NoError(t, headRepo.LoadOwner(t.Context()))
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
// repo owner write access to exactly this head branch and nothing else.
pr := &issues_model.PullRequest{
Issue: &issues_model.Issue{
RepoID: baseRepo.ID,
PosterID: headRepo.OwnerID,
},
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "granted-branch",
BaseBranch: "master",
AllowMaintainerEdit: true,
}
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
maintainer := baseRepo.Owner
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
require.NoError(t, err)
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
loadedPusher: true,
user: maintainer,
userPerm: headPerm,
}
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
// ...but another branch in the same push must NOT inherit that grant.
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
}
+3 -9
View File
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return return
} }
// Validate the passcode with the stored TOTP secret. // Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateTOTP(form.Passcode) ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil { if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return
} }
if ok && twofa.LastUsedPasscode != form.Passcode { if ok {
remember := ctx.Session.Get("twofaRemember").(bool) remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id) u, err := user_model.GetUserByID(ctx, id)
if err != nil { if err != nil {
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
} }
} }
twofa.LastUsedPasscode = form.Passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember) handleSignIn(ctx, u, remember)
return return
+3 -9
View File
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true regenerateScratchToken = true
} else { } else {
passcode := ctx.FormString("passcode") passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateTOTP(passcode) ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
if err != nil { if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error()) ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
return return
} }
if !ok || twofa.LastUsedPasscode == passcode { if !ok {
ctx.Data["IsResetForm"] = true ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
return return
} }
twofa.LastUsedPasscode = passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
return
}
} }
} }
+1 -1
View File
@@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch") branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false) _, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil { if err != nil {
if errors.Is(err, util.ErrNotExist) { if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
ctx.JSONErrorNotFound() ctx.JSONErrorNotFound()
return return
} else if pull_service.IsErrMergeConflicts(err) { } else if pull_service.IsErrMergeConflicts(err) {
+2 -1
View File
@@ -177,7 +177,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
} }
return err return err
} }
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil { // Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
return err return err
} else if !ok { } else if !ok {
return util.NewInvalidArgumentErrorf("invalid provided OTP") return util.NewInvalidArgumentErrorf("invalid provided OTP")
+13
View File
@@ -8,7 +8,9 @@ import (
"fmt" "fmt"
issue_model "gitea.dev/models/issues" issue_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/gitrepo" "gitea.dev/modules/gitrepo"
@@ -26,6 +28,17 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
if err = repo.GetBaseRepo(ctx); err != nil { if err = repo.GetBaseRepo(ctx); err != nil {
return "", err return "", err
} }
// The doer must still be able to read the base repository's code. Otherwise a fork created
// while the base repo was public could keep pulling commits after it turned private.
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
if err != nil {
return "", err
}
if !basePerm.CanRead(unit.TypeCode) {
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
}
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch) divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
if err != nil { if err != nil {
return "", err return "", err
+48
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
org_model "gitea.dev/models/organization" org_model "gitea.dev/models/organization"
"gitea.dev/models/perm" "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -292,3 +293,50 @@ func testAPIDeleteOrgRepos(t *testing.T) {
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
}) })
} }
// TestAPIOrgLabelsVisibility ensures the organization label read endpoints honor
// the organization visibility: labels of a private org must not be disclosed to
// users who cannot see the org (GHSA: unauthorized access to private org labels).
func TestAPIOrgLabelsVisibility(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// privated_org (id 23) is a private organization; user5 is its only member.
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
require.NoError(t, issues_model.NewLabel(t.Context(), label))
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
t.Run("NonMemberDenied", func(t *testing.T) {
// user2 is not a member of the private org and must not see its labels.
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
})
t.Run("AnonymousDenied", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
})
t.Run("MemberAllowed", func(t *testing.T) {
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
labels := DecodeJSON(t, resp, &[]*api.Label{})
assert.Len(t, *labels, 1)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("SiteAdminAllowed", func(t *testing.T) {
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
})
t.Run("PublicOrgStillReadable", func(t *testing.T) {
// org3 (id 3) is a public org with labels; non-members may read them.
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
})
}
+6
View File
@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
AddBasicAuth(user.Name) AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode) req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
req = NewRequest(t, "GET", "/api/v1/user").
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusUnauthorized)
} }
func TestBasicAuthWithWebAuthn(t *testing.T) { func TestBasicAuthWithWebAuthn(t *testing.T) {
@@ -171,5 +171,18 @@ func TestRepoMergeUpstream(t *testing.T) {
}).AddTokenAuth(token) }).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest) MakeRequest(t, req, http.StatusBadRequest)
}) })
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
// add a new commit to the base repo, then make the base repo private
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
baseRepo.IsPrivate = true
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
require.NoError(t, err)
// the fork owner can no longer read the base repo, so syncing must be refused
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
Branch: "fork-branch",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
})
}) })
} }