Browse Source

lfs: verify content hash and prevent object overwrite (#8166)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
ᴊᴏᴇ ᴄʜᴇɴ 2 days ago
parent
commit
81ee883644

+ 4 - 0
CHANGELOG.md

@@ -4,6 +4,10 @@ All notable changes to Gogs are documented in this file.
 
 
 ## 0.15.0+dev (`main`)
 ## 0.15.0+dev (`main`)
 
 
+### Fixed
+
+- _Security:_ Cross-repository LFS object overwrite via missing content hash verification. [#8166](https://github.com/gogs/gogs/pull/8166) - [GHSA-gmf8-978x-2fg2](https://github.com/gogs/gogs/security/advisories/GHSA-gmf8-978x-2fg2)
+
 ### Removed
 ### Removed
 
 
 - The `gogs cert` subcommand. [#8153](https://github.com/gogs/gogs/pull/8153)
 - The `gogs cert` subcommand. [#8153](https://github.com/gogs/gogs/pull/8153)

+ 2 - 0
conf/app.ini

@@ -279,6 +279,8 @@ ACCESS_CONTROL_ALLOW_ORIGIN =
 STORAGE = local
 STORAGE = local
 ; The root path to store LFS objects on local file system.
 ; The root path to store LFS objects on local file system.
 OBJECTS_PATH = data/lfs-objects
 OBJECTS_PATH = data/lfs-objects
+; The path to temporarily store LFS objects during upload verification.
+OBJECTS_TEMP_PATH = data/tmp/lfs-objects
 
 
 [attachment]
 [attachment]
 ; Whether to enabled upload attachments in general.
 ; Whether to enabled upload attachments in general.

+ 1 - 0
internal/conf/conf.go

@@ -346,6 +346,7 @@ func Init(customConf string) error {
 		return errors.Wrap(err, "mapping [lfs] section")
 		return errors.Wrap(err, "mapping [lfs] section")
 	}
 	}
 	LFS.ObjectsPath = ensureAbs(LFS.ObjectsPath)
 	LFS.ObjectsPath = ensureAbs(LFS.ObjectsPath)
+	LFS.ObjectsTempPath = ensureAbs(LFS.ObjectsTempPath)
 
 
 	handleDeprecated()
 	handleDeprecated()
 	if !HookMode {
 	if !HookMode {

+ 3 - 2
internal/conf/static.go

@@ -361,8 +361,9 @@ type DatabaseOpts struct {
 var Database DatabaseOpts
 var Database DatabaseOpts
 
 
 type LFSOpts struct {
 type LFSOpts struct {
-	Storage     string
-	ObjectsPath string
+	Storage         string
+	ObjectsPath     string
+	ObjectsTempPath string
 }
 }
 
 
 // LFS settings
 // LFS settings

+ 1 - 0
internal/database/repo.go

@@ -162,6 +162,7 @@ func NewRepoContext() {
 	}
 	}
 
 
 	RemoveAllWithNotice("Clean up repository temporary data", filepath.Join(conf.Server.AppDataPath, "tmp"))
 	RemoveAllWithNotice("Clean up repository temporary data", filepath.Join(conf.Server.AppDataPath, "tmp"))
+	RemoveAllWithNotice("Clean up LFS temporary data", conf.LFS.ObjectsTempPath)
 }
 }
 
 
 // Repository contains information of a repository.
 // Repository contains information of a repository.

+ 43 - 15
internal/lfsutil/storage.go

@@ -1,6 +1,8 @@
 package lfsutil
 package lfsutil
 
 
 import (
 import (
+	"crypto/sha256"
+	"encoding/hex"
 	"io"
 	"io"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
@@ -10,7 +12,10 @@ import (
 	"gogs.io/gogs/internal/osutil"
 	"gogs.io/gogs/internal/osutil"
 )
 )
 
 
-var ErrObjectNotExist = errors.New("Object does not exist")
+var (
+	ErrObjectNotExist = errors.New("object does not exist")
+	ErrOIDMismatch    = errors.New("content hash does not match OID")
+)
 
 
 // Storager is an storage backend for uploading and downloading LFS objects.
 // Storager is an storage backend for uploading and downloading LFS objects.
 type Storager interface {
 type Storager interface {
@@ -39,6 +44,8 @@ var _ Storager = (*LocalStorage)(nil)
 type LocalStorage struct {
 type LocalStorage struct {
 	// The root path for storing LFS objects.
 	// The root path for storing LFS objects.
 	Root string
 	Root string
+	// The path for storing temporary files during upload verification.
+	TempDir string
 }
 }
 
 
 func (*LocalStorage) Storage() Storage {
 func (*LocalStorage) Storage() Storage {
@@ -58,29 +65,50 @@ func (s *LocalStorage) Upload(oid OID, rc io.ReadCloser) (int64, error) {
 		return 0, ErrInvalidOID
 		return 0, ErrInvalidOID
 	}
 	}
 
 
-	var err error
 	fpath := s.storagePath(oid)
 	fpath := s.storagePath(oid)
-	defer func() {
-		rc.Close()
+	dir := filepath.Dir(fpath)
 
 
-		if err != nil {
-			_ = os.Remove(fpath)
-		}
-	}()
+	defer rc.Close()
 
 
-	err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
-	if err != nil {
+	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
 		return 0, errors.Wrap(err, "create directories")
 		return 0, errors.Wrap(err, "create directories")
 	}
 	}
-	w, err := os.Create(fpath)
+
+	// If the object file already exists, skip the upload and return the
+	// existing file's size.
+	if fi, err := os.Stat(fpath); err == nil {
+		_, _ = io.Copy(io.Discard, rc)
+		return fi.Size(), nil
+	}
+
+	// Write to a temp file and verify the content hash before publishing.
+	// This ensures the final path always contains a complete, hash-verified
+	// file, even when concurrent uploads of the same OID race.
+	if err := os.MkdirAll(s.TempDir, os.ModePerm); err != nil {
+		return 0, errors.Wrap(err, "create temp directory")
+	}
+	tmp, err := os.CreateTemp(s.TempDir, "upload-*")
 	if err != nil {
 	if err != nil {
-		return 0, errors.Wrap(err, "create file")
+		return 0, errors.Wrap(err, "create temp file")
 	}
 	}
-	defer w.Close()
+	tmpPath := tmp.Name()
+	defer os.Remove(tmpPath)
 
 
-	written, err := io.Copy(w, rc)
+	hash := sha256.New()
+	written, err := io.Copy(tmp, io.TeeReader(rc, hash))
+	if closeErr := tmp.Close(); err == nil && closeErr != nil {
+		err = closeErr
+	}
 	if err != nil {
 	if err != nil {
-		return 0, errors.Wrap(err, "copy file")
+		return 0, errors.Wrap(err, "write object file")
+	}
+
+	if computed := hex.EncodeToString(hash.Sum(nil)); computed != string(oid) {
+		return 0, ErrOIDMismatch
+	}
+
+	if err := os.Rename(tmpPath, fpath); err != nil && !os.IsExist(err) {
+		return 0, errors.Wrap(err, "publish object file")
 	}
 	}
 	return written, nil
 	return written, nil
 }
 }

+ 40 - 33
internal/lfsutil/storage_test.go

@@ -10,6 +10,9 @@ import (
 	"testing"
 	"testing"
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"gogs.io/gogs/internal/osutil"
 )
 )
 
 
 func TestLocalStorage_storagePath(t *testing.T) {
 func TestLocalStorage_storagePath(t *testing.T) {
@@ -46,50 +49,54 @@ func TestLocalStorage_storagePath(t *testing.T) {
 }
 }
 
 
 func TestLocalStorage_Upload(t *testing.T) {
 func TestLocalStorage_Upload(t *testing.T) {
+	base := t.TempDir()
 	s := &LocalStorage{
 	s := &LocalStorage{
-		Root: filepath.Join(os.TempDir(), "lfs-objects"),
+		Root:    filepath.Join(base, "lfs-objects"),
+		TempDir: filepath.Join(base, "tmp", "lfs"),
 	}
 	}
-	t.Cleanup(func() {
-		_ = os.RemoveAll(s.Root)
+
+	const helloWorldOID = OID("c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51a") // "Hello world!"
+
+	t.Run("invalid OID", func(t *testing.T) {
+		written, err := s.Upload("bad_oid", io.NopCloser(strings.NewReader("")))
+		assert.Equal(t, int64(0), written)
+		assert.Equal(t, ErrInvalidOID, err)
 	})
 	})
 
 
-	tests := []struct {
-		name       string
-		oid        OID
-		content    string
-		expWritten int64
-		expErr     error
-	}{
-		{
-			name:   "invalid oid",
-			oid:    "bad_oid",
-			expErr: ErrInvalidOID,
-		},
+	t.Run("valid OID", func(t *testing.T) {
+		written, err := s.Upload(helloWorldOID, io.NopCloser(strings.NewReader("Hello world!")))
+		require.NoError(t, err)
+		assert.Equal(t, int64(12), written)
+	})
 
 
-		{
-			name:       "valid oid",
-			oid:        "ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",
-			content:    "Hello world!",
-			expWritten: 12,
-		},
-	}
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			written, err := s.Upload(test.oid, io.NopCloser(strings.NewReader(test.content)))
-			assert.Equal(t, test.expWritten, written)
-			assert.Equal(t, test.expErr, err)
-		})
-	}
+	t.Run("valid OID but wrong content", func(t *testing.T) {
+		oid := OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f")
+		written, err := s.Upload(oid, io.NopCloser(strings.NewReader("Hello world!")))
+		assert.Equal(t, int64(0), written)
+		assert.Equal(t, ErrOIDMismatch, err)
+
+		// File should have been cleaned up.
+		assert.False(t, osutil.IsFile(s.storagePath(oid)))
+	})
+
+	t.Run("duplicate upload returns existing size", func(t *testing.T) {
+		written, err := s.Upload(helloWorldOID, io.NopCloser(strings.NewReader("should be ignored")))
+		require.NoError(t, err)
+		assert.Equal(t, int64(12), written)
+
+		// Verify original content is preserved.
+		var buf bytes.Buffer
+		err = s.Download(helloWorldOID, &buf)
+		require.NoError(t, err)
+		assert.Equal(t, "Hello world!", buf.String())
+	})
 }
 }
 
 
 func TestLocalStorage_Download(t *testing.T) {
 func TestLocalStorage_Download(t *testing.T) {
 	oid := OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f")
 	oid := OID("ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f")
 	s := &LocalStorage{
 	s := &LocalStorage{
-		Root: filepath.Join(os.TempDir(), "lfs-objects"),
+		Root: filepath.Join(t.TempDir(), "lfs-objects"),
 	}
 	}
-	t.Cleanup(func() {
-		_ = os.RemoveAll(s.Root)
-	})
 
 
 	fpath := s.storagePath(oid)
 	fpath := s.storagePath(oid)
 	err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
 	err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm)

+ 3 - 3
internal/route/lfs/basic.go

@@ -91,7 +91,7 @@ func (h *basicHandler) serveUpload(c *macaron.Context, repo *database.Repository
 	s := h.DefaultStorager()
 	s := h.DefaultStorager()
 	written, err := s.Upload(oid, c.Req.Request.Body)
 	written, err := s.Upload(oid, c.Req.Request.Body)
 	if err != nil {
 	if err != nil {
-		if err == lfsutil.ErrInvalidOID {
+		if err == lfsutil.ErrInvalidOID || err == lfsutil.ErrOIDMismatch {
 			responseJSON(c.Resp, http.StatusBadRequest, responseError{
 			responseJSON(c.Resp, http.StatusBadRequest, responseError{
 				Message: err.Error(),
 				Message: err.Error(),
 			})
 			})
@@ -105,8 +105,8 @@ func (h *basicHandler) serveUpload(c *macaron.Context, repo *database.Repository
 	err = h.store.CreateLFSObject(c.Req.Context(), repo.ID, oid, written, s.Storage())
 	err = h.store.CreateLFSObject(c.Req.Context(), repo.ID, oid, written, s.Storage())
 	if err != nil {
 	if err != nil {
 		// NOTE: It is OK to leave the file when the whole operation failed
 		// NOTE: It is OK to leave the file when the whole operation failed
-		// with a DB error, a retry on client side can safely overwrite the
-		// same file as OID is seen as unique to every file.
+		// with a DB error, a retry on client side will skip the upload as
+		// the file already exists on disk.
 		internalServerError(c.Resp)
 		internalServerError(c.Resp)
 		log.Error("Failed to create object [repo_id: %d, oid: %s]: %v", repo.ID, oid, err)
 		log.Error("Failed to create object [repo_id: %d, oid: %s]: %v", repo.ID, oid, err)
 		return
 		return

+ 1 - 1
internal/route/lfs/route.go

@@ -30,7 +30,7 @@ func RegisterRoutes(r *macaron.Router) {
 				store:          store,
 				store:          store,
 				defaultStorage: lfsutil.Storage(conf.LFS.Storage),
 				defaultStorage: lfsutil.Storage(conf.LFS.Storage),
 				storagers: map[lfsutil.Storage]lfsutil.Storager{
 				storagers: map[lfsutil.Storage]lfsutil.Storager{
-					lfsutil.StorageLocal: &lfsutil.LocalStorage{Root: conf.LFS.ObjectsPath},
+					lfsutil.StorageLocal: &lfsutil.LocalStorage{Root: conf.LFS.ObjectsPath, TempDir: conf.LFS.ObjectsTempPath},
 				},
 				},
 			}
 			}
 			r.Combo("/:oid", verifyOID()).
 			r.Combo("/:oid", verifyOID()).