From 68692e19d40894cf0c036f986f659b7ed9b298f5 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 17 Jun 2026 18:06:51 +0200 Subject: [PATCH] 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 --- docker/root/etc/templates/app.ini | 2 - docker/rootless/etc/templates/app.ini | 2 - models/auth/twofactor.go | 32 +++++++-- models/auth/twofactor_test.go | 47 +++++++++++++ routers/api/v1/api.go | 17 ++++- routers/api/v1/repo/branch.go | 3 + routers/private/hook_pre_receive.go | 46 +++++++----- routers/private/hook_pre_receive_test.go | 70 +++++++++++++++++++ routers/web/auth/2fa.go | 12 +--- routers/web/auth/password.go | 12 +--- routers/web/repo/branch.go | 2 +- services/auth/basic.go | 3 +- services/repository/merge_upstream.go | 13 ++++ tests/integration/api_org_test.go | 48 +++++++++++++ tests/integration/api_twofa_test.go | 6 ++ tests/integration/repo_merge_upstream_test.go | 13 ++++ 16 files changed, 280 insertions(+), 48 deletions(-) create mode 100644 models/auth/twofactor_test.go create mode 100644 routers/private/hook_pre_receive_test.go diff --git a/docker/root/etc/templates/app.ini b/docker/root/etc/templates/app.ini index 01fb407f49..1794d1f128 100644 --- a/docker/root/etc/templates/app.ini +++ b/docker/root/etc/templates/app.ini @@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log [security] INSTALL_LOCK = $INSTALL_LOCK SECRET_KEY = $SECRET_KEY -REVERSE_PROXY_LIMIT = 1 -REVERSE_PROXY_TRUSTED_PROXIES = * [service] DISABLE_REGISTRATION = $DISABLE_REGISTRATION diff --git a/docker/rootless/etc/templates/app.ini b/docker/rootless/etc/templates/app.ini index 0057635062..0357e6aa1f 100644 --- a/docker/rootless/etc/templates/app.ini +++ b/docker/rootless/etc/templates/app.ini @@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log [security] INSTALL_LOCK = $INSTALL_LOCK SECRET_KEY = $SECRET_KEY -REVERSE_PROXY_LIMIT = 1 -REVERSE_PROXY_TRUSTED_PROXIES = * [service] DISABLE_REGISTRATION = $DISABLE_REGISTRATION diff --git a/models/auth/twofactor.go b/models/auth/twofactor.go index 9c0208f47f..51f487aac1 100644 --- a/models/auth/twofactor.go +++ b/models/auth/twofactor.go @@ -21,6 +21,7 @@ import ( "github.com/pquerna/otp/totp" "golang.org/x/crypto/pbkdf2" + "xorm.io/builder" ) // @@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error { return nil } -// ValidateTOTP validates the provided passcode. -func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { +// validateTOTP validates the provided passcode. It does not consume the passcode; all login +// 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) 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) 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) 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. func NewTwoFactor(ctx context.Context, t *TwoFactor) error { _, err := db.GetEngine(ctx).Insert(t) diff --git a/models/auth/twofactor_test.go b/models/auth/twofactor_test.go new file mode 100644 index 0000000000..1da5814e03 --- /dev/null +++ b/models/auth/twofactor_test.go @@ -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) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 02cabc55d1..a76563496c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) { if ctx.IsUserSiteAdmin() { return 0, true, true @@ -1728,7 +1743,7 @@ func Routes() *web.Router { m.Combo("/{id}").Get(reqToken(), org.GetLabel). Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel). Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel) - }) + }, reqOrgVisible()) m.Group("/hooks", func() { m.Combo("").Get(org.ListHooks). Post(bind(api.CreateHookOption{}), org.CreateHook) diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 0806858b4d..1b7b6f32b9 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) { } else if errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusNotFound, err.Error()) return + } else if errors.Is(err, util.ErrPermissionDenied) { + ctx.APIError(http.StatusForbidden, err.Error()) + return } ctx.APIErrorInternal(err) return diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 91a02b0f36..39836ab016 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -40,9 +40,6 @@ type preReceiveContext struct { canCreatePullRequest bool checkedCanCreatePullRequest bool - canWriteCode bool - checkedCanWriteCode bool - protectedTags []*git_model.ProtectedTag gotProtectedTags bool @@ -50,24 +47,36 @@ type preReceiveContext struct { 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) CanWriteCode() bool { - if !ctx.checkedCanWriteCode { - if !ctx.loadPusherAndPermission() { - return false +func (ctx *preReceiveContext) canWriteCodeUnit() bool { + if ctx.canWriteCodeUnitCached == nil { + var canWrite bool + if ctx.loadPusherAndPermission() { + canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite } - ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite - ctx.checkedCanWriteCode = true + ctx.canWriteCodeUnitCached = &canWrite } - return ctx.canWriteCode + return *ctx.canWriteCodeUnitCached } -// AssertCanWriteCode returns true if pusher can write code -func (ctx *preReceiveContext) AssertCanWriteCode() bool { - if !ctx.CanWriteCode() { +// 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 issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user) +} + +// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false +func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool { + if !ctx.canWriteCodeRef(refFullName) { if ctx.Written() { return false } @@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor(): preReceiveFor(ourCtx, refFullName) default: - ourCtx.AssertCanWriteCode() + ourCtx.assertCanWriteRef(refFullName) } if ctx.Written() { return @@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { branchName := refFullName.BranchName() - ctx.branchName = branchName - if !ctx.AssertCanWriteCode() { + if !ctx.assertCanWriteRef(refFullName) { return } @@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r } func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) { - if !ctx.AssertCanWriteCode() { + if !ctx.assertCanWriteRef(refFullName) { return } diff --git a/routers/private/hook_pre_receive_test.go b/routers/private/hook_pre_receive_test.go new file mode 100644 index 0000000000..bea5e8974d --- /dev/null +++ b/routers/private/hook_pre_receive_test.go @@ -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"))) +} diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index 77d90c4da1..10376842a3 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) { return } - // Validate the passcode with the stored TOTP secret. - ok, err := twofa.ValidateTOTP(form.Passcode) + // Validate the passcode and atomically consume it to prevent reuse/replay. + ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode) if err != nil { ctx.ServerError("UserSignIn", err) return } - if ok && twofa.LastUsedPasscode != form.Passcode { + if ok { remember := ctx.Session.Get("twofaRemember").(bool) u, err := user_model.GetUserByID(ctx, id) 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) handleSignIn(ctx, u, remember) return diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index a650f243ec..ca3e37dad0 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) { regenerateScratchToken = true } else { passcode := ctx.FormString("passcode") - ok, err := twofa.ValidateTOTP(passcode) + ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error()) return } - if !ok || twofa.LastUsedPasscode == passcode { + if !ok { ctx.Data["IsResetForm"] = true ctx.Data["Err_Passcode"] = true ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) return } - - twofa.LastUsedPasscode = passcode - if err = auth.UpdateTwoFactor(ctx, twofa); err != nil { - ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err) - return - } } } diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index f5972c8db0..fcd328efaf 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) { branchName := ctx.FormString("branch") _, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false) if err != nil { - if errors.Is(err, util.ErrNotExist) { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) { ctx.JSONErrorNotFound() return } else if pull_service.IsErrMergeConflicts(err) { diff --git a/services/auth/basic.go b/services/auth/basic.go index ed2a2e1945..26883a3746 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -177,7 +177,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error { } 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 } else if !ok { return util.NewInvalidArgumentErrorf("invalid provided OTP") diff --git a/services/repository/merge_upstream.go b/services/repository/merge_upstream.go index 61f27b6542..ad45839ba9 100644 --- a/services/repository/merge_upstream.go +++ b/services/repository/merge_upstream.go @@ -8,7 +8,9 @@ import ( "fmt" issue_model "gitea.dev/models/issues" + access_model "gitea.dev/models/perm/access" repo_model "gitea.dev/models/repo" + "gitea.dev/models/unit" user_model "gitea.dev/models/user" "gitea.dev/modules/git" "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 { 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) if err != nil { return "", err diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go index 3306c6539c..21123ebeb3 100644 --- a/tests/integration/api_org_test.go +++ b/tests/integration/api_org_test.go @@ -11,6 +11,7 @@ import ( "time" auth_model "gitea.dev/models/auth" + issues_model "gitea.dev/models/issues" org_model "gitea.dev/models/organization" "gitea.dev/models/perm" 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 }) } + +// 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) + }) +} diff --git a/tests/integration/api_twofa_test.go b/tests/integration/api_twofa_test.go index d8d8fa4497..141b7e4cdc 100644 --- a/tests/integration/api_twofa_test.go +++ b/tests/integration/api_twofa_test.go @@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) { AddBasicAuth(user.Name) req.Header.Set("X-Gitea-OTP", passcode) 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) { diff --git a/tests/integration/repo_merge_upstream_test.go b/tests/integration/repo_merge_upstream_test.go index 44b3c748c1..4b6db52046 100644 --- a/tests/integration/repo_merge_upstream_test.go +++ b/tests/integration/repo_merge_upstream_test.go @@ -171,5 +171,18 @@ func TestRepoMergeUpstream(t *testing.T) { }).AddTokenAuth(token) 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) + }) }) }