06fdf62b53f8d80c61de912bf84be7c04553df01

Author
tonghuaroot (童话) <tonghuaroot@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

Merge commit from fork

Any authenticated user could import a repository from an arbitrary URL,
including loopback, RFC1918, and cloud metadata addresses, turning the
import feature into a server-side request forgery primitive. Scheduled
mirror syncs re-fetched the stored URL every 10 minutes, so a single
import kept the access alive with no further interaction.

Remotes are now checked against private and internal address ranges
before any network access happens, and every remote on a mirror is
re-checked before each sync. A remote that cannot be read or validated
causes the sync to be skipped instead of proceeding unchecked.

Checking the address is not enough on its own, because the work is done
by a git subprocess that makes its own network decisions afterwards:

  - git follows the first HTTP redirect by default, so a remote that
    passed as public could hand off to an internal address. Redirect
    following is now disabled for these operations, which means moved
    repositories must be imported from their current URL.
  - git resolves the hostname a second time, so the address connected to
    need not be the one that was checked. Validated hostnames are now
    pinned to the checked address for the duration of the operation.

Two parsing gaps are also closed. Addresses written in non-standard
forms such as "0177.0.0.1" are rejected rather than resolved, because
Go and libcurl disagree about which host they name: one reads it as a
public address, the other as loopback. Remotes using the git:// protocol
are now validated too, since they open a connection to an arbitrary host
and port just as HTTP does. Those cannot be address-pinned, because the
pinning mechanism is HTTP-specific.

User-supplied Git LFS endpoints get the same treatment, since they are
stored alongside the repository and read again by the sync job.

SSH remotes are deliberately left alone. They authenticate with the
operator's own key rather than anything the importing user controls.

Reported-by: 456789TZ <153798304+456789TZ@users.noreply.github.com>
Reported-by: Koda Reef <kodareef5@gmail.com>
Reported-by: Ta Duc Thien <thienbk89@gmail.com>
Reported-by: tonghuaroot <tonghuaroot@gmail.com>

Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>

Diff

This diff is truncated to protect this page.

  1diff --git a/pkg/backend/repo.go b/pkg/backend/repo.go
  2index f151b7110476faa85de3e95982691112ee4deea2..435f9696758cc7485f2deeb437aa15587b380ae9 100644
  3--- a/pkg/backend/repo.go
  4+++ b/pkg/backend/repo.go
  5@@ -19,19 +19,30 @@ import (
  6 	"github.com/charmbracelet/soft-serve/pkg/hooks"
  7 	"github.com/charmbracelet/soft-serve/pkg/lfs"
  8 	"github.com/charmbracelet/soft-serve/pkg/proto"
  9+	"github.com/charmbracelet/soft-serve/pkg/ssrf"
 10 	"github.com/charmbracelet/soft-serve/pkg/storage"
 11 	"github.com/charmbracelet/soft-serve/pkg/task"
 12 	"github.com/charmbracelet/soft-serve/pkg/utils"
 13 	"github.com/charmbracelet/soft-serve/pkg/webhook"
 14 )
 15 
 16-func validateImportRemote(remote string) error {
 17+// validateImportRemote validates an import remote against private, internal,
 18+// and loopback ranges, and returns the git environment that must be applied to
 19+// the clone subprocess for that validation to hold.
 20+func validateImportRemote(remote string) ([]string, error) {
 21 	endpoint, err := lfs.NewEndpoint(remote)
 22 	if err != nil || endpoint.Host == "" {
 23-		return proto.ErrInvalidRemote
 24+		return nil, proto.ErrInvalidRemote
 25 	}
 26 
 27-	return nil
 28+	// Validate the remote as given rather than the LFS endpoint, which has
 29+	// already had its scheme rewritten and its path rewritten to /info/lfs.
 30+	v, err := ssrf.ValidateGitRemote(remote)
 31+	if err != nil {
 32+		return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
 33+	}
 34+
 35+	return ssrf.GitEnv(v), nil
 36 }
 37 
 38 // CreateRepository creates a new repository.
 39@@ -145,10 +156,20 @@ func (d *Backend) ImportRepository(_ context.Context, name string, user proto.Us
 40 	}
 41 
 42 	remote = utils.Sanitize(remote)
 43-	if err := validateImportRemote(remote); err != nil {
 44+	remoteEnv, err := validateImportRemote(remote)
 45+	if err != nil {
 46 		return nil, err
 47 	}
 48 
 49+	// The LFS endpoint is user-supplied and gets persisted to the repo config,
 50+	// where the mirror job later reads it. Validate it up front rather than
 51+	// relying on the LFS client to refuse the dial.
 52+	if opts.LFSEndpoint != "" {
 53+		if _, err := ssrf.ValidateGitRemote(opts.LFSEndpoint); err != nil {
 54+			return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
 55+		}
 56+	}
 57+
 58 	rp := filepath.Join(d.repoPath(name))
 59 
 60 	tid := "import:" + name
 61@@ -166,6 +187,16 @@ func (d *Backend) ImportRepository(_ context.Context, name string, user proto.Us
 62 	d.manager.Add(tid, func(ctx context.Context) (err error) {
 63 		ctx = proto.WithUserContext(ctx, user)
 64 
 65+		// remoteEnv carries the SSRF guard: it disables redirect following
 66+		// and pins the validated address. Without it the clone can reach a
 67+		// different host than the one validated above.
 68+		cloneEnv := append(remoteEnv,
 69+			fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 70+				filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
 71+				d.cfg.SSH.ClientKeyPath,
 72+			),
 73+		)
 74+
 75 		copts := git.CloneOptions{
 76 			Bare:   true,
 77 			Mirror: opts.Mirror,
 78@@ -173,12 +204,7 @@ func (d *Backend) ImportRepository(_ context.Context, name string, user proto.Us
 79 			CommandOptions: git.CommandOptions{
 80 				Timeout: -1,
 81 				Context: ctx,
 82-				Envs: []string{
 83-					fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 84-						filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
 85-						d.cfg.SSH.ClientKeyPath,
 86-					),
 87-				},
 88+				Envs:    cloneEnv,
 89 			},
 90 		}
 91 
 92diff --git a/pkg/backend/repo_test.go b/pkg/backend/repo_test.go
 93index 57851fab6f6794de1711ea4e50950af5609c0651..3f9fa3df027c4d6dbddfd54735b33ba7b29d1b18 100644
 94--- a/pkg/backend/repo_test.go
 95+++ b/pkg/backend/repo_test.go
 96@@ -2,6 +2,8 @@ package backend
 97 
 98 import (
 99 	"context"
100+	"errors"
101+	"slices"
102 	"testing"
103 
104 	"github.com/charmbracelet/soft-serve/pkg/db"
105@@ -43,3 +45,47 @@ func TestDefaultAdminUserIDNoAdmin(t *testing.T) {
106 	})
107 	is.True(err != nil)
108 }
109+
110+// TestValidateImportRemote verifies that import remotes pointing at private,
111+// internal, or non-network destinations are rejected, and that accepted
112+// remotes carry the git environment that keeps the validation honest.
113+func TestValidateImportRemote(t *testing.T) {
114+	tests := []struct {
115+		name    string
116+		remote  string
117+		wantErr bool
118+	}{
119+		{"public https", "https://1.1.1.1/x.git", false},
120+		{"public git", "git://1.1.1.1/x.git", false},
121+		{"ssh", "ssh://git@10.0.0.1/x.git", false},
122+
123+		{"loopback", "http://127.0.0.1/x.git", true},
124+		{"localhost", "http://localhost:8080/x.git", true},
125+		{"private network", "http://10.0.0.1/x.git", true},
126+		{"cloud metadata", "http://169.254.169.254/latest/meta-data/", true},
127+		{"git scheme private", "git://10.0.0.1/x.git", true},
128+		{"octal loopback", "http://0177.0.0.1/x.git", true},
129+		{"local path", "/data/repos/secret.git", true},
130+		{"file scheme", "file:///etc/passwd", true},
131+		{"empty", "", true},
132+	}
133+
134+	for _, tt := range tests {
135+		t.Run(tt.name, func(t *testing.T) {
136+			is := is.New(t)
137+			env, err := validateImportRemote(tt.remote)
138+
139+			if tt.wantErr {
140+				is.True(err != nil)
141+				is.True(errors.Is(err, proto.ErrInvalidRemote))
142+				return
143+			}
144+
145+			is.NoErr(err)
146+			// Redirect following must be off, or a public remote can hand
147+			// off to an internal one after validation has passed.
148+			is.True(slices.Contains(env, "GIT_CONFIG_VALUE_0=false"))
149+			is.True(slices.Contains(env, "GIT_CONFIG_KEY_0=http.followRedirects"))
150+		})
151+	}
152+}
153diff --git a/pkg/jobs/mirror.go b/pkg/jobs/mirror.go
154index 59abac08809ff0fda5c77d0b9dd0e60176bc082f..a5b1fefcc3cdbcca61bad62cbba279e51d0158cd 100644
155--- a/pkg/jobs/mirror.go
156+++ b/pkg/jobs/mirror.go
157@@ -13,6 +13,7 @@ import (
158 	"github.com/charmbracelet/soft-serve/pkg/config"
159 	"github.com/charmbracelet/soft-serve/pkg/db"
160 	"github.com/charmbracelet/soft-serve/pkg/lfs"
161+	"github.com/charmbracelet/soft-serve/pkg/ssrf"
162 	"github.com/charmbracelet/soft-serve/pkg/store"
163 	"github.com/charmbracelet/soft-serve/pkg/sync"
164 )
165@@ -23,6 +24,40 @@ func init() {
166 
167 type mirrorPull struct{}
168 
169+// validateMirrorRemotes validates every remote configured on a mirror
170+// repository and returns the git environment that must be applied to the sync
171+// commands.
172+//
173+// `git remote update` fetches from all remotes, not just origin, so every one
174+// of them has to pass. Any failure to read or validate skips the sync: a
175+// remote that cannot be checked is not one to fetch from.
176+func validateMirrorRemotes(r *git.Repository) ([]string, error) {
177+	cfg, err := r.Config()
178+	if err != nil {
179+		return nil, fmt.Errorf("reading git config: %w", err)
180+	}
181+
182+	var remotes []ssrf.ValidatedGitRemote
183+	for _, sub := range cfg.Section("remote").Subsections {
184+		url := sub.Option("url")
185+		if url == "" {
186+			continue
187+		}
188+
189+		v, err := ssrf.ValidateGitRemote(url)
190+		if err != nil {
191+			return nil, fmt.Errorf("remote %q: %w", sub.Name, err)
192+		}
193+		remotes = append(remotes, v)
194+	}
195+
196+	if len(remotes) == 0 {
197+		return nil, fmt.Errorf("no remote url configured")
198+	}
199+
200+	return ssrf.GitEnv(remotes...), nil
201+}
202+
203 // Spec derives the spec used for pull mirrors and implements Runner.
204 func (m mirrorPull) Spec(ctx context.Context) string {
205 	cfg := config.FromContext(ctx)
206@@ -64,6 +99,25 @@ func (m mirrorPull) Func(ctx context.Context) func() {
207 				wq.Add(name, func() {
208 					repo := repo
209 
210+					// Re-validate every configured remote before syncing.
211+					// `remote update` touches all of them, and a remote may
212+					// predate the import-time guard or have been written out
213+					// of band. Validation failure skips the repo entirely.
214+					remoteEnv, err := validateMirrorRemotes(r)
215+					if err != nil {
216+						logger.Warn("skipping mirror sync, remote failed validation", "repo", name, "err", err)
217+						return
218+					}
219+
220+					// remoteEnv carries the SSRF guard and must reach every
221+					// command below, so build the full set once.
222+					syncEnv := append(remoteEnv,
223+						fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
224+							filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
225+							cfg.SSH.ClientKeyPath,
226+						),
227+					)
228+
229 					cmds := []string{
230 						"fetch --prune",         // fetch prune before updating remote
231 						"remote update --prune", // update remote and prune remote refs
232@@ -72,12 +126,7 @@ func (m mirrorPull) Func(ctx context.Context) func() {
233 					for _, c := range cmds {
234 						args := strings.Split(c, " ")
235 						cmd := git.NewCommand(args...).WithContext(ctx).WithTimeout(-1)
236-						cmd.AddEnvs(
237-							fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
238-								filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
239-								cfg.SSH.ClientKeyPath,
240-							),
241-						)
242+						cmd.AddEnvs(syncEnv...)
243 
244 						if _, err := cmd.RunInDir(r.Path); err != nil {
245 							logger.Error("error running git remote update", "repo", name, "err", err)
246@@ -98,6 +147,13 @@ func (m mirrorPull) Func(ctx context.Context) func() {
247 							return
248 						}
249 
250+						// The endpoint is stored in the repo config and may
251+						// predate validation, so check it before dialing.
252+						if _, err := ssrf.ValidateGitRemote(lfsEndpoint); err != nil {
253+							logger.Warn("skipping lfs sync, endpoint failed validation", "repo", name, "err", err)
254+							return
255+						}
256+
257diff --git a/pkg/jobs/mirror_test.go b/pkg/jobs/mirror_test.go
258new file mode 100644
259index 0000000000000000000000000000000000000000..063e0d7e188a895b9b59d14ecabd9f1b9a32fc52
260--- /dev/null
261+++ b/pkg/jobs/mirror_test.go
262@@ -0,0 +1,123 @@
263+package jobs
264+
265+import (
266+	"errors"
267+	"os/exec"
268+	"path/filepath"
269+	"slices"
270+	"testing"
271+
272+	"github.com/charmbracelet/soft-serve/git"
273+	"github.com/charmbracelet/soft-serve/pkg/ssrf"
274+)
275+
276+// newRepoWithRemotes creates a bare repo with the given name=url remotes.
277+func newRepoWithRemotes(t *testing.T, remotes map[string]string) *git.Repository {
278+	t.Helper()
279+
280+	path := filepath.Join(t.TempDir(), "repo.git")
281+	if _, err := git.Init(path, true); err != nil {
282+		t.Fatalf("init: %v", err)
283+	}
284+
285+	for name, url := range remotes {
286+		cmd := exec.CommandContext(t.Context(), "git", "remote", "add", name, url)
287+		cmd.Dir = path
288+		if out, err := cmd.CombinedOutput(); err != nil {
289+			t.Fatalf("adding remote %s: %v: %s", name, err, out)
290+		}
291+	}
292+
293+	r, err := git.Open(path)
294+	if err != nil {
295+		t.Fatalf("open: %v", err)
296+	}
297+	return r
298+}
299+
300+func TestValidateMirrorRemotes(t *testing.T) {
301+	tests := []struct {
302+		name    string
303+		remotes map[string]string
304+		wantErr error
305+	}{
306+		{
307+			name:    "public origin",
308+			remotes: map[string]string{"origin": "https://1.1.1.1/x.git"},
309+		},
310+		{
311+			name:    "ssh origin",
312+			remotes: map[string]string{"origin": "ssh://git@10.0.0.1/x.git"},
313+		},
314+		{
315+			name:    "private origin",
316+			remotes: map[string]string{"origin": "http://127.0.0.1:8080/x.git"},
317+			wantErr: ssrf.ErrPrivateIP,
318+		},
319+		{
320+			name:    "metadata origin",
321+			remotes: map[string]string{"origin": "http://169.254.169.254/x.git"},
322+			wantErr: ssrf.ErrPrivateIP,
323+		},
324+		{
325+			// `git remote update` fetches from every remote, not just
326+			// origin. A guard that only reads origin misses this entirely.
327+			name: "private non-origin remote",
328+			remotes: map[string]string{
329+				"origin": "https://1.1.1.1/x.git",
330+				"backup": "http://192.168.1.1/x.git",
331+			},
332+			wantErr: ssrf.ErrPrivateIP,
333+		},
334+		{
335+			// git:// is a raw TCP connect and is just as usable for SSRF.
336+			name:    "private git scheme",
337+			remotes: map[string]string{"origin": "git://10.0.0.1/x.git"},
338+			wantErr: ssrf.ErrPrivateIP,
339+		},
340+		{
341+			// Go and libcurl disagree on non-canonical IPv4 literals.
342+			name:    "octal loopback",
343+			remotes: map[string]string{"origin": "http://0177.0.0.1/x.git"},
344+			wantErr: ssrf.ErrAmbiguousHost,
345+		},
346+		{
347+			// A remote that cannot be parsed must not be fetched from.
348+			// Failing open here was how unvalidated remotes kept firing.
349+			name:    "unparseable remote",
350+			remotes: map[string]string{"origin": "http://[::1/x.git"},
351+			wantErr: ssrf.ErrInvalidURL,
352+		},
353+	}
354+
355+	for _, tt := range tests {
356+		t.Run(tt.name, func(t *testing.T) {
357+			r := newRepoWithRemotes(t, tt.remotes)
358+			env, err := validateMirrorRemotes(r)
359+
360+			if tt.wantErr != nil {
361+				if !errors.Is(err, tt.wantErr) {
362diff --git a/pkg/ssrf/git.go b/pkg/ssrf/git.go
363new file mode 100644
364index 0000000000000000000000000000000000000000..936ae1b3d6148b797be75cd33d6173f86eec9462
365--- /dev/null
366+++ b/pkg/ssrf/git.go
367@@ -0,0 +1,262 @@
368+package ssrf
369+
370+import (
371+	"context"
372+	"errors"
373+	"fmt"
374+	"net"
375+	"net/netip"
376+	"net/url"
377+	"strconv"
378+	"strings"
379+)
380+
381+var (
382+	// ErrUnsupportedRemoteScheme is returned when a git remote uses a scheme
383+	// that is neither a supported network transport nor safe to hand to the
384+	// git subprocess (e.g. file://, ext::, or a bare local path).
385+	ErrUnsupportedRemoteScheme = errors.New("remote scheme is not allowed")
386+	// ErrAmbiguousHost is returned when a remote host is neither a canonical
387+	// IP literal nor a valid DNS name. Non-canonical IPv4 literals such as
388+	// "0177.0.0.1" land here: Go reads them as one address and libcurl reads
389+	// them as another, so they can never be validated safely.
390+	ErrAmbiguousHost = errors.New("remote host is not a canonical IP or DNS name")
391+)
392+
393+// GitRemoteTransport describes how a validated remote will be reached.
394+type GitRemoteTransport int
395+
396+const (
397+	// GitTransportHTTP is an http/https remote. These are validated and
398+	// pinned to the resolved IP.
399+	GitTransportHTTP GitRemoteTransport = iota
400+	// GitTransportGit is a git:// remote. These are validated but cannot be
401+	// pinned, since the git protocol dials directly rather than via libcurl.
402+	GitTransportGit
403+	// GitTransportSSH is an ssh remote. Reachability is governed by the
404+	// operator's SSH client key, so these are not IP-validated.
405+	GitTransportSSH
406+)
407+
408+// GitConfigEntry is a single git configuration key/value pair.
409+type GitConfigEntry struct {
410+	Key   string
411+	Value string
412+}
413+
414+// ValidatedGitRemote is the result of validating a git remote URL.
415+type ValidatedGitRemote struct {
416+	// Transport is the transport the remote will use.
417+	Transport GitRemoteTransport
418+	// Config holds per-remote git configuration required for the validation
419+	// to hold, such as pinning a hostname to the address that was validated.
420+	// Render it with GitEnv, which also applies the settings that are needed
421+	// regardless of remote.
422+	Config []GitConfigEntry
423+}
424+
425+// GitEnv renders the environment that must be passed to any git subprocess
426+// touching the given validated remotes.
427+//
428+// Validation alone is not sufficient for a subprocess: git resolves hostnames
429+// again itself, and follows the first HTTP redirect by default. Both walk
430+// straight through a validate-then-exec check, so this environment disables
431+// redirects and pins each validated address. Callers MUST apply it to every
432+// git command that touches the remote, or the validation means nothing.
433+func GitEnv(remotes ...ValidatedGitRemote) []string {
434+	// git follows the first HTTP redirect by default, which lets a public URL
435+	// hand off to an internal one after validation has already passed. This
436+	// costs imports of moved repositories, which is the right trade for a
437+	// user-supplied remote.
438+	entries := []GitConfigEntry{{Key: "http.followRedirects", Value: "false"}}
439+	for _, r := range remotes {
440+		entries = append(entries, r.Config...)
441+	}
442+
443+	env := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(entries))}
444+	for i, e := range entries {
445+		env = append(env,
446+			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, e.Key),
447+			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, e.Value),
448+		)
449+	}
450+	return env
451+}
452+
453+// ValidateGitRemote validates a git remote URL against private, internal, and
454+// loopback address ranges.
455+//
456+// The result must be passed to GitEnv, and that environment applied to every
457+// git subprocess touching the remote. Validation on its own does not survive
458+// contact with git, which re-resolves hostnames and follows redirects.
459+//
460+// SSH remotes are allowed without IP validation: they authenticate with the
461+// operator's own client key rather than anything an importing user controls.
462+func ValidateGitRemote(remote string) (ValidatedGitRemote, error) {
463+	if remote == "" {
464+		return ValidatedGitRemote{}, ErrInvalidURL
465+	}
466+
467diff --git a/pkg/ssrf/git_test.go b/pkg/ssrf/git_test.go
468new file mode 100644
469index 0000000000000000000000000000000000000000..5ddee2a59151ec66b6bfd44e75be7e77d6e57b73
470--- /dev/null
471+++ b/pkg/ssrf/git_test.go
472@@ -0,0 +1,269 @@
473+package ssrf
474+
475+import (
476+	"errors"
477+	"net/netip"
478+	"net/url"
479+	"slices"
480+	"strconv"
481+	"strings"
482+	"testing"
483+)
484+
485+func TestValidateGitRemote(t *testing.T) {
486+	tests := []struct {
487+		name      string
488+		remote    string
489+		transport GitRemoteTransport
490+		wantErr   error
491+	}{
492+		// Allowed network remotes. IP literals only, so the suite does not
493+		// depend on DNS. Hostname resolution is covered separately.
494+		{"public http", "http://1.1.1.1/x.git", GitTransportHTTP, nil},
495+		{"public https", "https://1.1.1.1/x.git", GitTransportHTTP, nil},
496+		{"public https with port", "https://1.1.1.1:8443/x.git", GitTransportHTTP, nil},
497+		{"public ipv6", "https://[2606:4700::1111]/x.git", GitTransportHTTP, nil},
498+
499+		// SSH is reachability-governed by the operator's client key.
500+		{"ssh url", "ssh://git@10.0.0.1/x.git", GitTransportSSH, nil},
501+		{"bare ssh", "git@github.com:charmbracelet/soft-serve.git", GitTransportSSH, nil},
502+		{"git+ssh", "git+ssh://git@example.com/x.git", GitTransportSSH, nil},
503+		{"ssh+git", "ssh+git://git@example.com/x.git", GitTransportSSH, nil},
504+
505+		// Loopback and private ranges over http.
506+		{"loopback", "http://127.0.0.1/x.git", 0, ErrPrivateIP},
507+		{"localhost", "http://localhost:8080/x.git", 0, ErrPrivateIP},
508+		{"private 10.x", "http://10.0.0.1/x.git", 0, ErrPrivateIP},
509+		{"private 192.168.x", "http://192.168.1.1/x.git", 0, ErrPrivateIP},
510+		{"cloud metadata", "http://169.254.169.254/latest/meta-data/", 0, ErrPrivateIP},
511+		{"ipv6 loopback", "http://[::1]/x.git", 0, ErrPrivateIP},
512+		{"ipv4-mapped loopback", "http://[::ffff:127.0.0.1]/x.git", 0, ErrPrivateIP},
513+
514+		// git:// is a raw TCP connect and must be validated the same way.
515+		// Regression: an earlier fix checked http(s) on import but let
516+		// git:// through on mirror sync.
517+		{"git scheme private", "git://10.0.0.1/x.git", 0, ErrPrivateIP},
518+		{"git scheme metadata", "git://169.254.169.254/x.git", 0, ErrPrivateIP},
519+		{"git scheme public", "git://1.1.1.1/x.git", GitTransportGit, nil},
520+
521+		// Non-canonical IPv4 literals parse differently in Go and libcurl.
522+		// Regression: net.ParseIP reads 0177.0.0.1 as public 177.0.0.1 while
523+		// libcurl reads it as loopback.
524+		{"octal loopback", "http://0177.0.0.1/x.git", 0, ErrAmbiguousHost},
525+		{"hex loopback", "http://0x7f.1/x.git", 0, ErrAmbiguousHost},
526+		{"short-form loopback", "http://127.1/x.git", 0, ErrAmbiguousHost},
527+		{"decimal loopback", "http://2130706433/x.git", 0, ErrAmbiguousHost},
528+		{"octal private", "http://010.0.0.1/x.git", 0, ErrAmbiguousHost},
529+
530+		// Non-network schemes are not remotes at all.
531+		{"file scheme", "file:///etc/passwd", 0, ErrUnsupportedRemoteScheme},
532+		{"ext scheme", "ext::sh -c whoami", 0, ErrUnsupportedRemoteScheme},
533+		{"local path", "/data/repos/secret.git", 0, ErrUnsupportedRemoteScheme},
534+
535+		{"empty", "", 0, ErrInvalidURL},
536+		{"no host", "https:///x.git", 0, ErrInvalidURL},
537+	}
538+
539+	for _, tt := range tests {
540+		t.Run(tt.name, func(t *testing.T) {
541+			got, err := ValidateGitRemote(tt.remote)
542+
543+			if tt.wantErr != nil {
544+				if !errors.Is(err, tt.wantErr) {
545+					t.Fatalf("ValidateGitRemote(%q) error = %v, want %v", tt.remote, err, tt.wantErr)
546+				}
547+				return
548+			}
549+
550+			if err != nil {
551+				t.Fatalf("ValidateGitRemote(%q) unexpected error: %v", tt.remote, err)
552+			}
553+			if got.Transport != tt.transport {
554+				t.Errorf("ValidateGitRemote(%q) transport = %v, want %v", tt.remote, got.Transport, tt.transport)
555+			}
556+		})
557+	}
558+}
559+
560+// TestValidateGitRemoteResolvesHostnames covers the DNS path, which the main
561+// table deliberately avoids so it can run offline.
562+func TestValidateGitRemoteResolvesHostnames(t *testing.T) {
563+	if testing.Short() {
564+		t.Skip("requires DNS")
565+	}
566+
567+	v, err := ValidateGitRemote("https://github.com/charmbracelet/soft-serve.git")
568+	if err != nil {
569+		t.Fatalf("public hostname rejected: %v", err)
570+	}
571+	if v.Transport != GitTransportHTTP {
572diff --git a/testscript/testdata/repo-import-ssrf.txtar b/testscript/testdata/repo-import-ssrf.txtar
573new file mode 100644
574index 0000000000000000000000000000000000000000..022d4f0513c2cc34fe362a1713af1e79611d7b31
575--- /dev/null
576+++ b/testscript/testdata/repo-import-ssrf.txtar
577@@ -0,0 +1,64 @@
578+# vi: set ft=conf
579+
580+# Test SSRF protection on repository import.
581+#
582+# Import takes an arbitrary remote URL from any authenticated user and hands it
583+# to a git clone subprocess. Without validation this reaches internal services
584+# and cloud metadata endpoints.
585+
586+# start soft serve
587+exec soft serve &
588+# wait for SSH server to start
589+ensureserverrunning SSH_PORT
590+
591+# loopback must be rejected
592+! soft repo import bad1 http://127.0.0.1:8080/x.git
593+stderr 'remote must be a network URL'
594+
595+# localhost by name must be rejected
596+! soft repo import bad2 http://localhost:8080/x.git
597+stderr 'remote must be a network URL'
598+
599+# cloud metadata endpoint must be rejected
600+! soft repo import bad3 http://169.254.169.254/latest/meta-data/
601+stderr 'remote must be a network URL'
602+
603+# RFC1918 ranges must be rejected
604+! soft repo import bad4 http://10.0.0.1/x.git
605+stderr 'remote must be a network URL'
606+
607+! soft repo import bad5 http://192.168.1.1/x.git
608+stderr 'remote must be a network URL'
609+
610+# git:// is a raw TCP connect and is equally usable for SSRF
611+! soft repo import bad6 git://10.0.0.1/x.git
612+stderr 'remote must be a network URL'
613+
614+# non-canonical IPv4 literals resolve differently in Go and libcurl:
615+# 0177.0.0.1 is 177.0.0.1 to Go and 127.0.0.1 to libcurl
616+! soft repo import bad7 http://0177.0.0.1:8080/x.git
617+stderr 'remote must be a network URL'
618+
619+! soft repo import bad8 http://127.1:8080/x.git
620+stderr 'remote must be a network URL'
621+
622+# non-network schemes are not remotes at all
623+! soft repo import bad9 file:///etc/passwd
624+stderr 'remote must be a network URL'
625+
626+# ext:: is arbitrary command execution
627+! soft repo import bad10 'ext::whoami'
628+stderr 'remote must be a network URL'
629+
630+# the LFS endpoint is user-supplied too, and is persisted to the repo config
631+# where the mirror job later reads it
632+! soft repo import bad11 https://1.1.1.1/x.git --lfs-endpoint http://127.0.0.1:9000
633+stderr 'remote must be a network URL'
634+
635+# none of the rejected imports may leave a repository behind
636+soft repo list
637+! stdout 'bad'
638+
639+# stop the server
640+[windows] stopserver
641+[windows] ! stderr .