fix(migrations): prevent path traversal in repository restore (#38215)

## Problem

The repository restorer (`services/migrations/restore.go`) builds
`file://` URLs for release attachments and PR patches by joining
user-supplied paths from `release.yml` and `pull_request.yml` onto the
dump directory:

```go
*asset.DownloadURL = "file://" + filepath.Join(r.baseDir, *asset.DownloadURL)
pr.PatchURL        = "file://" + filepath.Join(r.baseDir, pr.PatchURL)
```

`filepath.Join` cleans the path, so a crafted relative value such as
`../../../../etc/passwd` resolves to an absolute path **outside** the
dump directory. `uri.Open` then reads it via `os.Open` and stores the
content as a release attachment, which is retrievable through the API —
an arbitrary file read (Local File Inclusion) from a dump archive
supplied to `restore-repo`.

## Fix

Add a `localFileURL` helper that resolves the relative path against
`baseDir` and rejects anything that escapes it. Malicious entries are
skipped with a warning so a legitimate restore still completes; in-dump
files keep working unchanged.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bircni
2026-06-27 16:50:30 +02:00
committed by GitHub
parent 15ee850ede
commit d5e6f273f0
2 changed files with 52 additions and 2 deletions

View File

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

View File

@@ -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())
}