mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-17 19:10:22 +03:00
fix: Various security fixes (#38103)
- Enforce org visibility on organization label read endpoints (private org labels no longer leak to non-members). - Block fork sync (`merge-upstream`) when the base repo is no longer readable (stops pulling commits after a parent goes private). - Remove `REVERSE_PROXY_LIMIT` / `REVERSE_PROXY_TRUSTED_PROXIES` from the Docker `app.ini` templates (the `= *` default allowed `X-WEBAUTH-USER` impersonation; reverse-proxy auth is now opt-in and admin-configured). - Enforce single-use TOTP passcodes across web login, password-reset, and Basic-Auth `X-Gitea-OTP` (fixes a TOCTOU race and a stateless replay). - Re-check branch write permission for every ref in a push (the pre-receive hook cached the first ref's result, letting a per-branch maintainer-edit grant escalate to full repo write). --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateTOTP validates the provided passcode.
|
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
|
||||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
// surfaces must go through ValidateAndConsumeTOTP so that a passcode cannot be redeemed twice.
|
||||||
|
func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||||
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
|
return false, fmt.Errorf("validateTOTP invalid base64: %w", err)
|
||||||
}
|
}
|
||||||
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
return false, fmt.Errorf("validateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
|
||||||
}
|
}
|
||||||
secretStr := string(secretBytes)
|
secretStr := string(secretBytes)
|
||||||
return totp.Validate(passcode, secretStr), nil
|
return totp.Validate(passcode, secretStr), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||||
|
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||||
|
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||||
|
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||||
|
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||||
|
ok, err := t.validateTOTP(passcode)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||||
|
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||||
|
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||||
|
t.LastUsedPasscode = passcode
|
||||||
|
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||||
|
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||||
|
Cols("last_used_passcode").Update(t)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewTwoFactor creates a new two-factor authentication token.
|
// NewTwoFactor creates a new two-factor authentication token.
|
||||||
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
|
||||||
_, err := db.GetEngine(ctx).Insert(t)
|
_, err := db.GetEngine(ctx).Insert(t)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tfa := &auth_model.TwoFactor{UID: 1}
|
||||||
|
require.NoError(t, tfa.SetSecret(key.Secret()))
|
||||||
|
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
|
||||||
|
|
||||||
|
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// first use of a valid passcode succeeds
|
||||||
|
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ok)
|
||||||
|
|
||||||
|
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||||
|
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// an invalid passcode is rejected without consuming anything
|
||||||
|
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
+16
-1
@@ -505,6 +505,21 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reqOrgVisible requires the organization to be visible to the doer, or a site admin
|
||||||
|
func reqOrgVisible() func(ctx *context.APIContext) {
|
||||||
|
return func(ctx *context.APIContext) {
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
setting.PanicInDevOrTesting("reqOrgVisible: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("reqOrgVisible: unprepared context"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||||
if ctx.IsUserSiteAdmin() {
|
if ctx.IsUserSiteAdmin() {
|
||||||
return 0, true, true
|
return 0, true, true
|
||||||
@@ -1728,7 +1743,7 @@ func Routes() *web.Router {
|
|||||||
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
|
||||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
|
||||||
})
|
}, reqOrgVisible())
|
||||||
m.Group("/hooks", func() {
|
m.Group("/hooks", func() {
|
||||||
m.Combo("").Get(org.ListHooks).
|
m.Combo("").Get(org.ListHooks).
|
||||||
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
Post(bind(api.CreateHookOption{}), org.CreateHook)
|
||||||
|
|||||||
@@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) {
|
|||||||
} else if errors.Is(err, util.ErrNotExist) {
|
} else if errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.APIError(http.StatusNotFound, err.Error())
|
ctx.APIError(http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
|
} else if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
|
ctx.APIError(http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ type preReceiveContext struct {
|
|||||||
canCreatePullRequest bool
|
canCreatePullRequest bool
|
||||||
checkedCanCreatePullRequest bool
|
checkedCanCreatePullRequest bool
|
||||||
|
|
||||||
canWriteCode bool
|
|
||||||
checkedCanWriteCode bool
|
|
||||||
|
|
||||||
protectedTags []*git_model.ProtectedTag
|
protectedTags []*git_model.ProtectedTag
|
||||||
gotProtectedTags bool
|
gotProtectedTags bool
|
||||||
|
|
||||||
@@ -50,24 +47,36 @@ type preReceiveContext struct {
|
|||||||
|
|
||||||
opts *private.HookOptions
|
opts *private.HookOptions
|
||||||
|
|
||||||
branchName string
|
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
|
||||||
|
canWriteCodeUnitCached *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWriteCode returns true if pusher can write code
|
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
|
||||||
func (ctx *preReceiveContext) CanWriteCode() bool {
|
if ctx.canWriteCodeUnitCached == nil {
|
||||||
if !ctx.checkedCanWriteCode {
|
var canWrite bool
|
||||||
if !ctx.loadPusherAndPermission() {
|
if ctx.loadPusherAndPermission() {
|
||||||
|
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
||||||
|
}
|
||||||
|
ctx.canWriteCodeUnitCached = &canWrite
|
||||||
|
}
|
||||||
|
return *ctx.canWriteCodeUnitCached
|
||||||
|
}
|
||||||
|
|
||||||
|
// canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
|
||||||
|
func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
|
||||||
|
if ctx.canWriteCodeUnit() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// then check whether if the pusher is a maintainer who can write the PR author's head repo branch
|
||||||
|
if !refFullName.IsBranch() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
|
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
|
||||||
ctx.checkedCanWriteCode = true
|
|
||||||
}
|
|
||||||
return ctx.canWriteCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssertCanWriteCode returns true if pusher can write code
|
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
|
||||||
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
|
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
|
||||||
if !ctx.CanWriteCode() {
|
if !ctx.canWriteCodeRef(refFullName) {
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
|
||||||
preReceiveFor(ourCtx, refFullName)
|
preReceiveFor(ourCtx, refFullName)
|
||||||
default:
|
default:
|
||||||
ourCtx.AssertCanWriteCode()
|
ourCtx.assertCanWriteRef(refFullName)
|
||||||
}
|
}
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
@@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
|
|||||||
|
|
||||||
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
|
||||||
branchName := refFullName.BranchName()
|
branchName := refFullName.BranchName()
|
||||||
ctx.branchName = branchName
|
|
||||||
|
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
|
||||||
if !ctx.AssertCanWriteCode() {
|
if !ctx.assertCanWriteRef(refFullName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package private
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
issues_model "gitea.dev/models/issues"
|
||||||
|
"gitea.dev/models/perm/access"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/git"
|
||||||
|
"gitea.dev/services/contexttest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
|
||||||
|
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
|
||||||
|
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
|
||||||
|
// together with a protected branch or a tag to escalate into full repository write.
|
||||||
|
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||||
|
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
|
||||||
|
require.NoError(t, baseRepo.LoadOwner(t.Context()))
|
||||||
|
require.NoError(t, headRepo.LoadOwner(t.Context()))
|
||||||
|
|
||||||
|
// An open PR from the head repo owner, with maintainer edits allowed: this grants the base
|
||||||
|
// repo owner write access to exactly this head branch and nothing else.
|
||||||
|
pr := &issues_model.PullRequest{
|
||||||
|
Issue: &issues_model.Issue{
|
||||||
|
RepoID: baseRepo.ID,
|
||||||
|
PosterID: headRepo.OwnerID,
|
||||||
|
},
|
||||||
|
HeadRepoID: headRepo.ID,
|
||||||
|
BaseRepoID: baseRepo.ID,
|
||||||
|
HeadBranch: "granted-branch",
|
||||||
|
BaseBranch: "master",
|
||||||
|
AllowMaintainerEdit: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
|
||||||
|
|
||||||
|
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
|
||||||
|
maintainer := baseRepo.Owner
|
||||||
|
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
|
||||||
|
ctx := &preReceiveContext{
|
||||||
|
PrivateContext: mockCtx,
|
||||||
|
loadedPusher: true,
|
||||||
|
user: maintainer,
|
||||||
|
userPerm: headPerm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The granted branch must be writable...
|
||||||
|
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
|
||||||
|
|
||||||
|
// ...but another branch in the same push must NOT inherit that grant.
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromBranch("master")))
|
||||||
|
|
||||||
|
// ...and a tag sharing the granted branch's name must NOT inherit it either: the grant is
|
||||||
|
// scoped to PR head branches, so a non-branch ref can never match it. (A tag ref already
|
||||||
|
// yields an empty branch name, so this guards the per-ref evaluation, not the IsBranch check.)
|
||||||
|
assert.False(t, ctx.canWriteCodeRef(git.RefNameFromTag("granted-branch")))
|
||||||
|
}
|
||||||
@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the passcode with the stored TOTP secret.
|
// Validate the passcode and atomically consume it to prevent reuse/replay.
|
||||||
ok, err := twofa.ValidateTOTP(form.Passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && twofa.LastUsedPasscode != form.Passcode {
|
if ok {
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
u, err := user_model.GetUserByID(ctx, id)
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,12 +81,6 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = form.Passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
regenerateScratchToken = true
|
regenerateScratchToken = true
|
||||||
} else {
|
} else {
|
||||||
passcode := ctx.FormString("passcode")
|
passcode := ctx.FormString("passcode")
|
||||||
ok, err := twofa.ValidateTOTP(passcode)
|
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
|
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok || twofa.LastUsedPasscode == passcode {
|
if !ok {
|
||||||
ctx.Data["IsResetForm"] = true
|
ctx.Data["IsResetForm"] = true
|
||||||
ctx.Data["Err_Passcode"] = true
|
ctx.Data["Err_Passcode"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
twofa.LastUsedPasscode = passcode
|
|
||||||
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
|
|
||||||
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
|
|||||||
branchName := ctx.FormString("branch")
|
branchName := ctx.FormString("branch")
|
||||||
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
|
||||||
ctx.JSONErrorNotFound()
|
ctx.JSONErrorNotFound()
|
||||||
return
|
return
|
||||||
} else if pull_service.IsErrMergeConflicts(err) {
|
} else if pull_service.IsErrMergeConflicts(err) {
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
|
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
|
||||||
|
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
issue_model "gitea.dev/models/issues"
|
issue_model "gitea.dev/models/issues"
|
||||||
|
access_model "gitea.dev/models/perm/access"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unit"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/gitrepo"
|
"gitea.dev/modules/gitrepo"
|
||||||
@@ -26,6 +28,17 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_
|
|||||||
if err = repo.GetBaseRepo(ctx); err != nil {
|
if err = repo.GetBaseRepo(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The doer must still be able to read the base repository's code. Otherwise a fork created
|
||||||
|
// while the base repo was public could keep pulling commits after it turned private.
|
||||||
|
basePerm, err := access_model.GetDoerRepoPermission(ctx, repo.BaseRepo, doer)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !basePerm.CanRead(unit.TypeCode) {
|
||||||
|
return "", util.NewPermissionDeniedErrorf("permission denied to read base repo %d", repo.BaseRepo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
divergingInfo, err := GetUpstreamDivergingInfo(ctx, repo, branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
issues_model "gitea.dev/models/issues"
|
||||||
org_model "gitea.dev/models/organization"
|
org_model "gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
@@ -292,3 +293,50 @@ func testAPIDeleteOrgRepos(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
|
MakeRequest(t, req, http.StatusNoContent) // The org contains no repositories, so the API should return StatusNoContent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAPIOrgLabelsVisibility ensures the organization label read endpoints honor
|
||||||
|
// the organization visibility: labels of a private org must not be disclosed to
|
||||||
|
// users who cannot see the org (GHSA: unauthorized access to private org labels).
|
||||||
|
func TestAPIOrgLabelsVisibility(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// privated_org (id 23) is a private organization; user5 is its only member.
|
||||||
|
privateOrg := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 23})
|
||||||
|
label := &issues_model.Label{OrgID: privateOrg.ID, Name: "internal-label", Color: "#aabbcc", Description: "private organization label"}
|
||||||
|
require.NoError(t, issues_model.NewLabel(t.Context(), label))
|
||||||
|
|
||||||
|
listURL := fmt.Sprintf("/api/v1/orgs/%s/labels", privateOrg.Name)
|
||||||
|
getURL := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", privateOrg.Name, label.ID)
|
||||||
|
|
||||||
|
t.Run("NonMemberDenied", func(t *testing.T) {
|
||||||
|
// user2 is not a member of the private org and must not see its labels.
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusNotFound)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AnonymousDenied", func(t *testing.T) {
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL), http.StatusNotFound)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL), http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MemberAllowed", func(t *testing.T) {
|
||||||
|
token := getUserToken(t, "user5", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
resp := MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
labels := DecodeJSON(t, resp, &[]*api.Label{})
|
||||||
|
assert.Len(t, *labels, 1)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SiteAdminAllowed", func(t *testing.T) {
|
||||||
|
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", listURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", getURL).AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PublicOrgStillReadable", func(t *testing.T) {
|
||||||
|
// org3 (id 3) is a public org with labels; non-members may read them.
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
MakeRequest(t, NewRequest(t, "GET", "/api/v1/orgs/org3/labels").AddTokenAuth(token), http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
|
|||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
req.Header.Set("X-Gitea-OTP", passcode)
|
req.Header.Set("X-Gitea-OTP", passcode)
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// the same passcode must not be replayable on the basic-auth surface (RFC 6238 single-use)
|
||||||
|
req = NewRequest(t, "GET", "/api/v1/user").
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
req.Header.Set("X-Gitea-OTP", passcode)
|
||||||
|
MakeRequest(t, req, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBasicAuthWithWebAuthn(t *testing.T) {
|
func TestBasicAuthWithWebAuthn(t *testing.T) {
|
||||||
|
|||||||
@@ -171,5 +171,18 @@ func TestRepoMergeUpstream(t *testing.T) {
|
|||||||
}).AddTokenAuth(token)
|
}).AddTokenAuth(token)
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("BasePrivateBlocksSync", func(t *testing.T) {
|
||||||
|
// add a new commit to the base repo, then make the base repo private
|
||||||
|
require.NoError(t, createOrReplaceFileInBranch(baseUser, baseRepo, "secret.txt", "master", "private-content"))
|
||||||
|
baseRepo.IsPrivate = true
|
||||||
|
_, err := db.GetEngine(t.Context()).ID(baseRepo.ID).Cols("is_private").Update(baseRepo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// the fork owner can no longer read the base repo, so syncing must be refused
|
||||||
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/test-repo-fork/merge-upstream", forkUser.Name), &api.MergeUpstreamRequest{
|
||||||
|
Branch: "fork-branch",
|
||||||
|
}).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user