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
This commit is contained in:
Nicolas
2026-06-14 11:51:39 +02:00
parent a5339e0e7a
commit a746372325
3 changed files with 50 additions and 8 deletions
+6
View File
@@ -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")
}
+34
View File
@@ -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")
})
}
+10 -8
View File
@@ -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)