33a366157412c5e1d45199734f7451ed0c30d48e

Author
Drew Smirnoff <me@andrinoff.com>
Committer
GitHub <noreply@github.com>
Date

Message

Merge commit from fork

* fix: path traversal and OID validation

Signed-off-by: drew <me@andrinoff.com>

* fix: repair LFS upload, correct OID error status, guard HTTP sinks

Follow-up to the OID validation and path traversal fix.

Rejecting absolute paths in LocalStorage.fixPath broke every LFS upload:
Upload opened the staged temp file and passed obj.Name() back to
storage.Rename, which is the absolute path os.Open was handed, so the new
guard rejected it. Pass the relative tempName instead. Dropping the
intervening Open also closes a file handle that leaked for the life of
the connection.

lfs.ErrInvalidOIDFormat matches nothing in the transfer processor's error
switch, so a malformed OID fell through to a generic 500. Wrap it in
transfer.ErrParseError for a 400, keeping the original sentinel
reachable via errors.Is.

Two HTTP sinks derived a storage path from an unvalidated OID before the
IsValid check: the batch download handler's Exists call and the basic
verify handler's Stat call. Both are reachable in the default config,
where HTTP LFS is enabled and lfs.ssh_enabled is not, and both leaked
file existence outside the storage root. Validate first.

Adds regression tests for storage root confinement and for OID rejection
at the transfer backend.

* test: un-skip ssh-lfs testscript

The skip dates to 8c9777d (2024-07-30), which recorded "a bug with
git-lfs making the session hang" against git-lfs 3.5.1. That bug is
fixed upstream; the script passes against git-lfs 3.7.1.

It was the only test in the tree that drove git-lfs-transfer end to end,
so while it slept the entire SSH LFS service had no integration
coverage. That is not theoretical: reintroducing the obj.Name() bug from
the previous commit fails this script at the push step with

  FAIL: testdata/ssh-lfs.txtar:57: exit status 1
  ERRO lfs-transfer: error renaming object: path traversal detected

The HTTP LFS scripts cannot cover that path, because the HTTP upload
handler calls storage.Put directly and never reaches the SSH backend's
Upload.

Note the script intermittently surfaces a data race inside
charm.land/ssh v0.4.2, where Server.handleConn writes
conn.handshakeDeadline (server.go:433) while the handshake read loop
reads it through updateDeadline (conn.go:51). It is unsynchronized
upstream and unrelated to LFS. It does not fail the run, since the
background server is killed by signal and never reaches the race
runtime's nonzero exit, but it is worth reporting to charm.land/ssh.

* chore: bump charm.land/ssh to v0.4.3

v0.4.3 guards serverConn.handshakeDeadline with a mutex. Before it, the
accept goroutine cleared that deadline once the handshake returned while
the crypto/ssh read loop was already consulting it through updateDeadline
on every Read, with nothing synchronizing the two.

Soft Serve sets SSH.IdleTimeout to 10 minutes by default, which is what
makes Read consult the deadline at all, so the race was reachable in an
ordinary deployment rather than only under test. It surfaced as
intermittent race warnings from the ssh-lfs testscript, whose long-lived
transfer sessions widened the window.

The ssh-lfs script now runs clean across repeated runs.

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Kieran Klukas <kieran@dunkirk.sh>

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