diff --git a/services/migrations/restore.go b/services/migrations/restore.go index cb34f681868..e7bde418c82 100644 --- a/services/migrations/restore.go +++ b/services/migrations/restore.go @@ -11,6 +11,7 @@ import ( "strconv" base "gitea.dev/modules/migration" + "gitea.dev/modules/util" "go.yaml.in/yaml/v4" ) @@ -144,7 +145,7 @@ func (r *RepositoryRestorer) GetReleases(_ context.Context) ([]*base.Release, er for _, rel := range releases { for _, asset := range rel.Assets { if asset.DownloadURL != nil { - *asset.DownloadURL = "file://" + filepath.Join(r.baseDir, *asset.DownloadURL) + *asset.DownloadURL = "file://" + util.FilePathJoinAbs(r.baseDir, *asset.DownloadURL) } } } @@ -235,7 +236,9 @@ func (r *RepositoryRestorer) GetPullRequests(_ context.Context, page, perPage in return nil, false, err } for _, pr := range pulls { - pr.PatchURL = "file://" + filepath.Join(r.baseDir, pr.PatchURL) + if pr.PatchURL != "" { + pr.PatchURL = "file://" + util.FilePathJoinAbs(r.baseDir, pr.PatchURL) + } CheckAndEnsureSafePR(pr, "", r) } return pulls, true, nil diff --git a/services/migrations/restore_test.go b/services/migrations/restore_test.go new file mode 100644 index 00000000000..4346fc71ae8 --- /dev/null +++ b/services/migrations/restore_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package migrations + +import ( + "os" + "path/filepath" + "testing" + + "gitea.dev/modules/optional" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRepositoryRestorer_GetReleases_LocalFileInclusion ensures a crafted +// release.yml cannot make the restorer read files outside the dump +// directory via a path-traversal DownloadURL. +func TestRepositoryRestorer_GetReleases_LocalFileInclusion(t *testing.T) { + baseDir := t.TempDir() + + // a legitimate attachment that lives inside the dump directory + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "good.txt"), []byte("ok"), 0o644)) + + releaseYML := ` +- assets: + - name: good.txt + download_url: good.txt + - name: evil.txt + download_url: ../../../../../../../../etc/passwd +` + require.NoError(t, os.WriteFile(filepath.Join(baseDir, "release.yml"), []byte(releaseYML), 0o644)) + + r, err := NewRepositoryRestorer(t.Context(), baseDir, "owner", "repo", false) + require.NoError(t, err) + + releases, err := r.GetReleases(t.Context()) + require.NoError(t, err) + require.Len(t, releases, 1) + require.Len(t, releases[0].Assets, 2) + + // the in-dump asset keeps a file:// URL pointing inside baseDir + assets := releases[0].Assets + assert.Equal(t, "file://"+filepath.Join(baseDir, "good.txt"), optional.FromPtr(assets[0].DownloadURL).Value()) + assert.Equal(t, "file://"+filepath.Join(baseDir, "etc/passwd"), optional.FromPtr(assets[1].DownloadURL).Value()) +}