Compare commits

..

11 Commits

Author SHA1 Message Date
wxiaoguang
63bc4eae68 no need to "clean up test fixture" 2026-06-16 08:51:21 +08:00
wxiaoguang
6b416d6135 fix 2026-06-16 08:48:53 +08:00
Nicolas
95a300e1f4 Merge remote-tracking branch 'origin/main' into various-sec-fixes
# Conflicts:
#	routers/api/v1/api.go
2026-06-15 22:12:27 +02:00
Nicolas
2f5a735c75 unexport 2026-06-15 22:11:23 +02:00
Nicolas
8819eee696 derive maintainer edit 2026-06-15 22:08:10 +02:00
wxiaoguang
0c8be7bee0 FIXME 2026-06-15 14:22:38 +08:00
Nicolas
f25811942c fix: re-check branch write permission for every ref in a push
The pre-receive hook cached the result of CanWriteCode() after the first
ref in a batch push, but CanMaintainerWriteToBranch depends on the current
branch name. A user holding a per-branch maintainer-edit grant (an open PR
with "allow edits from maintainers") could batch that branch with protected
branches or tags and have the cached approval reused, escalating to full
repository write. Evaluate the permission fresh for every ref; the pusher
and base permission remain cached via loadPusherAndPermission.

Assisted-by: Claude:claude-opus-4-8
2026-06-13 18:38:06 +02:00
Nicolas
c0c11c551c fix: enforce single-use TOTP passcodes across all 2FA surfaces
The web 2FA login and password-reset paths validated the passcode and then
wrote LastUsedPasscode in a non-atomic read-check-write sequence, so two
parallel submissions of the same code could each authenticate (TOCTOU). The
Basic-Auth X-Gitea-OTP path never recorded the used passcode at all, letting
a captured code be replayed for its whole validity window.

Add TwoFactor.ValidateAndConsumeTOTP, which validates and atomically marks
the passcode used via a conditional UPDATE (rejecting replays and racing
duplicates), and route the web login, password-reset, and Basic-Auth paths
through it.

Assisted-by: Claude:claude-opus-4-8
2026-06-13 18:38:06 +02:00
Nicolas
e99e24cb04 fix: stop trusting all proxies by default in docker app.ini templates
The Docker app.ini templates hard-coded REVERSE_PROXY_TRUSTED_PROXIES = *,
so with ENABLE_REVERSE_PROXY_AUTHENTICATION enabled any source IP reaching
the container could impersonate any user via the X-WEBAUTH-USER header.
Align the templates with the documented loopback-only default
(127.0.0.0/8,::1/128), matching app.example.ini and the in-code default.

Assisted-by: Claude:claude-opus-4-8
2026-06-13 18:38:06 +02:00
Nicolas
c20df84548 fix: block fork sync when base repo is no longer readable
POST /api/v1/repos/{owner}/{repo}/merge-upstream kept importing commits
from the parent repository even after the parent was switched from public
to private, leaking commits a fork owner could no longer access directly.
Require the doer to still have read access to the base repo's code before
syncing, and map the permission error to 403 (API) / not-found (web).

Assisted-by: Claude:claude-opus-4-8
2026-06-13 17:36:35 +02:00
Nicolas
24ce5ae082 fix: enforce org visibility on organization label read endpoints
The GET /api/v1/orgs/{org}/labels and GET /api/v1/orgs/{org}/labels/{id}
endpoints did not check whether the caller could see the organization, so
labels of a private org were disclosed to non-members (and anonymously for
the list route). Add a reqOrgVisible() middleware mirroring the visibility
check used by org.Get and apply it to the labels group.
2026-06-13 17:30:10 +02:00
18 changed files with 284 additions and 68 deletions

View File

@@ -16,7 +16,7 @@ EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-che
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.1 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.0 # renovate: datasource=go
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go

View File

@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION

View File

@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
[security]
INSTALL_LOCK = $INSTALL_LOCK
SECRET_KEY = $SECRET_KEY
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
[service]
DISABLE_REGISTRATION = $DISABLE_REGISTRATION

View File

@@ -21,6 +21,7 @@ import (
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
)
//
@@ -104,20 +105,43 @@ func (t *TwoFactor) SetSecret(secretString string) error {
return nil
}
// ValidateTOTP validates the provided passcode.
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
// validateTOTP validates the provided passcode. It does not consume the passcode; all login
// 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)
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)
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)
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.
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
_, err := db.GetEngine(ctx).Insert(t)

View File

@@ -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)
}

View File

@@ -2205,10 +2205,10 @@
"repo.settings.trust_model.collaborator.desc": "Déanfar sínithe bailí ó chomhoibritheoirí an stórais seo a mharcáil mar \"iontaofa\", cibé acu a mheaitseálann siad an tiomnóir nó nach meaitseálann. Seachas sin, déanfar sínithe bailí a mharcáil mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomnóir agus \"gan mheaitseáil\" mura bhfuil.",
"repo.settings.trust_model.committer": "Coimisitheoir",
"repo.settings.trust_model.committer.long": "Tiomnaithe: Sínithe muiníne a mheaitseálann tiomnóirí. Meaitseálann sé seo iompar GitHub agus cuirfidh sé iallach ar thiomnóirí atá sínithe ag Gitea Gitea a bheith mar an tiomnóir.",
"repo.settings.trust_model.committer.desc": "Ní mharcálfar sínithe bailí mar \"iontaofa\" ach amháin má mheaitseálann siad an tiomnóir, nó marcálfar iad mar \"gan mheaitseáil\". Cuireann sé seo iallach ar Gitea a bheith ina thiomnóir ar thiomnuithe sínithe, agus an tiomnóir iarbhír marcáilte mar leantóir Co-authored-by: sa thiomnú. Caithfidh eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.committer.desc": "Ní mharcálfar sínithe bailí mar \"iontaofa\" ach amháin má mheaitseálann siad an tiomn, nó marcálfar iad mar \"gan mheaitseáil\". Cuireann sé seo iallach ar Gitea a bheith ina tiomn ar thiomnuithe sínithe, agus an tiomn iarbhír marcáilte mar Chomhúdaraithe ag: agus Co-thiomnaithe ag: leantóir sa tiomnú. Caithfidh eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.collaboratorcommitter": "Comhoibritheo+Coimiteoir",
"repo.settings.trust_model.collaboratorcommitter.long": "Comhoibrí+Coiste: sínithe muiníne ó chomhoibrithe a mheaitseálann an tiomnóir",
"repo.settings.trust_model.collaboratorcommitter.desc": "Marcálfar sínithe bailí ó chomhoibritheoirí an stórais seo mar \"iontaofa\" má mheaitseálann siad an tiomnóir. Seachas sin, marcálfar sínithe bailí mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomnóir agus \"gan mheaitseáil\" murach sin. Cuirfidh sé seo iallach ar Gitea a bheith marcáilte mar an tiomnóir ar thiomnuithe sínithe, agus an tiomnóir iarbhír marcáilte mar leantóir Co-Authored-By: sa tiomnú. Ní mór don eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.collaboratorcommitter.desc": "Marcálfar sínithe bailí ó chomhoibritheoirí an stórais seo mar \"iontaofa\" má mheaitseálann siad an tiomn. Seachas sin, marcálfar sínithe bailí mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomn agus \"gan mheaitseáil\" murach sin. Cuirfidh sé seo iallach ar Gitea a bheith marcáilte mar an tiomn ar thiomnuithe sínithe, agus an tiomn iarbhír marcáilte mar Chomhúdaraithe ag: agus Co-Tiomnaithe ag: leantóir sa tiomnú. Ní mór don eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.wiki_delete": "Scrios Sonraí Vicí",
"repo.settings.wiki_delete_desc": "Tá sonraí wiki stóras a scriosadh buan agus ní féidir iad a chur ar ais.",
"repo.settings.wiki_delete_notices_1": "- Scriosfaidh agus díchumasóidh sé seo an stóras vicí do %s go buan.",
@@ -2599,9 +2599,6 @@
"repo.diff.review.reject": "Iarr athruithe",
"repo.diff.review.self_approve": "Ní féidir le húdair iarratais tarraing a n-iarratas tarraingthe féin a chead",
"repo.diff.committed_by": "tiomanta ag",
"repo.diff.coauthored_by": "comhúdaraithe ag",
"repo.commits.avatar_stack_and": "agus",
"repo.commits.avatar_stack_people": "%d duine",
"repo.diff.protected": "Cosanta",
"repo.diff.image.side_by_side": "Taobh le Taobh",
"repo.diff.image.swipe": "Scaoil",
@@ -2865,14 +2862,6 @@
"org.teams.all_repositories_read_permission_desc": "Tugann an fhoireann seo rochtain do <strong>Léamh</strong> ar <strong>gach stórais</strong>: is féidir le baill amharc ar stórais agus iad a chlónáil.",
"org.teams.all_repositories_write_permission_desc": "Tugann an fhoireann seo rochtain do <strong>Scríobh</strong> ar <strong>gach stórais</strong>: is féidir le baill léamh ó stórais agus iad a bhrú chucu.",
"org.teams.all_repositories_admin_permission_desc": "Tugann an fhoireann seo rochtain <strong>Riarthóra</strong> ar <strong>gach stóras</strong>: is féidir le comhaltaí léamh, brú a dhéanamh agus comhoibritheoirí a chur le stórtha.",
"org.teams.visibility": "Infheictheacht",
"org.teams.visibility_private": "Príobháideach",
"org.teams.visibility_private_helper": "Le feiceáil ag baill foirne agus úinéirí eagraíochta amháin.",
"org.teams.visibility_limited": "Teoranta",
"org.teams.visibility_limited_helper": "Infheicthe ag gach ball den eagraíocht seo.",
"org.teams.visibility_public": "Poiblí",
"org.teams.visibility_public_helper": "Infheicthe ag aon úsáideoir atá sínithe isteach.",
"org.teams.owners_visibility_fixed": "Ní féidir infheictheacht fhoireann na nÚinéirí a athrú.",
"org.teams.invite.title": "Tugadh cuireadh duit dul isteach i bhfoireann <strong>%s</strong> san eagraíocht <strong>%s</strong>.",
"org.teams.invite.by": "Ar cuireadh ó %s",
"org.teams.invite.description": "Cliceáil ar an gcnaipe thíos le do thoil chun dul isteach san fhoireann.",
@@ -3785,7 +3774,6 @@
"actions.runs.no_matching_online_runner_helper": "Gan aon reathaí ar líne a mheaitseáil le lipéad: %s",
"actions.runs.no_job_without_needs": "Caithfidh post amháin ar a laghad a bheith sa sreabhadh oibre gan spleáchas.",
"actions.runs.no_job": "Caithfidh post amháin ar a laghad a bheith sa sreabhadh oibre",
"actions.runs.invalid_reusable_workflow_uses": "Sreabhadh oibre in-athúsáidte neamhbhailí \"úsáidí\": %s",
"actions.runs.actor": "Aisteoir",
"actions.runs.status": "Stádas",
"actions.runs.actors_no_select": "Gach aisteoir",
@@ -3806,17 +3794,13 @@
"actions.runs.view_workflow_file": "Féach ar chomhad sreabha oibre",
"actions.runs.summary": "Achoimre",
"actions.runs.all_jobs": "Gach post",
"actions.runs.job_summaries": "Achoimrí poist",
"actions.runs.expand_caller_jobs": "Taispeáin poist an ghlaoiteora sreabha oibre in-athúsáidte seo",
"actions.runs.collapse_caller_jobs": "Folaigh poist an ghlaoiteora sreabha oibre in-athúsáidte seo",
"actions.runs.attempt": "Iarracht",
"actions.runs.latest": "Is déanaí",
"actions.runs.latest_attempt": "An iarracht is déanaí",
"actions.runs.triggered_via": "Spreagtha trí %s",
"actions.runs.rerun_triggered": "Athrith spreagtha",
"actions.runs.back_to_pull_request": "Ar ais chuig an iarratas tarraingthe",
"actions.runs.back_to_workflow": "Ar ais chuig an sreabhadh oibre",
"actions.runs.total_duration": "Fad iomlán",
"actions.runs.total_duration": "Fad iomlán:",
"actions.runs.workflow_dependencies": "Spleáchais ar Shreabhadh Oibre",
"actions.runs.graph_jobs_count_1": "%d post",
"actions.runs.graph_jobs_count_n": "%d poist",

View File

@@ -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) {
if ctx.IsUserSiteAdmin() {
return 0, true, true
@@ -1728,7 +1743,7 @@ func Routes() *web.Router {
m.Combo("/{id}").Get(reqToken(), org.GetLabel).
Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel).
Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel)
})
}, reqOrgVisible())
m.Group("/hooks", func() {
m.Combo("").Get(org.ListHooks).
Post(bind(api.CreateHookOption{}), org.CreateHook)

View File

@@ -1336,6 +1336,9 @@ func MergeUpstream(ctx *context.APIContext) {
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err.Error())
return
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
return
}
ctx.APIErrorInternal(err)
return

View File

@@ -40,9 +40,6 @@ type preReceiveContext struct {
canCreatePullRequest bool
checkedCanCreatePullRequest bool
canWriteCode bool
checkedCanWriteCode bool
protectedTags []*git_model.ProtectedTag
gotProtectedTags bool
@@ -50,24 +47,36 @@ type preReceiveContext struct {
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) CanWriteCode() bool {
if !ctx.checkedCanWriteCode {
if !ctx.loadPusherAndPermission() {
return false
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
var canWrite bool
if ctx.loadPusherAndPermission() {
canWrite = ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
}
ctx.canWriteCode = issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, ctx.branchName, ctx.user) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.checkedCanWriteCode = true
ctx.canWriteCodeUnitCached = &canWrite
}
return ctx.canWriteCode
return *ctx.canWriteCodeUnitCached
}
// AssertCanWriteCode returns true if pusher can write code
func (ctx *preReceiveContext) AssertCanWriteCode() bool {
if !ctx.CanWriteCode() {
// 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 issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
}
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
if !ctx.canWriteCodeRef(refFullName) {
if ctx.Written() {
return false
}
@@ -129,7 +138,7 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName)
default:
ourCtx.AssertCanWriteCode()
ourCtx.assertCanWriteRef(refFullName)
}
if ctx.Written() {
return
@@ -141,9 +150,8 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName()
ctx.branchName = branchName
if !ctx.AssertCanWriteCode() {
if !ctx.assertCanWriteRef(refFullName) {
return
}
@@ -404,7 +412,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
if !ctx.AssertCanWriteCode() {
if !ctx.assertCanWriteRef(refFullName) {
return
}

View File

@@ -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")))
}

View File

@@ -58,14 +58,14 @@ func TwoFactorPost(ctx *context.Context) {
return
}
// Validate the passcode with the stored TOTP secret.
ok, err := twofa.ValidateTOTP(form.Passcode)
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok && twofa.LastUsedPasscode != form.Passcode {
if ok {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
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)
handleSignIn(ctx, u, remember)
return

View File

@@ -177,23 +177,17 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateTOTP(passcode)
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error())
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
return
}
if !ok || twofa.LastUsedPasscode == passcode {
if !ok {
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
return
}
twofa.LastUsedPasscode = passcode
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
return
}
}
}

View File

@@ -264,7 +264,7 @@ func MergeUpstream(ctx *context.Context) {
branchName := ctx.FormString("branch")
_, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrPermissionDenied) {
ctx.JSONErrorNotFound()
return
} else if pull_service.IsErrMergeConflicts(err) {

View File

@@ -177,7 +177,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
}
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
} else if !ok {
return util.NewInvalidArgumentErrorf("invalid provided OTP")

View File

@@ -8,7 +8,9 @@ import (
"fmt"
issue_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"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 {
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)
if err != nil {
return "", err

View File

@@ -11,6 +11,7 @@ import (
"time"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
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
})
}
// 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)
})
}

View File

@@ -51,6 +51,12 @@ func TestAPITwoFactor(t *testing.T) {
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
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) {

View File

@@ -171,5 +171,18 @@ func TestRepoMergeUpstream(t *testing.T) {
}).AddTokenAuth(token)
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)
})
})
}