From a7463723257f84b139b69ffac011ffb5273ab8b1 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 14 Jun 2026 11:51:39 +0200 Subject: [PATCH] fix(sec): block redirects in repository migration clone (SSRF) Migration validates the initial clone address against the allow/block list, but a git-service migration clones with `git clone`, which follows an HTTP 302 from the remote to an internal address without re-validating. This let a low-privilege user reach internal services through Gitea. Refuse redirects on the migration clone via `http.followRedirects=false`, the only reliable guard since git resolves redirects below Gitea's validation layer. Applied to both the repository and wiki clones. Assisted-by: Claude:claude-opus-4-8 --- modules/git/repo.go | 6 ++++++ modules/git/repo_test.go | 34 ++++++++++++++++++++++++++++++++++ services/repository/migrate.go | 18 ++++++++++-------- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/modules/git/repo.go b/modules/git/repo.go index e17e7e1460..7be2a91bc9 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -111,6 +111,9 @@ type CloneRepoOptions struct { SkipTLSVerify bool SingleBranch bool Env []string + // NoFollowRedirects refuses HTTP redirects during the clone. It is used for + // migrations to stop a remote redirecting to an otherwise-blocked address (SSRF). + NoFollowRedirects bool } // Clone clones original repository to target path. @@ -124,6 +127,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { if opts.SkipTLSVerify { cmd.AddArguments("-c", "http.sslVerify=false") } + if opts.NoFollowRedirects { + cmd.AddArguments("-c", "http.followRedirects=false") + } if opts.Mirror { cmd.AddArguments("--mirror") } diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go index 776c297a34..76acac0b76 100644 --- a/modules/git/repo_test.go +++ b/modules/git/repo_test.go @@ -4,7 +4,10 @@ package git import ( + "net/http" + "net/http/httptest" "path/filepath" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -19,3 +22,34 @@ func TestRepoIsEmpty(t *testing.T) { assert.NoError(t, err) assert.True(t, isEmpty) } + +// TestCloneNoFollowRedirects ensures the migration clone refuses HTTP redirects, +// so a remote cannot redirect to an otherwise-blocked address (SSRF). Without the +// option git follows the redirect and reaches the target. +func TestCloneNoFollowRedirects(t *testing.T) { + var targetHit atomic.Bool + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHit.Store(true) + w.WriteHeader(http.StatusNotFound) + })) + defer target.Close() + + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound) + })) + defer redirect.Close() + + t.Run("FollowsRedirectByDefault", func(t *testing.T) { + targetHit.Store(false) + err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{}) + assert.Error(t, err) + assert.True(t, targetHit.Load(), "git should reach the redirect target without the protection") + }) + + t.Run("RefusesRedirect", func(t *testing.T) { + targetHit.Store(false) + err := Clone(t.Context(), redirect.URL, filepath.Join(t.TempDir(), "dst"), CloneRepoOptions{NoFollowRedirects: true}) + assert.Error(t, err) + assert.False(t, targetHit.Load(), "git must not follow the redirect to the target") + }) +} diff --git a/services/repository/migrate.go b/services/repository/migrate.go index a0182769f4..d59b81e046 100644 --- a/services/repository/migrate.go +++ b/services/repository/migrate.go @@ -45,10 +45,11 @@ func cloneWiki(ctx context.Context, repo *repo_model.Repository, opts migration. } } if err := gitrepo.CloneExternalRepo(ctx, wikiRemoteURL, storageRepo, git.CloneRepoOptions{ - Mirror: true, - Quiet: true, - Timeout: migrateTimeout, - SkipTLSVerify: setting.Migrations.SkipTLSVerify, + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + SkipTLSVerify: setting.Migrations.SkipTLSVerify, + NoFollowRedirects: true, }); err != nil { log.Error("Clone wiki failed, err: %v", err) cleanIncompleteWikiPath() @@ -91,10 +92,11 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, } if err := gitrepo.CloneExternalRepo(ctx, opts.CloneAddr, repo, git.CloneRepoOptions{ - Mirror: true, - Quiet: true, - Timeout: migrateTimeout, - SkipTLSVerify: setting.Migrations.SkipTLSVerify, + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + SkipTLSVerify: setting.Migrations.SkipTLSVerify, + NoFollowRedirects: true, }); err != nil { if errors.Is(err, context.DeadlineExceeded) { return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err)