33a366157412c5e1d45199734f7451ed0c30d48e
- Author
- Drew Smirnoff <me@andrinoff.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/go.mod b/go.mod
2index 5b5f6131a0911dec18798a908b51721ef4cc4df5..4557f80ca6bb345f0fc2b624377960f70a05a43e 100644
3--- a/go.mod
4+++ b/go.mod
5@@ -8,7 +8,7 @@ require (
6 charm.land/glamour/v2 v2.0.1
7 charm.land/lipgloss/v2 v2.0.5
8 charm.land/log/v2 v2.0.0
9- charm.land/ssh v0.4.2
10+ charm.land/ssh v0.4.3
11 charm.land/wish/v2 v2.0.3
12 github.com/alecthomas/chroma/v2 v2.27.0
13 github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53
14diff --git a/go.sum b/go.sum
15index 4880500be6bf80f9ea93f58cc24a2f5adf0165fc..4ab6af509d537cc2e40b0a1fe104dd3b994a41f9 100644
16--- a/go.sum
17+++ b/go.sum
18@@ -8,8 +8,8 @@ charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
19 charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
20 charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
21 charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
22-charm.land/ssh v0.4.2 h1:mpJW8KuCQSu5mn4L9cRtDQpVtUNa/JwbqbOHyB/H1lI=
23-charm.land/ssh v0.4.2/go.mod h1:so/3IECPNlYZSnE7JKn7NFmcUyyxJqIAeM4TJy35qPk=
24+charm.land/ssh v0.4.3 h1:hr5cYmlUYsP+KxyG/ug7ZMzKKN9f6VOD9yPpcGqdzzM=
25+charm.land/ssh v0.4.3/go.mod h1:so/3IECPNlYZSnE7JKn7NFmcUyyxJqIAeM4TJy35qPk=
26 charm.land/wish/v2 v2.0.3 h1:Xkgw31lEH9AJkPfgXYYvsgrskfDIY9ffHTxFRV4UT+4=
27 charm.land/wish/v2 v2.0.3/go.mod h1:i8gFfXu+IyMcGpRh6D84Wa+mDGwjYCKWcA86R+IJf0c=
28 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
29diff --git a/pkg/git/lfs.go b/pkg/git/lfs.go
30index 14846c1208eb10ac7e99964fe92f5a32574532f2..a14b9f3800a32f8f368342748a490c3beafd0072 100644
31--- a/pkg/git/lfs.go
32+++ b/pkg/git/lfs.go
33@@ -35,6 +35,15 @@ type lfsTransfer struct {
34
35 var _ transfer.Backend = &lfsTransfer{}
36
37+// errInvalidOid is returned when a client sends a malformed object ID. Object
38+// IDs are interpolated into storage paths, so anything outside the SHA-256
39+// alphabet is a path traversal attempt.
40+//
41+// It wraps transfer.ErrParseError so the processor answers with a 400 instead
42+// of falling through to a generic internal error, and lfs.ErrInvalidOIDFormat
43+// so callers can still test for the cause.
44+var errInvalidOid = fmt.Errorf("%w: %w", transfer.ErrParseError, lfs.ErrInvalidOIDFormat)
45+
46 // LFSTransfer is a Git LFS transfer service handler.
47 // ctx is expected to have proto.User, *backend.Backend, *log.Logger,
48 // *config.Config, *db.DB, and store.Store.
49@@ -88,6 +97,13 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
50
51 // Batch implements transfer.Backend.
52 func (t *lfsTransfer) Batch(_ string, pointers []transfer.BatchItem, _ transfer.Args) ([]transfer.BatchItem, error) {
53+ for i := range pointers {
54+ p := transfer.Pointer{Oid: pointers[i].Oid, Size: pointers[i].Size}
55+ if !p.IsValid() {
56+ return pointers, errInvalidOid
57+ }
58+ }
59+
60 for i := range pointers {
61 obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid)
62 if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
63@@ -111,6 +127,11 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.BatchItem, _ transfer.
64
65 // Download implements transfer.Backend.
66 func (t *lfsTransfer) Download(oid string, _ transfer.Args) (io.ReadCloser, int64, error) {
67+ p := transfer.Pointer{Oid: oid}
68+ if !p.IsValid() {
69+ return nil, 0, errInvalidOid
70+ }
71+
72 cfg := config.FromContext(t.ctx)
73 repoID := strconv.FormatInt(t.repo.ID(), 10)
74 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
75@@ -128,6 +149,11 @@ func (t *lfsTransfer) Download(oid string, _ transfer.Args) (io.ReadCloser, int6
76
77 // Upload implements transfer.Backend.
78 func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Args) error {
79+ p := transfer.Pointer{Oid: oid}
80+ if !p.IsValid() {
81+ return errInvalidOid
82+ }
83+
84 if r == nil {
85 return fmt.Errorf("no reader: %w", transfer.ErrMissingData)
86 }
87@@ -147,12 +173,6 @@ func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Arg
88 return err
89 }
90
91- obj, err := t.storage.Open(tempName)
92- if err != nil {
93- t.logger.Errorf("error opening object: %v", err)
94- return err
95- }
96-
97 pointer := transfer.Pointer{
98 Oid: oid,
99 }
100@@ -166,8 +186,10 @@ func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Arg
101 return db.WrapError(err)
102 }
103
104+ // Rename takes names relative to the storage root, not the absolute path
105+ // the temp file happens to live at.
106 expectedPath := path.Join("objects", pointer.RelativePath())
107- if err := t.storage.Rename(obj.Name(), expectedPath); err != nil {
108+ if err := t.storage.Rename(tempName, expectedPath); err != nil {
109 t.logger.Errorf("error renaming object: %v", err)
110 _ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid)
111 return err
112@@ -178,6 +200,11 @@ func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Arg
113
114 // Verify implements transfer.Backend.
115 func (t *lfsTransfer) Verify(oid string, size int64, _ transfer.Args) (transfer.Status, error) {
116+ p := transfer.Pointer{Oid: oid}
117+ if !p.IsValid() {
118+ return transfer.NewStatus(transfer.StatusConflict, "invalid OID format"), nil
119+ }
120+
121 obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid)
122 if err != nil {
123 if errors.Is(err, db.ErrRecordNotFound) {
124diff --git a/pkg/git/lfs_test.go b/pkg/git/lfs_test.go
125new file mode 100644
126index 0000000000000000000000000000000000000000..7e97a41a87dd3cb1608caaa678bac362e11eaaf2
127--- /dev/null
128+++ b/pkg/git/lfs_test.go
129@@ -0,0 +1,64 @@
130+package git
131+
132+import (
133+ "errors"
134+ "strings"
135+ "testing"
136+
137+ "github.com/charmbracelet/git-lfs-transfer/transfer"
138+ "github.com/charmbracelet/soft-serve/pkg/lfs"
139+)
140+
141+// Object IDs arrive raw off the pktline stream. They must be rejected before
142+// they can be joined into a storage path. The backend is left zero-valued on
143+// purpose: a guard that fires only after the store or filesystem is touched is
144+// not a guard.
145+func TestLFSTransferRejectsMalformedOid(t *testing.T) {
146+ oids := []string{
147+ "../../../../../../../../etc/passwd",
148+ "../../ssh/soft_serve_host_ed25519",
149+ "objects/../../../soft-serve.db",
150+ "/etc/passwd",
151+ "",
152+ "abc",
153+ strings.Repeat("a", 63),
154+ strings.Repeat("a", 65),
155+ strings.ToUpper(strings.Repeat("a", 64)),
156+ }
157+
158+ var backend lfsTransfer
159+ for _, oid := range oids {
160+ t.Run(oid, func(t *testing.T) {
161+ if _, _, err := backend.Download(oid, nil); !errors.Is(err, errInvalidOid) {
162+ t.Errorf("Download: got %v, want errInvalidOid", err)
163+ }
164+ if err := backend.Upload(oid, 1, strings.NewReader("x"), nil); !errors.Is(err, errInvalidOid) {
165+ t.Errorf("Upload: got %v, want errInvalidOid", err)
166+ }
167+ // Verify answers in-band with a conflict status rather than
168+ // tearing down the session.
169+ status, err := backend.Verify(oid, 1, nil)
170+ if err != nil {
171+ t.Errorf("Verify: unexpected error %v", err)
172+ } else if status == nil || status.Code() != transfer.StatusConflict {
173+ t.Errorf("Verify: got %v, want a conflict status", status)
174+ }
175+
176+ items := []transfer.BatchItem{{Pointer: transfer.Pointer{Oid: oid, Size: 1}}}
177+ if _, err := backend.Batch("download", items, nil); !errors.Is(err, errInvalidOid) {
178+ t.Errorf("Batch: got %v, want errInvalidOid", err)
179+ }
180+ })
181+ }
182+}
183+
184+// A malformed object ID is a client error, so the processor reports it as a
185+// 400 rather than falling through to a generic internal error.
186+func TestInvalidOidIsAParseError(t *testing.T) {
187+ if !errors.Is(errInvalidOid, transfer.ErrParseError) {
188+ t.Errorf("errInvalidOid does not wrap transfer.ErrParseError")
189+ }
190+ if !errors.Is(errInvalidOid, lfs.ErrInvalidOIDFormat) {
191+ t.Errorf("errInvalidOid does not wrap lfs.ErrInvalidOIDFormat")
192+ }
193+}
194diff --git a/pkg/storage/local.go b/pkg/storage/local.go
195index bf2fac9a5a37498b645f3dd234b3129bb985b6ac..4d19183d8fc3e528833adb3be51332273eafe011 100644
196--- a/pkg/storage/local.go
197+++ b/pkg/storage/local.go
198@@ -9,6 +9,9 @@ import (
199 "strings"
200 )
201
202+// ErrPathTraversal is returned when a path attempts to escape the storage root.
203+var ErrPathTraversal = errors.New("path traversal detected")
204+
205 // LocalStorage is a storage implementation that stores objects on the local
206 // filesystem.
207 type LocalStorage struct {
208@@ -24,25 +27,37 @@ func NewLocalStorage(root string) *LocalStorage {
209
210 // Delete implements Storage.
211 func (l *LocalStorage) Delete(name string) error {
212- name = l.fixPath(name)
213+ name, err := l.fixPath(name)
214+ if err != nil {
215+ return err
216+ }
217 return os.Remove(name)
218 }
219
220 // Open implements Storage.
221 func (l *LocalStorage) Open(name string) (Object, error) {
222- name = l.fixPath(name)
223+ name, err := l.fixPath(name)
224+ if err != nil {
225+ return nil, err
226+ }
227 return os.Open(name)
228 }
229
230 // Stat implements Storage.
231 func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
232- name = l.fixPath(name)
233+ name, err := l.fixPath(name)
234+ if err != nil {
235+ return nil, err
236+ }
237 return os.Stat(name)
238 }
239
240 // Put implements Storage.
241 func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {
242- name = l.fixPath(name)
243+ name, err := l.fixPath(name)
244+ if err != nil {
245+ return 0, err
246+ }
247 if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
248 return 0, err
249 }
250@@ -57,8 +72,11 @@ func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {
251
252 // Exists implements Storage.
253 func (l *LocalStorage) Exists(name string) (bool, error) {
254- name = l.fixPath(name)
255- _, err := os.Stat(name)
256+ name, err := l.fixPath(name)
257+ if err != nil {
258+ return false, err
259+ }
260+ _, err = os.Stat(name)
261 if err == nil {
262 return true, nil
263 }
264@@ -70,8 +88,14 @@ func (l *LocalStorage) Exists(name string) (bool, error) {
265
266 // Rename implements Storage.
267 func (l *LocalStorage) Rename(oldName, newName string) error {
268- oldName = l.fixPath(oldName)
269- newName = l.fixPath(newName)
270+ oldName, err := l.fixPath(oldName)
271+ if err != nil {
272+ return err
273+ }
274+ newName, err = l.fixPath(newName)
275+ if err != nil {
276+ return err
277+ }
278 if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil {
279 return err
280 }
281@@ -79,12 +103,23 @@ func (l *LocalStorage) Rename(oldName, newName string) error {
282 return os.Rename(oldName, newName)
283 }
284
285-// Replace all slashes with the OS-specific separator
286-func (l LocalStorage) fixPath(path string) string {
287- path = strings.ReplaceAll(path, "/", string(os.PathSeparator))
288- if !filepath.IsAbs(path) {
289- return filepath.Join(l.root, path)
290+// fixPath resolves the given path relative to the storage root and ensures
291+// it does not escape outside the root directory.
292+func (l LocalStorage) fixPath(name string) (string, error) {
293+ name = strings.ReplaceAll(name, "/", string(os.PathSeparator))
294+ if filepath.IsAbs(name) {
295+ return "", ErrPathTraversal
296+ }
297+
298diff --git a/pkg/storage/local_test.go b/pkg/storage/local_test.go
299new file mode 100644
300index 0000000000000000000000000000000000000000..aab7b423dbab24ff0a79dd099e766df52414be4d
301--- /dev/null
302+++ b/pkg/storage/local_test.go
303@@ -0,0 +1,91 @@
304+package storage
305+
306+import (
307+ "errors"
308+ "os"
309+ "path/filepath"
310+ "strings"
311+ "testing"
312+)
313+
314+// Names handed to LocalStorage come from LFS object IDs, which are attacker
315+// controlled. None of them may resolve outside the root.
316+func TestLocalStorageConfinesToRoot(t *testing.T) {
317+ outside := t.TempDir()
318+ root := filepath.Join(outside, "root")
319+ secret := filepath.Join(outside, "secret")
320+ if err := os.WriteFile(secret, []byte("host key"), 0o600); err != nil {
321+ t.Fatal(err)
322+ }
323+
324+ l := NewLocalStorage(root)
325+ for _, name := range []string{
326+ "../secret",
327+ "objects/../../secret",
328+ "../../../../../../../../etc/passwd",
329+ secret,
330+ "/etc/passwd",
331+ } {
332+ t.Run(name, func(t *testing.T) {
333+ if _, err := l.Open(name); !errors.Is(err, ErrPathTraversal) {
334+ t.Errorf("Open: got %v, want ErrPathTraversal", err)
335+ }
336+ if _, err := l.Stat(name); !errors.Is(err, ErrPathTraversal) {
337+ t.Errorf("Stat: got %v, want ErrPathTraversal", err)
338+ }
339+ if _, err := l.Exists(name); !errors.Is(err, ErrPathTraversal) {
340+ t.Errorf("Exists: got %v, want ErrPathTraversal", err)
341+ }
342+ if _, err := l.Put(name, strings.NewReader("x")); !errors.Is(err, ErrPathTraversal) {
343+ t.Errorf("Put: got %v, want ErrPathTraversal", err)
344+ }
345+ if err := l.Delete(name); !errors.Is(err, ErrPathTraversal) {
346+ t.Errorf("Delete: got %v, want ErrPathTraversal", err)
347+ }
348+ if err := l.Rename("objects/a", name); !errors.Is(err, ErrPathTraversal) {
349+ t.Errorf("Rename dst: got %v, want ErrPathTraversal", err)
350+ }
351+ if err := l.Rename(name, "objects/a"); !errors.Is(err, ErrPathTraversal) {
352+ t.Errorf("Rename src: got %v, want ErrPathTraversal", err)
353+ }
354+ })
355+ }
356+
357+ if _, err := os.Stat(root); !errors.Is(err, os.ErrNotExist) {
358+ t.Errorf("root was created by a rejected write: %v", err)
359+ }
360+ if b, err := os.ReadFile(secret); err != nil || string(b) != "host key" {
361+ t.Errorf("secret was clobbered: %q %v", b, err)
362+ }
363+}
364+
365+// Ordinary relative names still round-trip through the root. This is the shape
366+// of an LFS upload: stage under "incomplete", then rename into place. Names
367+// crossing the Storage boundary are relative, so callers must not feed back the
368+// absolute path an opened Object reports.
369+func TestLocalStorageRoundTrip(t *testing.T) {
370+ root := t.TempDir()
371+ l := NewLocalStorage(root)
372+
373+ if _, err := l.Put("incomplete/tmp", strings.NewReader("hello")); err != nil {
374+ t.Fatal(err)
375+ }
376+ if err := l.Rename("incomplete/tmp", "objects/ab/cd/abcd"); err != nil {
377+ t.Fatal(err)
378+ }
379+
380+ exists, err := l.Exists("objects/ab/cd/abcd")
381+ if err != nil || !exists {
382+ t.Fatalf("Exists: %v %v", exists, err)
383+ }
384+
385+ f, err := l.Open("objects/ab/cd/abcd")
386+ if err != nil {
387+ t.Fatal(err)
388+ }
389+ defer f.Close() //nolint: errcheck
390+
391+ if got := f.Name(); !strings.HasPrefix(got, root) {
392+ t.Errorf("Name %q is not under root %q", got, root)
393+ }
394+}
395diff --git a/pkg/web/git_lfs.go b/pkg/web/git_lfs.go
396index 12d3cbd61307fdb9fddfd3650230626edb594e49..6d7dfe863d255e001d533d188076de4799e0d131 100644
397--- a/pkg/web/git_lfs.go
398+++ b/pkg/web/git_lfs.go
399@@ -104,6 +104,20 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
400 switch batchRequest.Operation {
401 case lfs.OperationDownload:
402 for _, o := range batchRequest.Objects {
403+ // Object IDs become storage paths, so validate before touching the
404+ // filesystem.
405+ if !o.IsValid() {
406+ logger.Error("invalid object", "oid", o.Oid, "repo", name)
407+ objects = append(objects, &lfs.ObjectResponse{
408+ Pointer: o,
409+ Error: &lfs.ObjectError{
410+ Code: http.StatusUnprocessableEntity,
411+ Message: "invalid object",
412+ },
413+ })
414+ continue
415+ }
416+
417 exist, err := strg.Exists(path.Join("objects", o.RelativePath()))
418 if err != nil && !errors.Is(err, fs.ErrNotExist) {
419 logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
420@@ -138,7 +152,7 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
421 Message: "size mismatch",
422 },
423 })
424- } else if o.IsValid() {
425+ } else {
426 download := &lfs.Link{
427 Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
428 }
429@@ -165,15 +179,6 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
430 return
431 }
432 }
433- } else {
434- logger.Error("invalid object", "oid", o.Oid, "repo", name)
435- objects = append(objects, &lfs.ObjectResponse{
436- Pointer: o,
437- Error: &lfs.ObjectError{
438- Code: http.StatusUnprocessableEntity,
439- Message: "invalid object",
440- },
441- })
442 }
443 }
444 case lfs.OperationUpload:
445@@ -405,6 +410,16 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
446 return
447 }
448
449+ // Object IDs become storage paths, so validate before touching the
450+ // filesystem.
451+ if !pointer.IsValid() {
452+ logger.Error("invalid object", "oid", pointer.Oid)
453+ renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
454+ Message: "invalid object",
455+ })
456+ return
457+ }
458+
459 cfg := config.FromContext(ctx)
460 dbx := db.FromContext(ctx)
461 datastore := store.FromContext(ctx)
462@@ -435,7 +450,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
463 return
464 }
465
466- if pointer.IsValid() && stat.Size() == pointer.Size {
467+ if stat.Size() == pointer.Size {
468 renderStatus(http.StatusOK)(w, nil)
469 return
470 }
471diff --git a/testscript/testdata/ssh-lfs.txtar b/testscript/testdata/ssh-lfs.txtar
472index a647dff91a996cc6dc0eeaf4041464550b5a6cd3..239a3fd49e49b09ae9cd1ddda44d970cd68a1cf3 100644
473--- a/testscript/testdata/ssh-lfs.txtar
474+++ b/testscript/testdata/ssh-lfs.txtar
475@@ -2,8 +2,6 @@
476
477 [windows] dos2unix err1.txt err2.txt err3.txt errauth.txt
478
479-skip 'breaks with git-lfs 3.5.1'
480-
481 # enable ssh lfs transfer
482 env SOFT_SERVE_LFS_SSH_ENABLED=true
483 # start soft serve