fix(api): accept fully-qualified refs in contents API (#38650)

`GET/POST` repository contents endpoints resolve the `ref` query
parameter through `ResolveRefCommit`, which previously only accepted
short branch/tag names or commit IDs. Clients that pass fully-qualified
Git refs (GitHub-compatible), e.g. `ref=refs%2Fheads%2Fmain` or
`ref=refs%2Ftags%2Fv1.0`, received 404 "object does not exist".

This change accepts fully-qualified **`refs/heads/*`** and
**`refs/tags/*`** only, then falls back to the existing short-name and
SHA logic. Other `refs/*` prefixes (e.g. `refs/pull/`, `refs/for/`) are
rejected.

Fixes #38197

---------

Co-authored-by: roman s <roman.sukach@dust-labs.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
roman.s
2026-07-27 05:31:58 +00:00
committed by GitHub
co-authored by roman s wxiaoguang
parent 4da9b59414
commit cebdc90ed9
3 changed files with 140 additions and 96 deletions
+40 -12
View File
@@ -5,6 +5,7 @@ package utils
import ( import (
"errors" "errors"
"strings"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -20,23 +21,50 @@ type RefCommit struct {
CommitID string CommitID string
} }
// ResolveRefCommit resolve ref to a commit if exist // ResolveRefCommit resolve ref to a commit if it exists.
// inputRef may be a short branch/tag name, a commit ID, or a fully-qualified
// git ref (e.g. refs/heads/main, refs/tags/v1.0) for GitHub client compatibility.
func ResolveRefCommit(ctx reqctx.RequestContext, repo *repo_model.Repository, inputRef string, minCommitIDLen ...int) (_ *RefCommit, err error) { func ResolveRefCommit(ctx reqctx.RequestContext, repo *repo_model.Repository, inputRef string, minCommitIDLen ...int) (_ *RefCommit, err error) {
refCommit := RefCommit{InputRef: inputRef}
var testRefBranch, testRefTag git.RefName
if strings.HasPrefix(inputRef, "refs/") {
// Fully-qualified refs first so clients can pass unambiguous names.
testRef := git.RefName(inputRef)
if testRef.IsBranch() {
testRefBranch = testRef
} else if testRef.IsTag() {
testRefTag = testRef
}
} else {
testRefBranch, testRefTag = git.RefNameFromBranch(inputRef), git.RefNameFromTag(inputRef)
}
if testRefBranch != "" {
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, testRefBranch.ShortName()); exist {
refCommit.RefName = testRefBranch
}
}
if testRefTag != "" {
// TODO: use git model instead of git command in the future? Model seems to be faster.
if git.IsTagExist(ctx, repo, testRefTag.ShortName()) {
refCommit.RefName = testRefTag
}
}
if refCommit.RefName == "" {
if git.IsStringLikelyCommitID(git.ObjectFormatFromName(repo.ObjectFormatName), inputRef, minCommitIDLen...) {
refCommit.RefName = git.RefNameFromCommit(inputRef)
}
}
if refCommit.RefName == "" {
return nil, git.ErrNotExist{ID: inputRef}
}
gitRepo, err := git.RepositoryFromRequestContextOrOpen(ctx, repo) gitRepo, err := git.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil { if err != nil {
return nil, err return nil, err
} }
refCommit := RefCommit{InputRef: inputRef}
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, inputRef); exist {
refCommit.RefName = git.RefNameFromBranch(inputRef)
} else if git.IsTagExist(ctx, repo, inputRef) {
refCommit.RefName = git.RefNameFromTag(inputRef)
} else if git.IsStringLikelyCommitID(git.ObjectFormatFromName(repo.ObjectFormatName), inputRef, minCommitIDLen...) {
refCommit.RefName = git.RefNameFromCommit(inputRef)
}
if refCommit.RefName == "" {
return nil, git.ErrNotExist{ID: inputRef}
}
if refCommit.Commit, err = gitRepo.GetCommit(ctx, refCommit.RefName.String()); err != nil { if refCommit.Commit, err = gitRepo.GetCommit(ctx, refCommit.RefName.String()); err != nil {
return nil, err return nil, err
} }
+23 -6
View File
@@ -63,33 +63,50 @@ func TestAPIGetRequestedFiles(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/file-contents?body="+url.QueryEscape(string(reqBodyParam))) req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/file-contents?body="+url.QueryEscape(string(reqBodyParam)))
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
ret := DecodeJSON(t, resp, []*api.ContentsResponse{}) ret := DecodeJSON(t, resp, []*api.ContentsResponse{})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())} expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("master", "refs/heads/master", lastCommit.ID.String())}
assert.Equal(t, expected, ret) assert.Equal(t, expected, ret)
}) })
t.Run("User2NoRef", func(t *testing.T) { t.Run("User2NoRef", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents", []string{"README.md"}) ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())} expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("master", "refs/heads/master", lastCommit.ID.String())}
assert.Equal(t, expected, ret) assert.Equal(t, expected, ret)
}) })
t.Run("User2RefBranch", func(t *testing.T) { t.Run("User2RefBranch", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=master", []string{"README.md"}) ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=master", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(repo1.DefaultBranch, "branch", lastCommit.ID.String())} expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("master", "refs/heads/master", lastCommit.ID.String())}
assert.Equal(t, expected, ret) assert.Equal(t, expected, ret)
}) })
t.Run("User2RefTag", func(t *testing.T) { t.Run("User2RefTag", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=v1.1", []string{"README.md"}) ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=v1.1", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("v1.1", "tag", lastCommit.ID.String())} expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("v1.1", "refs/tags/v1.1", lastCommit.ID.String())}
assert.Equal(t, expected, ret) assert.Equal(t, expected, ret)
}) })
t.Run("User2RefCommit", func(t *testing.T) { t.Run("User2RefCommit", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=65f1bf27bc3bf70f64657658635e66094edbcb4d", []string{"README.md"}) commitID := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("65f1bf27bc3bf70f64657658635e66094edbcb4d", "commit", lastCommit.ID.String())} ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref="+commitID, []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents(commitID, git.RefNameFromCommit(commitID), lastCommit.ID.String())}
assert.Equal(t, expected, ret) assert.Equal(t, expected, ret)
}) })
t.Run("User2RefNotExist", func(t *testing.T) { t.Run("User2RefNotExist", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=not-exist", []string{"README.md"}, http.StatusNotFound) ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=not-exist", []string{"README.md"}, http.StatusNotFound)
assert.Empty(t, ret) assert.Empty(t, ret)
}) })
t.Run("User2RefFullBranch", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=refs/heads/master", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("refs/heads/master", "refs/heads/master", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefFullTag", func(t *testing.T) {
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref=refs/tags/v1.1", []string{"README.md"})
expected := []*api.ContentsResponse{getExpectedContentsResponseForContents("refs/tags/v1.1", "refs/tags/v1.1", lastCommit.ID.String())}
assert.Equal(t, expected, ret)
})
t.Run("User2RefInternalPullRejected", func(t *testing.T) {
// repo1 has refs/pull/2/head in fixtures; contents API must not resolve it
assert.True(t, git.IsReferenceExist(t.Context(), gitRepo, "refs/pull/2/head"))
ret := requestFiles(t, "/api/v1/repos/user2/repo1/file-contents?ref="+url.QueryEscape("refs/pull/2/head"), []string{"README.md"}, http.StatusNotFound)
assert.Empty(t, ret)
})
t.Run("PermissionCheck", func(t *testing.T) { t.Run("PermissionCheck", func(t *testing.T) {
filesOptions := &api.GetFilesOptions{Files: []string{"README.md"}} filesOptions := &api.GetFilesOptions{Files: []string{"README.md"}}
+77 -78
View File
@@ -24,10 +24,10 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse { func getExpectedContentsResponseForContents(inputRef string, expectedRef git.RefName, lastCommitSHA string) *api.ContentsResponse {
treePath := "README.md" treePath := "README.md"
selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + url.QueryEscape(inputRef)
htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath htmlURL := setting.AppURL + "user2/repo1/src/" + expectedRef.RefWebLinkPath() + "/" + treePath
gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f" gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f"
return &api.ContentsResponse{ return &api.ContentsResponse{
Name: treePath, Name: treePath,
@@ -43,7 +43,7 @@ func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string)
URL: &selfURL, URL: &selfURL,
HTMLURL: &htmlURL, HTMLURL: &htmlURL,
GitURL: &gitURL, GitURL: &gitURL,
DownloadURL: new(setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath), DownloadURL: new(setting.AppURL + "user2/repo1/raw/" + expectedRef.RefWebLinkPath() + "/" + treePath),
Links: &api.FileLinksResponse{ Links: &api.FileLinksResponse{
Self: &selfURL, Self: &selfURL,
GitURL: &gitURL, GitURL: &gitURL,
@@ -82,92 +82,91 @@ func testAPIGetContents(t *testing.T, _ *url.URL) {
require.NoError(t, err) require.NoError(t, err)
defer gitRepo.Close() defer gitRepo.Close()
// Make a new branch in repo1 defaultBranchCommitID, err := gitRepo.GetBranchCommitID(t.Context(), repo1.DefaultBranch)
newBranch := "test_branch"
err = repo_service.CreateNewBranch(t.Context(), user2, repo1, gitRepo, repo1.DefaultBranch, newBranch)
require.NoError(t, err) require.NoError(t, err)
commitID, err := gitRepo.GetBranchCommitID(t.Context(), repo1.DefaultBranch) t.Run("FileNotFound", func(t *testing.T) {
require.NoError(t, err) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/no-such/file.md", user2.Name, repo1.Name)
// Make a new tag in repo1 resp := MakeRequest(t, req, http.StatusNotFound)
newTag := "test_tag" assert.Contains(t, resp.Body.String(), "object does not exist [id: , rel_path: no-such]")
err = gitRepo.CreateTag(t.Context(), newTag, commitID) })
require.NoError(t, err)
/*** END SETUP ***/
// not found t.Run("NoRef", func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/no-such/file.md", user2.Name, repo1.Name) ref := git.RefNameFromBranch(repo1.DefaultBranch)
resp := MakeRequest(t, req, http.StatusNotFound) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath)
assert.Contains(t, resp.Body.String(), "object does not exist [id: , rel_path: no-such]") resp := MakeRequest(t, req, http.StatusOK)
contentsResponse := DecodeJSON(t, resp, &api.ContentsResponse{})
expectedContentsResponse := getExpectedContentsResponseForContents("master", ref, defaultBranchCommitID)
assert.Equal(t, *expectedContentsResponse, *contentsResponse)
})
// ref is default ref t.Run("NewlyCreatedBranchSynced", func(t *testing.T) {
ref := repo1.DefaultBranch newBranch := "test_branch"
refType := "branch" err = repo_service.CreateNewBranch(t.Context(), user2, repo1, gitRepo, repo1.DefaultBranch, newBranch)
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) require.NoError(t, err)
resp = MakeRequest(t, req, http.StatusOK)
contentsResponse := DecodeJSON(t, resp, &api.ContentsResponse{})
lastCommit, _ := gitRepo.GetCommitByPath(t.Context(), "README.md")
expectedContentsResponse := getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
assert.Equal(t, *expectedContentsResponse, *contentsResponse)
// No ref ref := git.RefNameFromBranch(newBranch)
refType = "branch" req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref.ShortName())
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) resp := MakeRequest(t, req, http.StatusOK)
resp = MakeRequest(t, req, http.StatusOK) contentsResponse := DecodeJSON(t, resp, &api.ContentsResponse{})
contentsResponse = DecodeJSON(t, resp, &api.ContentsResponse{}) branchCommit, _ := gitRepo.GetBranchCommit(t.Context(), ref.ShortName())
expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String()) lastCommit, _ := branchCommit.GetCommitByPath(t.Context(), gitRepo, "README.md")
assert.Equal(t, *expectedContentsResponse, *contentsResponse) expectedContentsResponse := getExpectedContentsResponseForContents(ref.ShortName(), ref, lastCommit.ID.String())
assert.Equal(t, *expectedContentsResponse, *contentsResponse)
})
// ref is the branch we created above in setup t.Run("NewlyCreatedTagSynced", func(t *testing.T) {
ref = newBranch newTag := "test_tag"
refType = "branch" err = gitRepo.CreateTag(t.Context(), newTag, defaultBranchCommitID)
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) require.NoError(t, err)
resp = MakeRequest(t, req, http.StatusOK) ref := git.RefNameFromTag(newTag)
contentsResponse = DecodeJSON(t, resp, &api.ContentsResponse{}) tagCommit, _ := gitRepo.GetTagCommit(t.Context(), ref.ShortName())
branchCommit, _ := gitRepo.GetBranchCommit(t.Context(), ref) lastCommit, _ := tagCommit.GetCommitByPath(t.Context(), gitRepo, "README.md")
lastCommit, _ = branchCommit.GetCommitByPath(t.Context(), gitRepo, "README.md") expectedContentsResponse := getExpectedContentsResponseForContents(ref.ShortName(), ref, lastCommit.ID.String())
expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
assert.Equal(t, *expectedContentsResponse, *contentsResponse)
// ref is the new tag we created above in setup req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref.ShortName())
ref = newTag resp := MakeRequest(t, req, http.StatusOK)
refType = "tag" contentsResponse := DecodeJSON(t, resp, &api.ContentsResponse{})
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) assert.Equal(t, *expectedContentsResponse, *contentsResponse)
resp = MakeRequest(t, req, http.StatusOK) })
contentsResponse = DecodeJSON(t, resp, &api.ContentsResponse{})
tagCommit, _ := gitRepo.GetTagCommit(t.Context(), ref)
lastCommit, _ = tagCommit.GetCommitByPath(t.Context(), gitRepo, "README.md")
expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
assert.Equal(t, *expectedContentsResponse, *contentsResponse)
// ref is a commit t.Run("CommitRef", func(t *testing.T) {
ref = commitID ref := git.RefNameFromCommit(defaultBranchCommitID)
refType = "commit" req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref.ShortName())
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) resp := MakeRequest(t, req, http.StatusOK)
resp = MakeRequest(t, req, http.StatusOK) contentsResponse := DecodeJSON(t, resp, &api.ContentsResponse{})
contentsResponse = DecodeJSON(t, resp, &api.ContentsResponse{}) expectedContentsResponse := getExpectedContentsResponseForContents(ref.ShortName(), ref, defaultBranchCommitID)
expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, commitID) assert.Equal(t, *expectedContentsResponse, *contentsResponse)
assert.Equal(t, *expectedContentsResponse, *contentsResponse) })
// Test file contents a file with a bad ref t.Run("NotExistingRef", func(t *testing.T) {
ref = "badref" req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=not-existing", user2.Name, repo1.Name, treePath)
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) MakeRequest(t, req, http.StatusNotFound)
MakeRequest(t, req, http.StatusNotFound) })
// Test accessing private ref with user token that does not have access - should fail t.Run("InternalRef", func(t *testing.T) {
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath). // Internal pull ref exists in the test repo but must not be accepted via contents API
AddTokenAuth(token4) assert.True(t, git.IsReferenceExist(t.Context(), gitRepo, "refs/pull/2/head"))
MakeRequest(t, req, http.StatusNotFound) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, url.QueryEscape("refs/pull/2/head"))
MakeRequest(t, req, http.StatusNotFound)
})
// Test access private ref of owner of token t.Run("Permission", func(t *testing.T) {
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name). // Test accessing private ref with user token that does not have access - should fail
AddTokenAuth(token2) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath).
MakeRequest(t, req, http.StatusOK) AddTokenAuth(token4)
MakeRequest(t, req, http.StatusNotFound)
// Test access of org org3 private repo file by owner user2 // Test access private ref of owner of token
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath). req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name).
AddTokenAuth(token2) AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
// Test access of org org3 private repo file by owner user2
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK)
})
} }
func testAPIGetContentsRefFormats(t *testing.T) { func testAPIGetContentsRefFormats(t *testing.T) {