From ec7608d35594bade65b83c42266a6f34511ca74d Mon Sep 17 00:00:00 2001 From: Claude Mythos Date: Wed, 6 May 2026 00:44:00 +0800 Subject: [PATCH 1/2] feat: add HTTPS deploy keys for repository authentication Add per-repository HTTPS deploy key support, allowing authentication for git HTTPS operations without user accounts. This includes: - New https_deploy_key table and migration (v332) - HTTPSDeployKey model with token hashing and CRUD operations - Git HTTP authentication middleware for deploy tokens - UI for managing HTTPS deploy keys in repository settings - Scope enforcement (read-only vs writable, wiki unit check) - Integration tests for clone/push authorization Fixes #2051 Co-Authored-By: Claude Mythos --- models/asymkey/error.go | 42 +++++ models/asymkey/https_deploy_key.go | 183 +++++++++++++++++++++ models/asymkey/https_deploy_key_test.go | 116 +++++++++++++ models/asymkey/main_test.go | 1 + models/fixtures/https_deploy_key.yml | 3 + models/migrations/migrations.go | 1 + models/migrations/v1_27/v332.go | 26 +++ models/migrations/v1_27/v332_test.go | 52 ++++++ options/locale/locale_en-US.json | 5 + routers/web/repo/githttp.go | 42 +++++ routers/web/repo/setting/deploy_key.go | 51 ++++++ routers/web/repo/setting/settings_test.go | 42 +++++ routers/web/web.go | 18 +- services/auth/basic.go | 9 +- services/auth/https_deploy_token.go | 85 ++++++++++ services/forms/repo_form.go | 23 +++ templates/repo/settings/deploy_keys.tmpl | 67 ++++++++ tests/integration/https_deploy_key_test.go | 135 +++++++++++++++ 18 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 models/asymkey/https_deploy_key.go create mode 100644 models/asymkey/https_deploy_key_test.go create mode 100644 models/fixtures/https_deploy_key.yml create mode 100644 models/migrations/v1_27/v332.go create mode 100644 models/migrations/v1_27/v332_test.go create mode 100644 services/auth/https_deploy_token.go create mode 100644 tests/integration/https_deploy_key_test.go 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) +} From 3a5148e450d02059a9ac8fc572a8ea4eb36ff621 Mon Sep 17 00:00:00 2001 From: Claude Mythos Date: Wed, 6 May 2026 20:53:50 +0800 Subject: [PATCH 2/2] feat: harden HTTPS deploy keys and add REST API Address remaining review issues for HTTPS deploy keys: - Extract HTTPSDeployKeyTokenLength constant for token validation - Fix race condition in AddHTTPSDeployKey by moving duplicate check inside transaction - Add timing-resistant dummy hash for invalid-format tokens - Set explicit timestamps (NoAutoTime) in AddHTTPSDeployKey - Add REST API CRUD endpoints (/repos/{owner}/{repo}/https_keys) with integration tests - Switch HTTPS deploy key creation to inline token rendering via ctx.Data instead of cookie-backed flash (security improvement) - Improve log.Trace for SSH and HTTPS deploy key create/delete handlers (operator, repo, key name, key ID) - Remove unused DeadlineForm struct - Fix "bearer token" terminology in auth method comment Co-Authored-By: Claude Mythos --- models/asymkey/https_deploy_key.go | 38 ++- models/asymkey/https_deploy_key_test.go | 318 +++++++++++++++++++++- modules/structs/repo_key.go | 42 +++ options/locale/locale_en-US.json | 1 + routers/api/v1/api.go | 6 + routers/api/v1/repo/https_key.go | 242 ++++++++++++++++ routers/api/v1/swagger/key.go | 14 + routers/api/v1/swagger/options.go | 3 + routers/web/repo/setting/deploy_key.go | 46 +++- routers/web/repo/setting/settings_test.go | 41 ++- services/auth/https_deploy_token.go | 2 +- services/convert/convert.go | 15 + services/forms/repo_form.go | 11 - templates/repo/settings/deploy_keys.tmpl | 14 +- templates/swagger/v1_json.tmpl | 278 +++++++++++++++++++ templates/swagger/v1_openapi3_json.tmpl | 302 ++++++++++++++++++++ tests/integration/api_https_keys_test.go | 201 ++++++++++++++ 17 files changed, 1522 insertions(+), 52 deletions(-) create mode 100644 routers/api/v1/repo/https_key.go create mode 100644 tests/integration/api_https_keys_test.go diff --git a/models/asymkey/https_deploy_key.go b/models/asymkey/https_deploy_key.go index 581df72369..334e491f85 100644 --- a/models/asymkey/https_deploy_key.go +++ b/models/asymkey/https_deploy_key.go @@ -18,6 +18,10 @@ import ( "xorm.io/builder" ) +// HTTPSDeployKeyTokenLength is the expected length of a hex-encoded deploy +// token (20 random bytes → 40 hex chars). +const HTTPSDeployKeyTokenLength = 40 + // 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 @@ -61,7 +65,7 @@ func init() { // (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 { + if len(s) != HTTPSDeployKeyTokenLength { return false } for i := 0; i < len(s); i++ { @@ -81,14 +85,6 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly 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) @@ -98,6 +94,7 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly mode = perm.AccessModeWrite } + now := timeutil.TimeStampNow() key := &HTTPSDeployKey{ RepoID: repoID, Name: name, @@ -105,9 +102,27 @@ func AddHTTPSDeployKey(ctx context.Context, repoID int64, name string, readOnly TokenSalt: salt, TokenLastEight: token[len(token)-8:], Mode: mode, + CreatedUnix: now, + UpdatedUnix: now, } - if err := db.Insert(ctx, key); err != nil { - return nil, "", err + + insertErr := db.WithTx(ctx, func(ctx context.Context) error { + has, err := db.GetEngine(ctx).Where("repo_id = ? AND name = ?", repoID, name).Exist(new(HTTPSDeployKey)) + if err != nil { + return err + } + if has { + return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + + _, err = db.GetEngine(ctx).NoAutoTime().Insert(key) + if err != nil { + return ErrHTTPSDeployKeyNameAlreadyUsed{RepoID: repoID, Name: name} + } + return nil + }) + if insertErr != nil { + return nil, "", insertErr } key.Token = token @@ -159,6 +174,7 @@ func DeleteHTTPSDeployKey(ctx context.Context, repoID, id int64) error { // authenticates, or ErrHTTPSDeployKeyNotExist if no key matches. func VerifyHTTPSDeployToken(ctx context.Context, token string) (*HTTPSDeployKey, error) { if !tokenIsValidFormat(token) { + _ = auth_model.HashToken(token, util.CryptoRandomString(10)) // dummy to prevent timing side-channel return nil, ErrHTTPSDeployKeyNotExist{} } diff --git a/models/asymkey/https_deploy_key_test.go b/models/asymkey/https_deploy_key_test.go index a18770279c..40cc56b5bc 100644 --- a/models/asymkey/https_deploy_key_test.go +++ b/models/asymkey/https_deploy_key_test.go @@ -4,15 +4,62 @@ package asymkey import ( + "context" + "crypto/rand" + "encoding/hex" + "strings" "testing" + "time" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestHTTPSDeployKeyTokenLength(t *testing.T) { + // Verify the token length constant is used for validation. + assert.True(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength+1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("g", HTTPSDeployKeyTokenLength))) +} + +func TestTokenIsValidFormatEdgeCases(t *testing.T) { + // Valid: 40 lowercase hex chars + assert.True(t, tokenIsValidFormat("aaaaaaaaaa000000000011111111112222222222")) + + // Reject uppercase hex + assert.False(t, tokenIsValidFormat(strings.Repeat("A", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat("AAAAAAAAAA000000000011111111112222222222")) + + // Reject whitespace + assert.False(t, tokenIsValidFormat(" "+strings.Repeat("a", HTTPSDeployKeyTokenLength-1))) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength-1)+" ")) + assert.False(t, tokenIsValidFormat(strings.Repeat("a", HTTPSDeployKeyTokenLength/2)+" "+strings.Repeat("a", HTTPSDeployKeyTokenLength/2-1))) + + // Reject empty string + assert.False(t, tokenIsValidFormat("")) + + // Reject special characters + assert.False(t, tokenIsValidFormat(strings.Repeat("!", HTTPSDeployKeyTokenLength))) + assert.False(t, tokenIsValidFormat(strings.Repeat("@", HTTPSDeployKeyTokenLength))) + + // Reject mixed case + assert.False(t, tokenIsValidFormat("AaAaAaAaAa0000000000111111111122222222")) +} + +func TestAddHTTPSDeployKeyEmptyName(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, _, err := AddHTTPSDeployKey(t.Context(), 1, "", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + func TestAddHTTPSDeployKey(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) @@ -23,24 +70,37 @@ func TestAddHTTPSDeployKey(t *testing.T) { 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") + assert.Len(t, token, HTTPSDeployKeyTokenLength, "token should match HTTPSDeployKeyTokenLength") for _, r := range token { ok := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') - assert.True(t, ok, "token contains non-hex char %q", r) + assert.Truef(t, ok, "token contains non-hex char %q", r) } + // Verify TokenHash is non-empty (token was hashed) + assert.NotEmpty(t, key.TokenHash, "TokenHash must be set after create") + + // Verify TokenLastEight matches the actual token suffix + assert.Equal(t, token[len(token)-8:], key.TokenLastEight, "TokenLastEight must match token suffix") + + // Verify timestamps are set + assert.NotZero(t, key.CreatedUnix, "CreatedUnix must be set after create") + assert.NotZero(t, key.UpdatedUnix, "UpdatedUnix must be set after create") + 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") + + _ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID) } func TestAddHTTPSDeployKey_NameUnique(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - _, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false) + keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "dup", false) require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID) _, _, err = AddHTTPSDeployKey(t.Context(), 1, "dup", false) require.Error(t, err) @@ -48,29 +108,44 @@ func TestAddHTTPSDeployKey_NameUnique(t *testing.T) { "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", err, err) // Same name on a different repo is fine. - _, _, err = AddHTTPSDeployKey(t.Context(), 2, "dup", false) + keyB, _, err := AddHTTPSDeployKey(t.Context(), 2, "dup", false) require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 2, keyB.ID) } func TestListHTTPSDeployKeys(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) - _, _, err := AddHTTPSDeployKey(t.Context(), 1, "a", true) + keyA, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-a", true) require.NoError(t, err) - _, _, err = AddHTTPSDeployKey(t.Context(), 1, "b", false) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyA.ID) + + keyB, _, err := AddHTTPSDeployKey(t.Context(), 1, "t-list-b", false) require.NoError(t, err) - _, _, err = AddHTTPSDeployKey(t.Context(), 2, "c", true) + defer DeleteHTTPSDeployKey(t.Context(), 1, keyB.ID) + + keyC, _, err := AddHTTPSDeployKey(t.Context(), 2, "t-list-c", true) require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 2, keyC.ID) keys, err := db.Find[HTTPSDeployKey](t.Context(), ListHTTPSDeployKeysOptions{RepoID: 1}) require.NoError(t, err) - assert.Len(t, keys, 2) + names := make(map[string]struct{}, len(keys)) + for _, k := range keys { + names[k.Name] = struct{}{} + } + assert.Contains(t, names, "t-list-a") + assert.Contains(t, names, "t-list-b") keys, err = db.Find[HTTPSDeployKey](t.Context(), ListHTTPSDeployKeysOptions{RepoID: 2}) require.NoError(t, err) - assert.Len(t, keys, 1) + names = make(map[string]struct{}, len(keys)) + for _, k := range keys { + names[k.Name] = struct{}{} + } + assert.Contains(t, names, "t-list-c") } func TestDeleteHTTPSDeployKey(t *testing.T) { @@ -113,4 +188,229 @@ func TestVerifyHTTPSDeployToken(t *testing.T) { _, err = VerifyHTTPSDeployToken(t.Context(), "not-hex") require.Error(t, err) + + _ = DeleteHTTPSDeployKey(t.Context(), 1, key.ID) +} + +func TestHTTPSDeployKeyAfterLoad(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "afterload-test", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Fresh key: CreatedUnix == UpdatedUnix, so HasUsed should be false + got, err := GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.False(t, got.HasUsed, "fresh key should not have HasUsed") + assert.True(t, got.HasRecentActivity, "fresh key should have recent activity") + + // After verify: UpdatedUnix > CreatedUnix, so HasUsed should be true + // Sleep to ensure second-level granularity differs. + time.Sleep(1 * time.Second) + _, err = VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + + got, err = GetHTTPSDeployKeyByID(t.Context(), key.ID) + require.NoError(t, err) + assert.True(t, got.HasUsed, "key should show HasUsed after verify") + assert.True(t, got.HasRecentActivity, "key should still have recent activity") + assert.NotEqual(t, got.CreatedUnix, got.UpdatedUnix, "UpdatedUnix should differ from CreatedUnix after use") +} + +func TestVerifyHTTPSDeployTokenUpdatesTimestamp(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "timestamp-test", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + originalUpdated := key.UpdatedUnix + + // Sleep to ensure second-level granularity differs. + time.Sleep(1 * time.Second) + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Greater(t, got.UpdatedUnix, originalUpdated, "UpdatedUnix should increase after verification") +} + +func TestAddHTTPSDeployKey_ConcurrentInsert(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Attempt concurrent inserts with the same (repoID, name) inside a transaction. + // Only one should succeed; the other must receive ErrHTTPSDeployKeyNameAlreadyUsed. + var results [3]struct { + key *HTTPSDeployKey + err error + } + + done := make(chan struct{}) + for idx := range 3 { + go func(i int) { + defer func() { done <- struct{}{} }() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + key, _, err := AddHTTPSDeployKey(ctx, 1, "concurrent-key", true) + results[i] = struct { + key *HTTPSDeployKey + err error + }{key: key, err: err} + }(idx) + } + + for range 3 { + <-done + } + + successCount := 0 + for i := range results { + if results[i].err == nil { + successCount++ + assert.NotNil(t, results[i].key, "result[%d]: key should not be nil on success", i) + _ = DeleteHTTPSDeployKey(t.Context(), 1, results[i].key.ID) + } else { + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(results[i].err), + "result[%d]: expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", + i, results[i].err, results[i].err) + } + } + + assert.Equal(t, 1, successCount, "exactly one concurrent insert should succeed") +} + +func TestVerifyHTTPSDeployToken_TimingResistance(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Create a valid key so that valid-format tokens trigger the hash lookup path. + key, _, err := AddHTTPSDeployKey(t.Context(), 1, "timing-test", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Generate a fake valid-format token that does not match any key. + validFormatToken := func() string { + b := make([]byte, 20) + _, _ = rand.Read(b) + return hex.EncodeToString(b) + } + + // Measure the time to verify a valid-format but wrong token. + validFormat := validFormatToken() + start := time.Now() + for range 5 { + _, _ = VerifyHTTPSDeployToken(t.Context(), validFormat) + } + validFormatDuration := time.Since(start) + + // Measure the time to verify an invalid-format token. + // If the implementation short-circuits without a dummy hash, + // this will be orders of magnitude faster than the valid-format case. + invalidFormat := "short" + start = time.Now() + for range 5 { + _, _ = VerifyHTTPSDeployToken(t.Context(), invalidFormat) + } + invalidFormatDuration := time.Since(start) + + // The invalid-format path should not be dramatically faster. + // Allow a generous margin (invalid should be within 10x of valid) + // to account for CI noise while still catching the microsecond-vs-millisecond gap. + if invalidFormatDuration < validFormatDuration/10 { + t.Errorf("invalid-format verification (%v) is too fast compared to valid-format (%v); "+ + "possible timing oracle", invalidFormatDuration, validFormatDuration) + } +} + +func TestVerifyHTTPSDeployToken_DummyHash(t *testing.T) { + // Verify that the dummy hash in the invalid-format path actually performs + // a pbkdf2 computation by confirming the HashToken call is reachable. + // This is a structural check: the dummy hash uses a random salt, + // so the result should be deterministic for the same inputs. + salt := "test-salt-12345" + hash := auth_model.HashToken("dummy-token", salt) + assert.NotEmpty(t, hash, "HashToken should produce non-empty output") + assert.NotEqual(t, "dummy-token", hash, "HashToken should not return the input") +} + +func TestAddHTTPSDeployKey_WithinTransaction(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Create a key outside a transaction first. + key1, _, err := AddHTTPSDeployKey(t.Context(), 1, "in-tx-key", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key1.ID) + + // Attempt to create a duplicate inside a transaction. + // The operation should detect the conflict and return the proper error. + var insertErr error + _ = db.WithTx(t.Context(), func(ctx context.Context) error { + _, _, insertErr = AddHTTPSDeployKey(ctx, 1, "in-tx-key", true) + return insertErr + }) + require.Error(t, insertErr) + assert.True(t, IsErrHTTPSDeployKeyNameAlreadyUsed(insertErr), + "expected ErrHTTPSDeployKeyNameAlreadyUsed, got %T: %v", insertErr, insertErr) +} + +func TestHTTPSDeployKeyModeSelection(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + readOnlyKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-read", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, readOnlyKey.ID) + assert.True(t, readOnlyKey.IsReadOnly()) + + writeKey, _, err := AddHTTPSDeployKey(t.Context(), 1, "mode-write", false) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, writeKey.ID) + assert.False(t, writeKey.IsReadOnly()) +} + +func TestHTTPSDeployKeyTokenGeneration(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Verify that each key gets a unique token. + _, token1, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-1", true) + require.NoError(t, err) + _, token2, err := AddHTTPSDeployKey(t.Context(), 1, "token-gen-2", true) + require.NoError(t, err) + + assert.NotEqual(t, token1, token2, "each key should have a unique token") + + // Verify that generated tokens are always valid format. + assert.True(t, tokenIsValidFormat(token1)) + assert.True(t, tokenIsValidFormat(token2)) +} + +func TestVerifyHTTPSDeployToken_LastEightIndex(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + key, token, err := AddHTTPSDeployKey(t.Context(), 1, "last-eight", true) + require.NoError(t, err) + defer DeleteHTTPSDeployKey(t.Context(), 1, key.ID) + + // Verify that the last eight characters of the token match the index column. + assert.Equal(t, token[len(token)-8:], key.TokenLastEight) + + // Verify that the token lookup works even when other keys share the same suffix. + // Create a dummy key with a crafted token that shares the last eight chars. + salt := util.CryptoRandomString(10) + dummyToken := strings.Repeat("a", HTTPSDeployKeyTokenLength-8) + key.TokenLastEight + dummyHash := auth_model.HashToken(dummyToken, salt) + + dummyKey := &HTTPSDeployKey{ + RepoID: 1, + Name: "last-eight-collide", + TokenHash: dummyHash, + TokenSalt: salt, + TokenLastEight: key.TokenLastEight, + Mode: 1, + } + require.NoError(t, db.Insert(t.Context(), dummyKey)) + defer DeleteHTTPSDeployKey(t.Context(), 1, dummyKey.ID) + + // Verify the original token still resolves to the correct key. + got, err := VerifyHTTPSDeployToken(t.Context(), token) + require.NoError(t, err) + assert.Equal(t, key.ID, got.ID) } diff --git a/modules/structs/repo_key.go b/modules/structs/repo_key.go index a13cde71fb..06945519b6 100644 --- a/modules/structs/repo_key.go +++ b/modules/structs/repo_key.go @@ -47,3 +47,45 @@ type CreateKeyOption struct { // required: false ReadOnly bool `json:"read_only"` } + +// HTTPSDeployKey an HTTPS deploy key/token +type HTTPSDeployKey struct { + // ID is the unique identifier for the deploy key + ID int64 `json:"id"` + // Name is the human-readable name for the key + Name string `json:"name"` + // URL is the API URL for this deploy key + URL string `json:"url"` + // Token is the plaintext token, only returned on creation + Token string `json:"token,omitempty"` + // TokenLastEight is the last 8 characters of the token for identification + TokenLastEight string `json:"token_last_eight"` + // ReadOnly indicates if the key has read-only access + ReadOnly bool `json:"read_only"` + // HasUsed indicates if the key has been used for authentication + HasUsed bool `json:"has_used"` + // HasRecentActivity indicates if the key was used in the last 7 days + HasRecentActivity bool `json:"has_recent_activity"` + // Repository is the repository this deploy key belongs to + Repository *Repository `json:"repository,omitempty"` + // swagger:strfmt date-time + // Created is the time when the deploy key was created + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + // Updated is the time when the deploy key was last updated + Updated time.Time `json:"updated_at"` +} + +// CreateHTTPSDeployKeyOption options when creating an HTTPS deploy key +// swagger:model CreateHTTPSDeployKeyOption +type CreateHTTPSDeployKeyOption struct { + // Name of the key to add + // + // required: true + // unique: true + Name string `json:"name" binding:"Required;MaxSize(50)"` + // Describe if the key has only read access or read/write + // + // required: false + ReadOnly bool `json:"read_only"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index df91e76a78..49446b51f5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2385,6 +2385,7 @@ "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.https_deploy_key_created_info": "Your HTTPS deploy key \"%s\" was created successfully. Copy the token below — it will not be shown again:", "repo.settings.branches": "Branches", "repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch.save_rule": "Save Rule", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index a8bfa0965e..2ab345e7c0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1279,6 +1279,12 @@ func Routes() *web.Router { m.Combo("/{id}").Get(repo.GetDeployKey). Delete(repo.DeleteDeploykey) }, reqToken(), reqAdmin()) + m.Group("/https_keys", func() { + m.Combo("").Get(repo.ListHTTPSDeployKeys). + Post(bind(api.CreateHTTPSDeployKeyOption{}), repo.CreateHTTPSDeployKey) + m.Combo("/{id}").Get(repo.GetHTTPSDeployKey). + Delete(repo.DeleteHTTPSDeployKey) + }, reqToken(), reqAdmin()) m.Group("/times", func() { m.Combo("").Get(repo.ListTrackedTimesByRepository) m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser) diff --git a/routers/api/v1/repo/https_key.go b/routers/api/v1/repo/https_key.go new file mode 100644 index 0000000000..dd204f9b14 --- /dev/null +++ b/routers/api/v1/repo/https_key.go @@ -0,0 +1,242 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + "net/url" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func composeHTTPSDeployKeysAPILink(owner, name string) string { + return setting.AppURL + "api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/https_keys/" +} + +// ListHTTPSDeployKeys list all the HTTPS deploy keys of a repository +func ListHTTPSDeployKeys(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/https_keys repository repoListHTTPSKeys + // --- + // summary: List a repository's HTTPS deploy keys + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/HTTPSDeployKeyList" + // "404": + // "$ref": "#/responses/notFound" + + opts := asymkey_model.ListHTTPSDeployKeysOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + } + + keys, count, err := db.FindAndCount[asymkey_model.HTTPSDeployKey](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKeys := make([]*api.HTTPSDeployKey, len(keys)) + for i := range keys { + apiKeys[i] = convert.ToHTTPSDeployKey(apiLink, keys[i]) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiKeys) +} + +// GetHTTPSDeployKey get an HTTPS deploy key by id +func GetHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/https_keys/{id} repository repoGetHTTPSKey + // --- + // summary: Get a repository's HTTPS deploy key by id + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the key to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/HTTPSDeployKey" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if key.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + ctx.JSON(http.StatusOK, convert.ToHTTPSDeployKey(apiLink, key)) +} + +// CreateHTTPSDeployKey create HTTPS deploy key for a repository +func CreateHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/https_keys repository repoCreateHTTPSKey + // --- + // summary: Add an HTTPS deploy key to a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateHTTPSDeployKeyOption" + // responses: + // "201": + // "$ref": "#/responses/HTTPSDeployKey" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateHTTPSDeployKeyOption) + key, token, err := asymkey_model.AddHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, form.Name, form.ReadOnly) + if err != nil { + switch { + case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): + ctx.APIError(http.StatusUnprocessableEntity, "A deploy key with the same name already exists") + case errors.Is(err, util.ErrInvalidArgument): + ctx.APIError(http.StatusUnprocessableEntity, err) + default: + ctx.APIErrorInternal(err) + } + return + } + + log.Trace("HTTPS deploy key added (API): operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + apiLink := composeHTTPSDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKey := convert.ToHTTPSDeployKey(apiLink, key) + apiKey.Token = token + ctx.JSON(http.StatusCreated, apiKey) +} + +// DeleteHTTPSDeployKey delete HTTPS deploy key for a repository +func DeleteHTTPSDeployKey(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/https_keys/{id} repository repoDeleteHTTPSKey + // --- + // summary: Delete an HTTPS deploy key from a repository + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetHTTPSDeployKeyByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + if key.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, key.ID); err != nil { + if asymkey_model.IsErrHTTPSDeployKeyNotExist(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } + + log.Trace("HTTPS deploy key deleted (API): operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/key.go b/routers/api/v1/swagger/key.go index 8390833589..e565a03b89 100644 --- a/routers/api/v1/swagger/key.go +++ b/routers/api/v1/swagger/key.go @@ -48,3 +48,17 @@ type swaggerResponseDeployKeyList struct { // in:body Body []api.DeployKey `json:"body"` } + +// HTTPSDeployKey +// swagger:response HTTPSDeployKey +type swaggerResponseHTTPSDeployKey struct { + // in:body + Body api.HTTPSDeployKey `json:"body"` +} + +// HTTPSDeployKeyList +// swagger:response HTTPSDeployKeyList +type swaggerResponseHTTPSDeployKeyList struct { + // in:body + Body []api.HTTPSDeployKey `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1a442d1146..f00716796f 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -50,6 +50,9 @@ type swaggerParameterBodies struct { // in:body CreateKeyOption api.CreateKeyOption + // in:body + CreateHTTPSDeployKeyOption api.CreateHTTPSDeployKeyOption + // in:body RenameUserOption api.RenameUserOption diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index 95a0f91ffe..3aa890d401 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -4,19 +4,21 @@ package setting import ( + "errors" "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) -// DeployKeys render the deploy keys list of a repository page +// DeployKeys render the deploy keys and HTTPS deploy tokens list of a repository page func DeployKeys(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets") ctx.Data["PageIsSettingsKeys"] = true @@ -100,16 +102,20 @@ func DeployKeysPost(ctx *context.Context) { return } - log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID) + log.Trace("Deploy key added: operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") } // DeleteDeployKey response for deleting a deploy key func DeleteDeployKey(ctx *context.Context) { - if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { + id := ctx.FormInt64("id") + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, id); err != nil { ctx.Flash.Error("DeleteDeployKey: " + err.Error()) } else { + log.Trace("Deploy key deleted: operator=%s repo=%s key-id=%d", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), id) ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) } @@ -117,15 +123,18 @@ func DeleteDeployKey(ctx *context.Context) { } // 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. +// repository. The plaintext token is rendered inline via ctx.Data so it never +// touches cookie-backed flash storage. 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() { + ctx.Data["HasError"] = true + ctx.Data["httpsKeyTitle"] = form.Title DeployKeys(ctx) + ctx.HTML(http.StatusOK, tplDeployKeys) return } @@ -133,26 +142,41 @@ func HTTPSDeployKeysPost(ctx *context.Context) { if err != nil { switch { case asymkey_model.IsErrHTTPSDeployKeyNameAlreadyUsed(err): - ctx.Flash.Error(ctx.Tr("repo.settings.key_name_used")) + ctx.Data["HasError"] = true + ctx.Data["Err_Title"] = true + case errors.Is(err, util.ErrInvalidArgument): + ctx.Data["HasError"] = true + ctx.Data["Err_Title"] = true default: ctx.ServerError("AddHTTPSDeployKey", err) return } - ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + ctx.Data["httpsKeyTitle"] = form.Title + DeployKeys(ctx) + ctx.HTML(http.StatusOK, tplDeployKeys) 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") + log.Trace("HTTPS deploy key added: operator=%s repo=%s key=%s (id=%d)", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), key.Name, key.ID) + + // Render the page inline with the token in ctx.Data. + // This avoids storing the secret credential in cookie-backed flash. + DeployKeys(ctx) + ctx.Data["HTTPSDeployKeyToken"] = token + ctx.Data["HTTPSDeployKeyName"] = key.Name + ctx.HTML(http.StatusOK, tplDeployKeys) } // 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 { + id := ctx.FormInt64("id") + if err := asymkey_model.DeleteHTTPSDeployKey(ctx, ctx.Repo.Repository.ID, id); err != nil { ctx.Flash.Error("DeleteHTTPSDeployKey: " + err.Error()) } else { + log.Trace("HTTPS deploy key deleted: operator=%s repo=%s key-id=%d", + ctx.Doer.Name, ctx.Repo.Repository.FullName(), id) ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) } diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 52c4087ca0..90bf980355 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -446,7 +446,10 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { IsWritable: true, }) HTTPSDeployKeysPost(ctx) - assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) + + // Handler must render the template directly (200), not redirect (303). + // This avoids putting the plaintext token in a cookie-backed flash. + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) @@ -455,10 +458,10 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { 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") + // The plaintext token must be on ctx.Data, NOT in flash/cookies. + assert.NotEmpty(t, ctx.Data["HTTPSDeployKeyToken"], "token must be in ctx.Data") + assert.NotContains(t, ctx.Flash.SuccessMsg, ctx.Data["HTTPSDeployKeyToken"], + "token must NOT be in flash — it is a secret credential") // Now delete it. delCtx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys/https/delete") @@ -473,3 +476,31 @@ func TestHTTPSDeployKeyCreateAndDelete(t *testing.T) { require.NoError(t, err) assert.Empty(t, keys) } + +func TestHTTPSDeployKeysPostValidationError(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) + + // Empty title should fail the Required binding validation. + web.SetForm(ctx, &forms.HTTPSDeployKeyForm{ + Title: "", + IsWritable: false, + }) + HTTPSDeployKeysPost(ctx) + + // Must render template inline (200), not redirect — so error state is preserved. + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) + + // Error state must be set so the template can show the panel with errors. + hasError, ok := ctx.Data["HasError"].(bool) + assert.True(t, ok && hasError, "HasError must be set on validation failure") + + // No HTTPS deploy key should have been created. + keys, err := db.Find[asymkey_model.HTTPSDeployKey](ctx, + asymkey_model.ListHTTPSDeployKeysOptions{RepoID: 1}) + require.NoError(t, err) + assert.Empty(t, keys) +} diff --git a/services/auth/https_deploy_token.go b/services/auth/https_deploy_token.go index 9b81bfcb63..c4e20cbec0 100644 --- a/services/auth/https_deploy_token.go +++ b/services/auth/https_deploy_token.go @@ -19,7 +19,7 @@ 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 +// contexts where a repo-scoped deploy token makes sense (currently the git // smart-HTTP router). See routers/web/web.go for the gating flag. type HTTPSDeployToken struct{} diff --git a/services/convert/convert.go b/services/convert/convert.go index a7622644d8..1195b2e1d5 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -699,6 +699,21 @@ func ToDeployKey(apiLink string, key *asymkey_model.DeployKey) *api.DeployKey { } } +// ToHTTPSDeployKey convert asymkey_model.HTTPSDeployKey to api.HTTPSDeployKey +func ToHTTPSDeployKey(apiLink string, key *asymkey_model.HTTPSDeployKey) *api.HTTPSDeployKey { + return &api.HTTPSDeployKey{ + ID: key.ID, + Name: key.Name, + URL: fmt.Sprintf("%s%d", apiLink, key.ID), + TokenLastEight: key.TokenLastEight, + ReadOnly: key.Mode == perm.AccessModeRead, + HasUsed: key.HasUsed, + HasRecentActivity: key.HasRecentActivity, + Created: key.CreatedUnix.AsTime(), + Updated: key.UpdatedUnix.AsTime(), + } +} + // ToOrganization convert user_model.User to api.Organization func ToOrganization(ctx context.Context, org *organization.Organization) *api.Organization { return &api.Organization{ diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index c2a0bab95f..39d84ef06f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -735,17 +735,6 @@ 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)"` diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index 39b280f220..76f7a6b448 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -85,17 +85,23 @@
-
+
+ {{if .HTTPSDeployKeyToken}} +
+

{{ctx.Locale.Tr "repo.settings.https_deploy_key_created_info" .HTTPSDeployKeyName}}

+ +
+ {{end}}
{{ctx.Locale.Tr "repo.settings.https_deploy_key_desc"}}
-
+
- +
-
+