fix: block fork sync when base repo is no longer readable

POST /api/v1/repos/{owner}/{repo}/merge-upstream kept importing commits
from the parent repository even after the parent was switched from public
to private, leaking commits a fork owner could no longer access directly.
Require the doer to still have read access to the base repo's code before
syncing, and map the permission error to 403 (API) / not-found (web).

Assisted-by: Claude:claude-opus-4-8
This commit is contained in:
Nicolas
2026-06-13 17:36:35 +02:00
parent 24ce5ae082
commit c20df84548
4 changed files with 36 additions and 1 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -171,5 +171,24 @@ 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)
defer func() {
baseRepo.IsPrivate = false
_, 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)
})
})
}