diff --git a/services/migrations/dump.go b/services/migrations/dump.go index eb0367e9f9..5a416301ed 100644 --- a/services/migrations/dump.go +++ b/services/migrations/dump.go @@ -287,6 +287,7 @@ func (g *RepositoryDumper) CreateLabels(_ context.Context, labels ...*base.Label // CreateReleases creates releases func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.Release) error { if g.opts.ReleaseAssets { + httpClient := NewMigrationHTTPClient() for _, release := range releases { attachDir := filepath.Join("release_assets", uuid.New().String()) if err := os.MkdirAll(filepath.Join(g.baseDir, attachDir), os.ModePerm); err != nil { @@ -296,25 +297,26 @@ func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.R // we cannot use asset.Name because it might contains special characters. attachLocalPath := filepath.Join(attachDir, uuid.New().String()) - // SECURITY: We cannot check the DownloadURL and DownloadFunc are safe here - // ... we must assume that they are safe and simply download the attachment + // SECURITY: Prefer DownloadFunc and fall back to a migration-filtered HTTP client. // download attachment err := func(attachPath string) error { var rc io.ReadCloser var err error - if asset.DownloadURL == nil { + if asset.DownloadFunc != nil { rc, err = asset.DownloadFunc() if err != nil { return err } defer rc.Close() - } else { - resp, err := http.Get(*asset.DownloadURL) + } else if asset.DownloadURL != nil { + resp, err := httpClient.Get(*asset.DownloadURL) if err != nil { return err } defer resp.Body.Close() rc = resp.Body + } else { + return errors.New("missing download URL and download function") } fw, err := os.Create(attachPath) diff --git a/services/migrations/dump_test.go b/services/migrations/dump_test.go new file mode 100644 index 0000000000..40908e5fb8 --- /dev/null +++ b/services/migrations/dump_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package migrations + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "code.gitea.io/gitea/models/unittest" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryDumperReleaseAssetPrefersDownloadFunc(t *testing.T) { + var downloadURLHits int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&downloadURLHits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("remote")) + })) + t.Cleanup(server.Close) + + downloadURL := server.URL + "/asset" + var downloadFuncCalls int32 + asset := &base.ReleaseAsset{ + Name: "asset.txt", + DownloadURL: &downloadURL, + DownloadFunc: func() (io.ReadCloser, error) { + atomic.AddInt32(&downloadFuncCalls, 1) + return io.NopCloser(strings.NewReader("local")), nil + }, + } + release := &base.Release{ + TagName: "v1.0.0", + Assets: []*base.ReleaseAsset{asset}, + } + + baseDir := t.TempDir() + dumper, err := NewRepositoryDumper(context.Background(), baseDir, "owner", "repo", base.MigrateOptions{ReleaseAssets: true}) + require.NoError(t, err) + + require.NoError(t, dumper.CreateReleases(context.Background(), release)) + assert.Equal(t, int32(1), atomic.LoadInt32(&downloadFuncCalls)) + assert.Equal(t, int32(0), atomic.LoadInt32(&downloadURLHits)) + + attachRelative := filepath.Join("release_assets", release.TagName, asset.Name) + attachPath := filepath.Join(baseDir, "owner", "repo", attachRelative) + data, err := os.ReadFile(attachPath) + require.NoError(t, err) + assert.Equal(t, "local", string(data)) + require.NotNil(t, asset.DownloadURL) + assert.Equal(t, attachRelative, *asset.DownloadURL) +} + +func TestRepositoryDumperReleaseAssetUsesMigrationClient(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + test.MockVariableValue(&setting.Migrations.AllowedDomains, "github.com")() + test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, false)() + assert.NoError(t, Init()) + + var downloadURLHits int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&downloadURLHits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("remote")) + })) + t.Cleanup(server.Close) + + downloadURL := server.URL + "/asset" + asset := &base.ReleaseAsset{ + Name: "asset.txt", + DownloadURL: &downloadURL, + } + release := &base.Release{ + TagName: "v1.0.0", + Assets: []*base.ReleaseAsset{asset}, + } + + baseDir := t.TempDir() + dumper, err := NewRepositoryDumper(context.Background(), baseDir, "owner", "repo", base.MigrateOptions{ReleaseAssets: true}) + require.NoError(t, err) + + assert.Error(t, dumper.CreateReleases(context.Background(), release)) + assert.Equal(t, int32(0), atomic.LoadInt32(&downloadURLHits)) +}