06fdf62b53f8d80c61de912bf84be7c04553df01
- Author
- tonghuaroot (童话) <tonghuaroot@gmail.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
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 .