diff --git a/models/asymkey/error.go b/models/asymkey/error.go index 5df7beb8cd..6c0db1dc7c 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -276,6 +276,48 @@ func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error { return util.ErrNotExist } +// ErrHTTPSDeployKeyNotExist is returned when a lookup of an HTTPS deploy key +// fails. Either of ID or RepoID may be zero depending on the call site. +type ErrHTTPSDeployKeyNotExist struct { + ID int64 + RepoID int64 +} + +// IsErrHTTPSDeployKeyNotExist checks if an error is an ErrHTTPSDeployKeyNotExist. +func IsErrHTTPSDeployKeyNotExist(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNotExist) + return ok +} + +func (err ErrHTTPSDeployKeyNotExist) Error() string { + return fmt.Sprintf("HTTPS deploy key does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) +} + +func (err ErrHTTPSDeployKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrHTTPSDeployKeyNameAlreadyUsed is returned when creating an HTTPS deploy +// key would collide with an existing name under the same repository. +type ErrHTTPSDeployKeyNameAlreadyUsed struct { + RepoID int64 + Name string +} + +// IsErrHTTPSDeployKeyNameAlreadyUsed checks if an error is an ErrHTTPSDeployKeyNameAlreadyUsed. +func IsErrHTTPSDeployKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrHTTPSDeployKeyNameAlreadyUsed) + return ok +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("HTTPS deploy key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name) +} + +func (err ErrHTTPSDeployKeyNameAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + // ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error. type ErrSSHInvalidTokenSignature struct { Wrapped error diff --git a/models/asymkey/https_deploy_key.go b/models/asymkey/https_deploy_key.go new file mode 100644 index 0000000000..581df72369 --- /dev/null +++ b/models/asymkey/https_deploy_key.go @@ -0,0 +1,183 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// HTTPSDeployKey is a per-repository credential that authenticates Git +// operations over HTTPS without being tied to a user account. It mirrors the +// semantics of the SSH DeployKey (RepoID + Mode) but carries a hashed bearer +// token instead of a public-key fingerprint. +type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"` + + // Token holds the plaintext token only on the row returned from a creation + // call. It is never read from or written to the database. + Token string `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + HasRecentActivity bool `xorm:"-"` + HasUsed bool `xorm:"-"` +} + +// AfterLoad populates derived display fields after XORM reads a row. +func (k *HTTPSDeployKey) AfterLoad() { + k.HasUsed = k.UpdatedUnix > k.CreatedUnix + k.HasRecentActivity = k.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +// IsReadOnly reports whether the key grants only read access to its +// repository. +func (k *HTTPSDeployKey) IsReadOnly() bool { + return k.Mode == perm.AccessModeRead +} + +func init() { + db.RegisterModel(new(HTTPSDeployKey)) +} + +// tokenIsValidFormat reports whether s looks like a serialized deploy token +// (40 lowercase hex chars). We reject everything else early so that an +// incidental basic-auth password can never collide with the token lookup. +func tokenIsValidFormat(s string) bool { + if len(s) != 40 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// AddHTTPSDeployKey creates a new HTTPS deploy key for the given repository +// and returns both the stored row and the plaintext token. The plaintext is +// only returned here; callers must surface it to the user exactly once. +func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly bool) (*HTTPSDeployKey, string, error) { + if name == "" { + return nil, "", util.NewInvalidArgumentErrorf("deploy key name must not be empty") + } + + has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey)) + if err != nil { + return nil, "", err + } + if has { + return nil, "", ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + + salt := util.CryptoRandomString(10) + tokenBytes := util.CryptoRandomBytes(20) + token := hex.EncodeToString(tokenBytes) + + mode := perm.AccessModeRead + if !readOnly { + mode = perm.AccessModeWrite + } + + key := &HTTPSDeployKey{ + RepoID: repoID, + Name: name, + TokenHash: auth_model.HashToken(token, salt), + TokenSalt: salt, + TokenLastEight: token[len(token)-8:], + Mode: mode, + } + if err := db.Insert(ctx, key); err != nil { + return nil, "", err + } + + key.Token = token + return key, token, nil +} + +// GetHTTPSDeployKeyByID loads a single HTTPS deploy key by its primary key. +func GetHTTPSDeployKeyByID(ctx context.Context, id int64) (*HTTPSDeployKey, error) { + key, exist, err := db.GetByID[HTTPSDeployKey](ctx, id) + if err != nil { + return nil, err + } + if !exist { + return nil, ErrHTTPSDeployKeyNotExist{ID: id} + } + return key, nil +} + +// ListHTTPSDeployKeysOptions filters a list query. +type ListHTTPSDeployKeysOptions struct { + db.ListOptions + RepoID int64 +} + +// ToConds implements db.FindOptions. +func (opt ListHTTPSDeployKeysOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opt.RepoID != 0 { + cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) + } + return cond +} + +// DeleteHTTPSDeployKey removes the key identified by (repoID, id). The repo +// scope is required so that a caller in one repository cannot drop a token +// belonging to another. +func DeleteHTTPSDeployKey(ctx context.Context, repoID, id int64) error { + cnt, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).ID(id).Delete(new(HTTPSDeployKey)) + if err != nil { + return err + } + if cnt == 0 { + return ErrHTTPSDeployKeyNotExist{ID: id, RepoID: repoID} + } + return nil +} + +// VerifyHTTPSDeployToken returns the key that the given plaintext token +// authenticates, or ErrHTTPSDeployKeyNotExist if no key matches. +func VerifyHTTPSDeployToken(ctx context.Context, token string) (*HTTPSDeployKey, error) { + if !tokenIsValidFormat(token) { + return nil, ErrHTTPSDeployKeyNotExist{} + } + + lastEight := token[len(token)-8:] + var candidates []HTTPSDeployKey + if err := db.GetEngine(ctx).Where("token_last_eight = ?", lastEight).Find(&candidates); err != nil { + return nil, err + } + + for i := range candidates { + expected := auth_model.HashToken(token, candidates[i].TokenSalt) + if subtle.ConstantTimeCompare([]byte(candidates[i].TokenHash), []byte(expected)) == 1 { + k := candidates[i] + k.UpdatedUnix = timeutil.TimeStampNow() + if _, err := db.GetEngine(ctx).ID(k.ID).Cols("updated_unix").Update(&k); err != nil { + return nil, err + } + return &k, nil + } + } + return nil, ErrHTTPSDeployKeyNotExist{} +} diff --git a/models/asymkey/https_deploy_key_test.go b/models/asymkey/https_deploy_key_test.go new file mode 100644 index 0000000000..a18770279c --- /dev/null +++ b/models/asymkey/https_deploy_key_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "ci-readonly", true) + require.NoError(t, err) + require.NotNil(t, key) + + assert.Equal(t, int64(1), key.RepoID) + assert.Equal(t, "ci-readonly", key.Name) + assert.True(t, key.IsReadOnly()) + assert.Len(t, token, 40, "token should be a 40-char hex string") + for _, r := range token { + ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') + assert.True(t, ok, "token contains non-hex char %q", r) + } + + got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.TokenHash, got.TokenHash) + assert.Empty(t, got.Token, "plaintext token must not be persisted") +} + +func TestAddHTTPSDeployKey_NameUnique(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.NoError(t, err) + + _, _, err = AddHTTPSDeployKey(t.Context(), 1, "dup", false) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(err), + "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", err, err) + + // Same name on a different repo is fine. + _, _, err = AddHTTPSDeployKey(t.Context(), 2, "dup", false) + require.NoError(t, err) +} + +func TestListHTTPSDeployKeys(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "a", true) + require.NoError(t, err) + _, _, err = AddHTTPSDeployKey(t.Context(), 1, "b", false) + require.NoError(t, err) + _, _, err = AddHTTPSDeployKey(t.Context(), 2, "c", true) + require.NoError(t, err) + + keys, err := db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Len(t, keys, 2) + + keys, err = db.Find[HTTPSDeployKey](t.Context(), + ListHTTPSDeployKeysOptions{RepoID: 2}) + require.NoError(t, err) + assert.Len(t, keys, 1) +} + +func TestDeleteHTTPSDeployKey(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, _, err := AddHTTPSDeployKey(t.Context(), 1, "to-delete", true) + require.NoError(t, err) + + require.NoError(t, DeleteHTTPSDeployKey(t.Context(), 1, key.ID)) + + _, err = GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + // Deleting a key that belongs to a different repo must fail cleanly. + key, _, err = AddHTTPSDeployKey(t.Context(), 1, "stays", true) + require.NoError(t, err) + err = DeleteHTTPSDeployKey(t.Context(), 2, key.ID) + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) +} + +func TestVerifyHTTPSDeployToken(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "verify", false) + require.NoError(t, err) + + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) + assert.Equal(t, key.RepoID, got.RepoID) + + _, err = VerifyHTTPSDeployToken(t.Context(), "0000000000000000000000000000000000000000") + require.Error(t, err) + assert.True(t, IsErrHTTPSDeployKeyNotExist(err)) + + _, err = VerifyHTTPSDeployToken(t.Context(), "") + require.Error(t, err) + + _, err = VerifyHTTPSDeployToken(t.Context(), "not-hex") + require.Error(t, err) +} diff --git a/models/asymkey/main_test.go b/models/asymkey/main_test.go index be71f848d9..3613f5bf3c 100644 --- a/models/asymkey/main_test.go +++ b/models/asymkey/main_test.go @@ -15,6 +15,7 @@ func TestMain(m *testing.M) { "gpg_key.yml", "public_key.yml", "deploy_key.yml", + "https_deploy_key.yml", "gpg_key_import.yml", "user.yml", "email_address.yml", diff --git a/models/fixtures/https_deploy_key.yml b/models/fixtures/https_deploy_key.yml new file mode 100644 index 0000000000..0d1b2f0098 --- /dev/null +++ b/models/fixtures/https_deploy_key.yml @@ -0,0 +1,3 @@ +[] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..78b94e25fd 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add https_deploy_key table", v1_27.AddHTTPSDeployKeyTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..26794b9cb1 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddHTTPSDeployKeyTable introduces the per-repository HTTPS deploy-key +// credential table. The shape here must stay in lock-step with the +// HTTPSDeployKey struct in models/asymkey. +func AddHTTPSDeployKeyTable(x *xorm.Engine) error { + type HTTPSDeployKey struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + Name string `xorm:"UNIQUE(s) NOT NULL"` + TokenHash string `xorm:"UNIQUE NOT NULL"` + TokenSalt string `xorm:"NOT NULL"` + TokenLastEight string `xorm:"INDEX"` + Mode int `xorm:"NOT NULL DEFAULT 1"` + CreatedUnix int64 `xorm:"INDEX created"` + UpdatedUnix int64 `xorm:"INDEX updated"` + } + return x.Sync(new(HTTPSDeployKey)) +} diff --git a/models/migrations/v1_27/v332_test.go b/models/migrations/v1_27/v332_test.go new file mode 100644 index 0000000000..392bcfa690 --- /dev/null +++ b/models/migrations/v1_27/v332_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/migrationtest" + + "github.com/stretchr/testify/assert" +) + +func Test_AddHTTPSDeployKeyTable(t *testing.T) { + // Start from an empty DB — nothing depends on prior migration state. + x, deferable := migrationtest.PrepareTestEnv(t, 0) + defer deferable() + if x == nil || t.Failed() { + return + } + + if err := AddHTTPSDeployKeyTable(x); err != nil { + assert.NoError(t, err) + return + } + + type HTTPSDeployKey struct { + ID int64 + RepoID int64 + Name string + TokenHash string + TokenSalt string + TokenLastEight string + Mode int + CreatedUnix int64 + UpdatedUnix int64 + } + + _, err := x.Insert(&HTTPSDeployKey{ + RepoID: 1, + Name: "migration-smoke", + TokenHash: "hash", + TokenSalt: "salt", + TokenLastEight: "abcd1234", + Mode: 1, + }) + assert.NoError(t, err) + + count, err := x.Count(&HTTPSDeployKey{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 47fcac7ae7..df91e76a78 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2380,6 +2380,11 @@ "repo.settings.deploy_key_deletion": "Remove Deploy Key", "repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?", "repo.settings.deploy_key_deletion_success": "The deploy key has been removed.", + "repo.settings.https_deploy_keys": "HTTPS Deploy Keys", + "repo.settings.https_deploy_key_desc": "HTTPS deploy keys are per-repository tokens that can be used as the password for Git operations over HTTPS. The token is shown only once, right after it is created.", + "repo.settings.add_https_deploy_key": "Add HTTPS Deploy Key", + "repo.settings.no_https_deploy_keys": "There are no HTTPS deploy keys yet.", + "repo.settings.https_deploy_key_created": "The HTTPS deploy key \"%s\" has been created. Copy the token now — it will not be shown again: %s", "repo.settings.branches": "Branches", "repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch.save_rule": "Save Rule", diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 928c78a61f..8673998f9c 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -157,6 +157,48 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { return nil } + // HTTPS deploy tokens authenticate as the repo owner but must be + // constrained to the single bound repository and mode. We do this + // before the other basic-auth checks so a deploy token bypasses + // PAT-scope verification, 2FA, and unit-permission lookup — those + // all assume the doer's own permissions should drive the outcome, + // which is not what a deploy token is supposed to do. + if ctx.Data["IsDeployToken"] == true { + boundRepoID, _ := ctx.Data["DeployTokenRepoID"].(int64) + if !repoExist || boundRepoID != repo.ID { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return nil + } + tokenMode, _ := ctx.Data["DeployTokenMode"].(perm.AccessMode) + if accessMode > tokenMode { + ctx.PlainText(http.StatusForbidden, "Deploy token does not grant write access") + return nil + } + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return nil + } + // Enforce the per-unit enablement check. Without this a deploy + // token could push to or clone a wiki whose TypeWiki unit has + // been disabled, bypassing the same check applied to the + // user-password and PAT paths further down. + if isWiki { + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return nil + } + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return nil + } + } + var environ []string + if !isPull { + environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) + } + return &serviceHandler{serviceType, repo, isWiki, environ} + } + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) if ctx.Written() { return nil diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index ab54b8ccf5..95a0f91ffe 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -29,6 +29,14 @@ func DeployKeys(ctx *context.Context) { } ctx.Data["Deploykeys"] = keys + httpsKeys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("ListHTTPSDeployKeys", err) + return + } + ctx.Data["HTTPSDeploykeys"] = httpsKeys + ctx.HTML(http.StatusOK, tplDeployKeys) } @@ -107,3 +115,46 @@ func DeleteDeployKey(ctx *context.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") } + +// HTTPSDeployKeysPost handles creation of an HTTPS deploy key for the current +// repository. The plaintext token is surfaced to the user exactly once via +// the flash system. +func HTTPSDeployKeysPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.HTTPSDeployKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + ctx.Data["PageIsSettingsKeys"] = true + + if ctx.HasError() { + DeployKeys(ctx) + return + } + + key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, !form.IsWritable) + if err != nil { + switch { + case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): + ctx.Flash.Error(ctx.Tr("repo.settings.key_name_used")) + default: + ctx.ServerError("AddHTTPSDeployKey", err) + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + return + } + + log.Trace("HTTPS deploy key added: %d", key.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.https_deploy_key_created", key.Name, token)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + +// DeleteHTTPSDeployKey deletes a single HTTPS deploy key scoped to the +// current repository. +func DeleteHTTPSDeployKey(ctx *context.Context) { + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteHTTPSDeployKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) + } + + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") +} diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 154d01fda4..52c4087ca0 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -5,9 +5,11 @@ package setting import ( "net/http" + "strconv" "testing" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -431,3 +433,43 @@ func TestHandleSettingsPostMirrorPreservesExistingUsername(t *testing.T) { require.True(t, ok) assert.Equal(t, "updated-password", password) } + +func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + + web.SetForm(ctx, &forms.HTTPSDeployKeyForm{ + Title: "ci-writable", + IsWritable: true, + }) + HTTPSDeployKeysPost(ctx) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) + + keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, "ci-writable", keys[0].Name) + assert.False(t, keys[0].IsReadOnly()) + + // The plaintext token must be stashed on the flash for one-time display. + flash := ctx.Flash + require.NotNil(t, flash) + assert.Contains(t, flash.SuccessMsg, "ci-writable") + + // Now delete it. + delCtx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https/delete") + contexttest.LoadUser(t, delCtx, 2) + contexttest.LoadRepo(t, delCtx, 1) + delCtx.Req.Form.Set("id", strconv.FormatInt(keys[0].ID, 10)) + DeleteHTTPSDeployKey(delCtx) + assert.NotEqual(t, http.StatusInternalServerError, delCtx.Resp.WrittenStatus()) + + keys, err = db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Empty(t, keys) +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..9ec49d179c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { type AuthMiddleware struct { AllowOAuth2 types.PreMiddlewareProvider AllowBasic types.PreMiddlewareProvider + AllowDeployToken types.PreMiddlewareProvider MiddlewareHandler func(*context.Context) } func newWebAuthMiddleware() *AuthMiddleware { type keyAllowOAuth2 struct{} type keyAllowBasic struct{} + type keyAllowDeployToken struct{} webAuth := &AuthMiddleware{} middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { @@ -114,11 +116,17 @@ func newWebAuthMiddleware() *AuthMiddleware { webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) + // AllowDeployToken narrows HTTPS deploy-token authentication to the + // request contexts that need it (git smart-HTTP). Without this gate, a + // deploy token would authenticate as the repo owner on every Basic-auth + // endpoint — REST API, attachments, feeds — and act as a full-owner PAT. + webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true) enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext()) webAuth.MiddlewareHandler = func(ctx *context.Context) { allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true + allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true group := auth_service.NewGroup() @@ -127,6 +135,12 @@ func newWebAuthMiddleware() *AuthMiddleware { if allowOAuth2 { group.Add(&auth_service.OAuth2{}) } + if allowDeployToken { + // Must come before Basic so a valid deploy token short-circuits + // the fall-through into username/password sign-in (which would + // reject the 40-hex token and leave the caller with a 401). + group.Add(&auth_service.HTTPSDeployToken{}) + } if allowBasic { group.Add(&auth_service.Basic{}) } @@ -1208,6 +1222,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo("").Get(repo_setting.DeployKeys). Post(web.Bind(forms.AddKeyForm{}), repo_setting.DeployKeysPost) m.Post("/delete", repo_setting.DeleteDeployKey) + m.Post("/https", web.Bind(forms.HTTPSDeployKeyForm{}), repo_setting.HTTPSDeployKeysPost) + m.Post("/https/delete", repo_setting.DeleteHTTPSDeployKey) }) m.Group("/lfs", func() { @@ -1738,7 +1754,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // Some users want to use "web-based git client" to access Gitea's repositories, // so the CORS handler and OPTIONS method are used. // pattern: "/{username}/{reponame}/{git-paths}": git http support - addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) + addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb()) m.Group("/notifications", func() { m.Get("", user.Notifications) diff --git a/services/auth/basic.go b/services/auth/basic.go index e8a4a2e8f7..4842301865 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -24,10 +24,11 @@ var ( // BasicMethodName is the constant name of the basic authentication method const ( - BasicMethodName = "basic" - AccessTokenMethodName = "access_token" - OAuth2TokenMethodName = "oauth2_token" - ActionTokenMethodName = "action_token" + BasicMethodName = "basic" + AccessTokenMethodName = "access_token" + OAuth2TokenMethodName = "oauth2_token" + ActionTokenMethodName = "action_token" + HTTPSDeployTokenMethodName = "https_deploy_token" ) // Basic implements the Auth interface and authenticates requests (API requests diff --git a/services/auth/https_deploy_token.go b/services/auth/https_deploy_token.go new file mode 100644 index 0000000000..9b81bfcb63 --- /dev/null +++ b/services/auth/https_deploy_token.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/httpauth" + "code.gitea.io/gitea/modules/log" +) + +// Ensure the struct implements the interface. +var _ Method = &HTTPSDeployToken{} + +// HTTPSDeployToken authenticates HTTP Basic-auth credentials whose token half +// matches a row in the https_deploy_key table. It is deliberately *not* +// registered globally: callers add it to an auth group only for request +// contexts where a repo-scoped bearer token makes sense (currently the git +// smart-HTTP router). See routers/web/web.go for the gating flag. +type HTTPSDeployToken struct{} + +// Name returns the name of this auth method. +func (h *HTTPSDeployToken) Name() string { + return HTTPSDeployTokenMethodName +} + +// Verify parses the Basic-auth header, resolves the token to an +// HTTPSDeployKey, and returns the bound repository owner. The deploy-key +// metadata is stashed on the data store so downstream permission logic can +// constrain the request to the bound repo and access mode. +func (h *HTTPSDeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) { + authToken := extractBasicAuthToken(req) + if authToken == "" { + return nil, nil //nolint:nilnil // the auth method is not applicable + } + + key, err := asymkey_model.VerifyHTTPSDeployToken(req.Context(), authToken) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + return nil, nil //nolint:nilnil // not our token — fall through to regular basic auth + } + return nil, err + } + + repo, err := repo_model.GetRepositoryByID(req.Context(), key.RepoID) + if err != nil { + log.Error("HTTPSDeployToken: GetRepositoryByID(%d): %v", key.RepoID, err) + return nil, err + } + if err := repo.LoadOwner(req.Context()); err != nil { + log.Error("HTTPSDeployToken: LoadOwner for repo %d: %v", repo.ID, err) + return nil, err + } + + log.Trace("HTTPSDeployToken: valid HTTPS deploy key for repo[%d]", repo.ID) + store.GetData()["LoginMethod"] = HTTPSDeployTokenMethodName + store.GetData()["IsDeployToken"] = true + store.GetData()["DeployTokenID"] = key.ID + store.GetData()["DeployTokenRepoID"] = key.RepoID + store.GetData()["DeployTokenMode"] = key.Mode + return repo.Owner, nil +} + +// extractBasicAuthToken pulls the credential string out of an HTTP Basic +// Authorization header. It returns the password half when present (the +// conventional token-in-password pattern used by git credential helpers) and +// otherwise the username half. Returns "" if no Basic header is present. +func extractBasicAuthToken(req *http.Request) string { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { + return "" + } + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { + return "" + } + if parsed.BasicAuth.Password != "" && parsed.BasicAuth.Password != "x-oauth-basic" { + return parsed.BasicAuth.Password + } + return parsed.BasicAuth.Username +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..c2a0bab95f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -734,3 +734,26 @@ func (f *AddTimeManuallyForm) Validate(req *http.Request, errs binding.Errors) b type SaveTopicForm struct { Topics []string `binding:"topics;Required;"` } + +// DeadlineForm hold the validation rules for deadlines +type DeadlineForm struct { + DateString string `form:"date" binding:"Required;Size(10)"` +} + +// Validate validates the fields +func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + +// HTTPSDeployKeyForm form for adding an HTTPS deploy key to a repository. +type HTTPSDeployKeyForm struct { + Title string `binding:"Required;MaxSize(50)"` + IsWritable bool +} + +// Validate validates the fields +func (f *HTTPSDeployKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index c4bffbbf08..39b280f220 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -77,4 +77,71 @@ {{template "base/modal_actions_confirm" .}} +
+

+ {{ctx.Locale.Tr "repo.settings.https_deploy_keys"}} +
+ +
+

+
+
+
+
+ {{ctx.Locale.Tr "repo.settings.https_deploy_key_desc"}} +
+
+ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.is_writable_info"}} +
+
+ + +
+
+ {{if .HTTPSDeploykeys}} +
+ {{range .HTTPSDeploykeys}} +
+
+ {{svg "octicon-key" 32}} +
+
+
{{.Name}}
+
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} - {{ctx.Locale.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}} +
+
+
+ +
+
+ {{end}} +
+ {{else}} + {{ctx.Locale.Tr "repo.settings.no_https_deploy_keys"}} + {{end}} +
+
+ + + {{template "repo/settings/layout_footer" .}} diff --git a/tests/integration/https_deploy_key_test.go b/tests/integration/https_deploy_key_test.go new file mode 100644 index 0000000000..883794221e --- /dev/null +++ b/tests/integration/https_deploy_key_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// addTestHTTPSDeployKey is a small helper that creates an HTTPS deploy key in +// the test database and returns the plaintext token. It assumes the fixtures +// already hold a repo with the given ID. +func addTestHTTPSDeployKey(t *testing.T, repoID int64, name string, readOnly bool) string { + t.Helper() + _, token, err := asymkey_model.AddHTTPSDeployKey(t.Context(), repoID, name, readOnly) + require.NoError(t, err) + return token +} + +func TestHTTPSDeployKeyClone_Read(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-clone-read", true) + + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "service=git-upload-pack") +} + +func TestHTTPSDeployKeyPush_ReadOnlyDenied(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-push-denied", true) + + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestHTTPSDeployKeyPush_WriteAllowed(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "https-push-allowed", false) + + // `GET info/refs?service=git-receive-pack` is the same auth path a + // real push would hit first; a 200 means the server would accept the + // push as far as auth is concerned. + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-receive-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "service=git-receive-pack") +} + +func TestHTTPSDeployKeyCrossRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + token := addTestHTTPSDeployKey(t, repo1.ID, "https-cross-repo", false) + + req := NewRequest(t, "GET", "/"+repo2.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", token) + // A cross-repo token should be treated exactly like a missing credential + // for repo2 — the server must not leak whether the token is otherwise + // valid. + MakeRequest(t, req, http.StatusNotFound) +} + +func TestHTTPSDeployKeyInvalidToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // 40 hex chars that do not correspond to any stored token: the server + // must fall through to Basic auth which then rejects it. + req := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + req.Request.SetBasicAuth("x-deploy-token", "0123456789abcdef0123456789abcdef01234567") + MakeRequest(t, req, http.StatusUnauthorized) +} + +// TestHTTPSDeployKeyScopedToGitHTTP asserts that a deploy token does NOT +// authenticate on non-git Basic-auth endpoints. Historically the token check +// lived inside auth.Basic.VerifyAuthToken, which made the token behave like a +// full owner-scoped PAT on the REST API, attachments, feeds, etc. +func TestHTTPSDeployKeyScopedToGitHTTP(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + token := addTestHTTPSDeployKey(t, repo.ID, "scope-guard", false) + + // The token works on a git-HTTP path… + gitReq := NewRequest(t, "GET", "/"+repo.FullName()+"/info/refs?service=git-upload-pack") + gitReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, gitReq, http.StatusOK) + + // …but the same token on the REST API must NOT authenticate. The API + // layer only knows user/PAT credentials, so the request is rejected as + // an invalid credential (401) rather than silently admitted as the + // repo owner. + apiReq := NewRequest(t, "GET", "/api/v1/repos/"+repo.FullName()) + apiReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, apiReq, http.StatusUnauthorized) +} + +// TestHTTPSDeployKeyRespectsDisabledWikiUnit asserts that a deploy token +// cannot reach the wiki of a repository whose wiki unit has been disabled. +// Before the fix the deploy-token branch in httpBase short-circuited before +// the unit-enablement check that runs for password / PAT paths. +func TestHTTPSDeployKeyRespectsDisabledWikiUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, []unit.Type{unit.TypeWiki})) + + token := addTestHTTPSDeployKey(t, repo.ID, "wiki-guard", false) + + wikiReq := NewRequest(t, "GET", "/"+repo.FullName()+".wiki/info/refs?service=git-upload-pack") + wikiReq.Request.SetBasicAuth("x-deploy-token", token) + MakeRequest(t, wikiReq, http.StatusForbidden) +}