diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index f77ce032014f3..dff7874ce842e 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -28,6 +28,7 @@ import ( "gitea.dev/models/migrations/v1_25" "gitea.dev/models/migrations/v1_26" "gitea.dev/models/migrations/v1_27" + "gitea.dev/models/migrations/v1_28" "gitea.dev/models/migrations/v1_6" "gitea.dev/models/migrations/v1_7" "gitea.dev/models/migrations/v1_8" @@ -420,6 +421,9 @@ func prepareMigrationTasks() []*migration { newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob), newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns), newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema), + // Gitea 1.27.0 ends at migration ID number 342 (database version 343) + + newMigration(343, "Add creator_id to package blob uploads", v1_28.AddCreatorIDToPackageBlobUpload), } return preparedMigrations } diff --git a/models/migrations/v1_28/main_test.go b/models/migrations/v1_28/main_test.go new file mode 100644 index 0000000000000..7a9dee0fce9d3 --- /dev/null +++ b/models/migrations/v1_28/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "testing" + + "gitea.dev/models/migrations/migrationtest" +) + +func TestMain(m *testing.M) { + migrationtest.MainTest(m) +} diff --git a/models/migrations/v1_28/v343.go b/models/migrations/v1_28/v343.go new file mode 100644 index 0000000000000..c56355d87d2ae --- /dev/null +++ b/models/migrations/v1_28/v343.go @@ -0,0 +1,24 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "gitea.dev/models/db" + + "xorm.io/xorm" +) + +// AddCreatorIDToPackageBlobUpload binds blob upload sessions to the user who +// created them so other authenticated users cannot reuse a leaked UUID. +func AddCreatorIDToPackageBlobUpload(x db.EngineMigration) error { + type PackageBlobUpload struct { + CreatorID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(PackageBlobUpload)) + return err +} diff --git a/models/migrations/v1_28/v343_test.go b/models/migrations/v1_28/v343_test.go new file mode 100644 index 0000000000000..7d50037658d5f --- /dev/null +++ b/models/migrations/v1_28/v343_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "testing" + + "gitea.dev/models/migrations/migrationtest" + + "github.com/stretchr/testify/require" +) + +type packageBlobUploadBeforeV343 struct { + ID string `xorm:"pk"` + BytesReceived int64 `xorm:"NOT NULL DEFAULT 0"` +} + +func (packageBlobUploadBeforeV343) TableName() string { + return "package_blob_upload" +} + +type packageBlobUploadAfterV343 struct { + ID string `xorm:"pk"` + CreatorID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + BytesReceived int64 `xorm:"NOT NULL DEFAULT 0"` +} + +func (packageBlobUploadAfterV343) TableName() string { + return "package_blob_upload" +} + +func Test_AddCreatorIDToPackageBlobUpload(t *testing.T) { + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(packageBlobUploadBeforeV343)) + defer deferable() + + _, err := x.Insert(&packageBlobUploadBeforeV343{ + ID: "test-upload", + BytesReceived: 12, + }) + require.NoError(t, err) + + require.NoError(t, AddCreatorIDToPackageBlobUpload(x)) + + var pbu packageBlobUploadAfterV343 + has, err := x.ID("test-upload").Get(&pbu) + require.NoError(t, err) + require.True(t, has) + require.EqualValues(t, 0, pbu.CreatorID) + require.EqualValues(t, 12, pbu.BytesReceived) +} diff --git a/models/packages/package_blob_upload.go b/models/packages/package_blob_upload.go index 07afd0591b0db..7755c771da97f 100644 --- a/models/packages/package_blob_upload.go +++ b/models/packages/package_blob_upload.go @@ -23,6 +23,7 @@ func init() { // PackageBlobUpload represents a package blob upload type PackageBlobUpload struct { ID string `xorm:"pk"` + CreatorID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` BytesReceived int64 `xorm:"NOT NULL DEFAULT 0"` HashStateBytes []byte `xorm:"BLOB"` CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` @@ -30,11 +31,12 @@ type PackageBlobUpload struct { } // CreateBlobUpload inserts a blob upload -func CreateBlobUpload(ctx context.Context) (*PackageBlobUpload, error) { +func CreateBlobUpload(ctx context.Context, creatorID int64) (*PackageBlobUpload, error) { id := util.CryptoRandomString(25) pbu := &PackageBlobUpload{ - ID: strings.ToLower(id), + ID: strings.ToLower(id), + CreatorID: creatorID, } _, err := db.GetEngine(ctx).Insert(pbu) @@ -42,10 +44,10 @@ func CreateBlobUpload(ctx context.Context) (*PackageBlobUpload, error) { } // GetBlobUploadByID gets a blob upload by id -func GetBlobUploadByID(ctx context.Context, id string) (*PackageBlobUpload, error) { +func GetBlobUploadByID(ctx context.Context, id string, creatorID int64) (*PackageBlobUpload, error) { pbu := &PackageBlobUpload{} - has, err := db.GetEngine(ctx).ID(id).Get(pbu) + has, err := db.GetEngine(ctx).Where("id = ? AND creator_id = ?", id, creatorID).Get(pbu) if err != nil { return nil, err } diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index a293cac08d8a5..5a446a6f6e73e 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -318,7 +318,7 @@ func PostBlobsUploads(ctx *context.Context) { return } - upload, err := packages_model.CreateBlobUpload(ctx) + upload, err := packages_model.CreateBlobUpload(ctx, ctx.Doer.ID) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -336,7 +336,7 @@ func GetBlobsUpload(ctx *context.Context) { image := ctx.PathParam("image") uuid := ctx.PathParam("uuid") - upload, err := packages_model.GetBlobUploadByID(ctx, uuid) + upload, err := packages_model.GetBlobUploadByID(ctx, uuid, ctx.Doer.ID) if err != nil { if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) { apiErrorDefined(ctx, errBlobUploadUnknown) @@ -363,7 +363,7 @@ func GetBlobsUpload(ctx *context.Context) { func PatchBlobsUpload(ctx *context.Context) { image := ctx.PathParam("image") - uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid")) + uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"), ctx.Doer.ID) if err != nil { if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) { apiErrorDefined(ctx, errBlobUploadUnknown) @@ -417,7 +417,7 @@ func PutBlobsUpload(ctx *context.Context) { return } - uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid")) + uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"), ctx.Doer.ID) if err != nil { if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) { apiErrorDefined(ctx, errBlobUploadUnknown) @@ -480,7 +480,7 @@ func PutBlobsUpload(ctx *context.Context) { func DeleteBlobsUpload(ctx *context.Context) { uuid := ctx.PathParam("uuid") - _, err := packages_model.GetBlobUploadByID(ctx, uuid) + _, err := packages_model.GetBlobUploadByID(ctx, uuid, ctx.Doer.ID) if err != nil { if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) { apiErrorDefined(ctx, errBlobUploadUnknown) diff --git a/services/packages/container/blob_uploader.go b/services/packages/container/blob_uploader.go index 8b6e55d56dd1a..e69ff6b4825b8 100644 --- a/services/packages/container/blob_uploader.go +++ b/services/packages/container/blob_uploader.go @@ -39,8 +39,8 @@ func buildFilePath(uploadPath *tempdir.TempDir, id string) string { } // NewBlobUploader creates a new blob uploader for the given id -func NewBlobUploader(ctx context.Context, id string) (*BlobUploader, error) { - model, err := packages_model.GetBlobUploadByID(ctx, id) +func NewBlobUploader(ctx context.Context, id string, creatorID int64) (*BlobUploader, error) { + model, err := packages_model.GetBlobUploadByID(ctx, id, creatorID) if err != nil { return nil, err } diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index 298064de57cd5..a619200f7240f 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -274,13 +274,31 @@ func TestPackageContainer(t *testing.T) { uuid := resp.Header().Get("Docker-Upload-Uuid") assert.NotEmpty(t, uuid) - pbu, err := packages_model.GetBlobUploadByID(t.Context(), uuid) + pbu, err := packages_model.GetBlobUploadByID(t.Context(), uuid, user.ID) assert.NoError(t, err) assert.EqualValues(t, 0, pbu.BytesReceived) uploadURL := resp.Header().Get("Location") assert.NotEmpty(t, uploadURL) + attackerUploadURL := fmt.Sprintf("%sv2/%s/%s/blobs/uploads/%s", setting.AppURL, privateUser.Name, image, uuid) + + req = NewRequest(t, "GET", attackerUploadURL). + AddBasicAuth(privateUser.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "PATCH", attackerUploadURL, bytes.NewReader([]byte("x"))). + AddBasicAuth(privateUser.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "PUT", fmt.Sprintf("%s?digest=%s", attackerUploadURL, blobDigest)). + AddBasicAuth(privateUser.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", attackerUploadURL). + AddBasicAuth(privateUser.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestWithBody(t, "PATCH", setting.AppURL+uploadURL[1:]+"000", bytes.NewReader(blobContent)). AddTokenAuth(userToken) MakeRequest(t, req, http.StatusNotFound) @@ -318,7 +336,7 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, uploadURL, resp.Header().Get("Location")) assert.Equal(t, contentRange, resp.Header().Get("Range")) - pbu, err = packages_model.GetBlobUploadByID(t.Context(), uuid) + pbu, err = packages_model.GetBlobUploadByID(t.Context(), uuid, user.ID) assert.NoError(t, err) assert.EqualValues(t, len(blobContent), pbu.BytesReceived)