Compare commits

..
9 Commits
Author SHA1 Message Date
bircniandGitHub 95ba37d9af docs: Add Changelog for 1.26.4 (#38186)
Signed-off-by: bircni <bircni@icloud.com>
2026-06-21 17:18:36 +02:00
db23633874 fix: walk git log context error handling (#38182) (#38185)
Backport #38182

Fix #38177

Make WalkGitLog can handle EOF and context errors correctly, and don't
export these private functions & methods & structs.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-21 12:54:31 +00:00
2bde4fa5d2 fix(auth): do not auto-reactivate disabled users on OAuth2 callback (#38009) (#38183)
Backport #38009

The OAuth2 sign-in callback unconditionally set IsActive=true on the
local user row whenever the IdP authenticated them, silently undoing an
administrator's "Disable Account" action and granting the user a fresh
session in the same response. Treat the local IsActive flag as an
authoritative admin override: inactive users get a session and are
routed through the existing activate / prohibit-login pages by
verifyAuthWithOptions, matching the local-credentials sign-in path.

Adds an integration regression test that disables a linked local user
and asserts the row stays IsActive=false after a full OIDC callback.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-21 12:23:24 +00:00
4aa0d704f2 docs: add changelog for 1.26.3 (#38166)
Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-06-20 21:58:13 +02:00
b0eaf6c75b fix(hostmacher): patch incorrect private list (#38170) (#38173)
Backport #38170 by @TheFox0x7

regression from #38039

Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
2026-06-19 21:50:01 +00:00
42eb7945d1 fix: Fix issue target branch selection for non-collaborators (#36916) (#38164)
Backport #36916 

This PR fixes a bug in the UI that prevented non-collaborator users (the
issue poster or creator) from setting the target branch (ref) of an
issue. The backend API already supports this, but the UI was rigidly
disabling the dropdown based only on collaborator status.

Changes:
- Enable the branch selector for the issue poster and during new issue
creation.
- Fix a typo (.IsIssueWriter -> .IsIssuePoster) that was preventing the
reference update URL from being correctly set for posters.

Co-authored-by: fwag <30782430+fwag@users.noreply.github.com>
2026-06-19 18:16:02 +02:00
Lunny XiaoandGitHub 0b9623e188 fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026) (#38158)
Fix #38016
Backport #38026
2026-06-18 11:34:58 +03:00
6fb96fcfdf fix: allow git clone of private repos with anonymous code access (#38074) (#38146)
Backport #38074

## Summary

Fixes #38062.

Private repositories with a code unit configured for **anonymous read
access** (Settings → Public Access → Code: anonymous view) could not be
cloned without credentials. The git HTTP auth gate (`httpBase`) only
bypassed authentication for non-private repos, ignoring the per-unit
anonymous access setting entirely.

- Check anonymous permissions via
`access_model.GetDoerRepoPermission(ctx, repo, nil)` + `CanAccess`
before requiring auth on pull operations, so the per-unit
`AnonymousAccessMode` is respected through the existing permission model
- This also correctly handles `setting.Repository.ForcePrivate` (which
the naive direct-field check would have missed)
- Push (receive-pack) and `RequireSignInViewStrict` continue to require
credentials as before

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-06-17 21:21:05 +00:00
99f8b3d9a1 fix: Various security fixes (#38103) (#38151)
Backport #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>
2026-06-17 20:49:59 +00:00
40 changed files with 824 additions and 215 deletions
+50
View File
@@ -4,6 +4,56 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
* SECURITY
* fix(auth): do not auto-reactivate disabled users on OAuth2 callback (#38009) (#38183)
* BUGFIXES
* fix: walk git log context error handling (#38182) (#38185)
## [1.26.3](https://github.com/go-gitea/gitea/releases/tag/1.26.3) - 2026-06-18
* BREAKING
* fix(actions)!: require merged PR to bypass fork PR approval gate (#38010) (#38041)
* SECURITY
* fix(hostmatcher): patch incorrect private list (#38170) (#38173)
* fix: Various security fixes (#38103) (#38151)
* fix: Various sec fixes (#38108) (#38147)
* fix: allow git clone of private repos with anonymous code access (#38074) (#38146)
* fix(auth): ignore stale OIDC external login links to organizations (#37875) (#38141)
* fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) (#38059)
* fix(lfs): require Code-unit access for cross-repo LFS object reuse (#38006) (#38050)
* fix(lfs): reject unknown SSH LFS sub-verbs to prevent auth bypass (#38008) (#38015)
* fix: bound CODEOWNERS regex match time (#38011) (#38025)
* fix: bound debian ParseControlFile to a single control stanza (#38044) (#38055)
* fix(deps): update module golang.org/x/net to v0.55.0 [security] (#37813) (#37829)
* API
* feat(api): add Link header in ListForks (#38052) (#38063)
* BUGFIXES
* fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026) (#38158)
* fix(api): nil pointer panic when filtering tracked times by a non-existent user (#38112) (#38115)
* fix: keep literal "false" value displayed in workflow_dispatch choice dropdowns (#38080) (#38096)
* fix: parse HEAD ref (#38119)
* fix: git cmd (#38084) (#38087)
* fix(releases): generate notes for initial tag (#37697) (#37986)
* fix(actions): return 404 when job log blob is missing (#38003) (#38004)
* fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) (#37899)
* fix(actions): keep action run title clickable when commit subject is a URL (#37867) (#37898)
* fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) (#37895)
* fix(actions): ack re-sent `UpdateLog` finalize idempotently (#37885) (#37892)
* fix: http content file render (#37850) (#37856)
* fix(issues): clear stale ReviewTypeRequest when submitting pending review (#37809) (#37815)
* fix: Fix issue target branch selection for non-collaborators (#36916) (#38164)
* BUILD
* fix(deps): update `@playwright/test` to 1.60.0 (#38144)
* ci: add `tools/ci-tools.ts` for the PR labeler workflow (#37831)
* fix(build): swagger css import (#37801) (#37803)
## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20 ## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20
* SECURITY * SECURITY
-2
View File
@@ -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
-2
View File
@@ -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
+28 -4
View File
@@ -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"
) )
// //
@@ -107,20 +108,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)
+47
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 "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/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)
}
+1 -1
View File
@@ -47,7 +47,7 @@ func OrderBy(orderBy string) any {
} }
func whereOrderConditions(e db.Engine, conditions []any) db.Engine { func whereOrderConditions(e db.Engine, conditions []any) db.Engine {
orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic orderBy := "id" // query must have the "ORDER BY", otherwise the result is not deterministic. FIXME: some tables do not have "id" column
for _, condition := range conditions { for _, condition := range conditions {
switch cond := condition.(type) { switch cond := condition.(type) {
case *testCond: case *testCond:
+1 -1
View File
@@ -106,7 +106,7 @@ func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cac
// GetLastCommitForPaths returns last commit information // GetLastCommitForPaths returns last commit information
func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) { func GetLastCommitForPaths(ctx context.Context, commit *Commit, treePath string, paths []string) (map[string]*Commit, error) {
// We read backwards from the commit to obtain all of the commits // We read backwards from the commit to obtain all of the commits
revs, err := WalkGitLog(ctx, commit.repo, commit, treePath, paths...) revs, err := walkGitLog(ctx, commit.repo, commit, treePath, paths...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package git
import (
"context"
"path/filepath"
"testing"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEntries_GetCommitsInfo_ContextErr(t *testing.T) {
repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
require.NoError(t, err)
defer repo.Close()
commit, err := repo.GetCommit("feaf4ba6bc635fec442f46ddd4512416ec43c2c2")
require.NoError(t, err)
entries, err := commit.Tree.ListEntries()
require.NoError(t, err)
countCommitInfosCommit := func(infos []CommitInfo) (nilCommits, nonNilCommits int) {
for _, info := range infos {
nilCommits += util.Iif(info.Commit == nil, 1, 0)
nonNilCommits += util.Iif(info.Commit != nil, 1, 0)
}
return nilCommits, nonNilCommits
}
ctx, cancel := context.WithCancel(t.Context())
defer test.MockVariableValue(&walkGitLogDebugBeforeNext)()
walkGitLogDebugBeforeNext = cancel
commitInfos, _, err := entries.GetCommitsInfo(ctx, "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits := countCommitInfosCommit(commitInfos)
assert.Equal(t, 0, nonNilCommits) // no commit info due to canceled (or deadline-exceeded) context
assert.Equal(t, 3, nilCommits)
walkGitLogDebugBeforeNext = nil
commitInfos, _, err = entries.GetCommitsInfo(t.Context(), "/any/repo-link", commit, "")
assert.NoError(t, err)
nilCommits, nonNilCommits = countCommitInfosCommit(commitInfos)
assert.Equal(t, 3, nonNilCommits)
assert.Equal(t, 0, nilCommits)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (c *Commit) recursiveCache(ctx context.Context, tree *Tree, treePath string
entryPaths[i] = entry.Name() entryPaths[i] = entry.Name()
} }
_, err = WalkGitLog(ctx, c.repo, c, treePath, entryPaths...) _, err = walkGitLog(ctx, c.repo, c, treePath, entryPaths...)
if err != nil { if err != nil {
return err return err
} }
@@ -1,6 +1,8 @@
// Copyright 2021 The Gitea Authors. All rights reserved. // Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//go:build !gogit
package git package git
import ( import (
@@ -18,10 +20,8 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
) )
// LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function // logNameStatusRepo opens git log --raw in the provided repo and returns a parser
func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) { func logNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) *logNameStatusRepoParser {
// Lets also create a context so that we can absolutely ensure that the command should die when we're done
cmd := gitcmd.NewCommand() cmd := gitcmd.NewCommand()
cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head) cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head)
@@ -54,77 +54,62 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p
ctx, ctxCancel := context.WithCancel(ctx) ctx, ctxCancel := context.WithCancel(ctx)
go func() { go func() {
err := cmd.WithDir(repository).RunWithStderr(ctx) err := cmd.WithDir(repository).RunWithStderr(ctx)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
log.Error("Unable to run git command %v: %v", cmd.LogString(), err) log.Error("Unable to run git command %v: %v", cmd.LogString(), err)
} }
}() }()
bufReader := bufio.NewReaderSize(stdoutReader, 32*1024) bufReader := bufio.NewReaderSize(stdoutReader, 32*1024)
return &logNameStatusRepoParser{
return bufReader, func() { treepath: treepath,
ctxCancel() paths: paths,
stdoutReaderClose() rd: bufReader,
close: func() {
ctxCancel()
stdoutReaderClose()
},
} }
} }
// LogNameStatusRepoParser parses a git log raw output from LogRawRepo // logNameStatusRepoParser parses a git log raw output from LogRawRepo
type LogNameStatusRepoParser struct { type logNameStatusRepoParser struct {
treepath string treepath string
paths []string paths []string
next []byte next []byte
buffull bool buffull bool
rd *bufio.Reader rd *bufio.Reader
cancel func() close func()
} }
// NewLogNameStatusRepoParser returns a new parser for a git log raw output // logNameStatusCommitData represents a commit artifact from git log raw
func NewLogNameStatusRepoParser(ctx context.Context, repository, head, treepath string, paths ...string) *LogNameStatusRepoParser { type logNameStatusCommitData struct {
rd, cancel := LogNameStatusRepo(ctx, repository, head, treepath, paths...)
return &LogNameStatusRepoParser{
treepath: treepath,
paths: paths,
rd: rd,
cancel: cancel,
}
}
// LogNameStatusCommitData represents a commit artefact from git log raw
type LogNameStatusCommitData struct {
CommitID string CommitID string
ParentIDs []string ParentIDs []string
Paths []bool Paths []bool
} }
// Next returns the next LogStatusCommitData // walkNext returns the next LogStatusCommitData
func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*LogNameStatusCommitData, error) { func (g *logNameStatusRepoParser) walkNext(treepath string, paths2ids map[string]int, changed []bool, maxpathlen int) (*logNameStatusCommitData, error) {
var err error var err error
if len(g.next) == 0 { if len(g.next) == 0 {
g.buffull = false g.buffull = false
g.next, err = g.rd.ReadSlice('\x00') g.next, err = g.rd.ReadSlice('\x00')
if err != nil { switch {
switch err { case errors.Is(err, bufio.ErrBufferFull):
case bufio.ErrBufferFull: g.buffull = true
g.buffull = true case err != nil:
case io.EOF: return nil, err
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
} }
} }
ret := LogNameStatusCommitData{} ret := logNameStatusCommitData{}
if bytes.Equal(g.next, []byte("commit\000")) { if bytes.Equal(g.next, []byte("commit\000")) {
g.next, err = g.rd.ReadSlice('\x00') g.next, err = g.rd.ReadSlice('\x00')
if err != nil { switch {
switch err { case errors.Is(err, bufio.ErrBufferFull):
case bufio.ErrBufferFull: g.buffull = true
g.buffull = true case err != nil:
case io.EOF: return nil, err
return nil, nil //nolint:nilnil // return nil to signal EOF
default:
return nil, err
}
} }
} }
@@ -273,13 +258,10 @@ diffloop:
} }
} }
// Close closes the parser var walkGitLogDebugBeforeNext func() // is used to simulate various edge git process cases
func (g *LogNameStatusRepoParser) Close() {
g.cancel()
}
// WalkGitLog walks the git log --name-status for the head commit in the provided treepath and files // walkGitLog walks the git log --name-status for the head commit in the provided treepath and files
func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) { func walkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath string, paths ...string) (map[string]string, error) {
headRef := head.ID.String() headRef := head.ID.String()
tree, err := head.SubTree(treepath) tree, err := head.SubTree(treepath)
@@ -322,11 +304,9 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
} }
} }
g := NewLogNameStatusRepoParser(ctx, repo.Path, head.ID.String(), treepath, paths...) g := logNameStatusRepo(ctx, repo.Path, head.ID.String(), treepath, paths...)
// don't use defer g.Close() here as g may change its value - instead wrap in a func // don't use defer g.cancel() here as g may change its value - instead wrap in a func
defer func() { defer func() { g.close() }()
g.Close()
}()
results := make([]string, len(paths)) results := make([]string, len(paths))
remaining := len(paths) remaining := len(paths)
@@ -340,25 +320,16 @@ func WalkGitLog(ctx context.Context, repo *Repository, head *Commit, treepath st
heaploop: heaploop:
for { for {
select { if walkGitLogDebugBeforeNext != nil {
case <-ctx.Done(): walkGitLogDebugBeforeNext()
if ctx.Err() == context.DeadlineExceeded {
break heaploop
}
g.Close()
return nil, ctx.Err()
default:
} }
current, err := g.Next(treepath, path2idx, changed, maxpathlen) current, err := g.walkNext(treepath, path2idx, changed, maxpathlen)
if err != nil { if ctx.Err() != nil {
if errors.Is(err, context.DeadlineExceeded) { break heaploop // context is either canceled or deadline exceeded - break the loop and return what we have so far
break heaploop } else if errors.Is(err, io.EOF) {
} break heaploop // reached to the end of log output
g.Close() } else if err != nil {
return nil, err return nil, err // other unknown errors
}
if current == nil {
break heaploop
} }
parentRemaining.Remove(current.CommitID) parentRemaining.Remove(current.CommitID)
for i, found := range current.Paths { for i, found := range current.Paths {
@@ -395,14 +366,14 @@ heaploop:
if remaining <= nextRestart { if remaining <= nextRestart {
commitSinceNextRestart++ commitSinceNextRestart++
if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent { if 4*commitSinceNextRestart > 3*commitSinceLastEmptyParent {
g.Close()
remainingPaths := make([]string, 0, len(paths)) remainingPaths := make([]string, 0, len(paths))
for i, pth := range paths { for i, pth := range paths {
if results[i] == "" { if results[i] == "" {
remainingPaths = append(remainingPaths, pth) remainingPaths = append(remainingPaths, pth)
} }
} }
g = NewLogNameStatusRepoParser(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...) g.close()
g = logNameStatusRepo(ctx, repo.Path, lastEmptyParent, treepath, remainingPaths...)
parentRemaining = make(container.Set[string]) parentRemaining = make(container.Set[string])
nextRestart = (remaining * 3) / 4 nextRestart = (remaining * 3) / 4
continue heaploop continue heaploop
@@ -410,7 +381,6 @@ heaploop:
} }
parentRemaining.AddMultiple(current.ParentIDs...) parentRemaining.AddMultiple(current.ParentIDs...)
} }
g.Close()
resultsMap := map[string]string{} resultsMap := map[string]string{}
for i, pth := range paths { for i, pth := range paths {
+11 -10
View File
@@ -65,12 +65,9 @@ var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
return nets return nets
}) })
// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose // isReservedIP reports whether ip falls in reserved special-purpose
// range (see reservedIPNets) that must not be considered a public/external destination. // range (see reservedIPNets) that must not be considered a public/external destination.
func isPrivateIP(ip net.IP) bool { func isReservedIP(ip net.IP) bool {
if ip.IsPrivate() {
return true
}
for _, ipNet := range reservedIPNets() { for _, ipNet := range reservedIPNets() {
if ipNet.Contains(ip) { if ipNet.Contains(ip) {
return true return true
@@ -153,18 +150,22 @@ func (hl *HostMatchList) checkPattern(host string) bool {
return false return false
} }
func (hl *HostMatchList) checkIP(ip net.IP) bool { // matchesIP determines if the given IP matches any of the configured rules
func (hl *HostMatchList) matchesIP(ip net.IP) bool {
if slices.Contains(hl.patterns, "*") { if slices.Contains(hl.patterns, "*") {
return true return true
} }
for _, builtin := range hl.builtins { for _, builtin := range hl.builtins {
switch builtin { switch builtin {
case MatchBuiltinExternal: case MatchBuiltinExternal:
if ip.IsGlobalUnicast() && !isPrivateIP(ip) { // External address must be a global unicast, must not be in reserved range and must not be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
return true return true
} }
case MatchBuiltinPrivate: case MatchBuiltinPrivate:
if isPrivateIP(ip) { // Private address must be global unicast, must not be in range we explicitly exclude for security reasons
// and must be in private range
if ip.IsGlobalUnicast() && !isReservedIP(ip) && ip.IsPrivate() {
return true return true
} }
case MatchBuiltinLoopback: case MatchBuiltinLoopback:
@@ -195,7 +196,7 @@ func (hl *HostMatchList) MatchHostName(host string) bool {
return true return true
} }
if ip := net.ParseIP(hostname); ip != nil { if ip := net.ParseIP(hostname); ip != nil {
return hl.checkIP(ip) return hl.matchesIP(ip)
} }
return false return false
} }
@@ -206,7 +207,7 @@ func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {
return false return false
} }
host := ip.String() // nil-safe, we will get "<nil>" if ip is nil host := ip.String() // nil-safe, we will get "<nil>" if ip is nil
return hl.checkPattern(host) || hl.checkIP(ip) return hl.checkPattern(host) || hl.matchesIP(ip)
} }
// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list // MatchHostOrIP checks if the host or IP matches an allow/deny(block) list
+3 -1
View File
@@ -202,15 +202,17 @@ func TestReservedRanges(t *testing.T) {
"198.18.0.1", // benchmarking "198.18.0.1", // benchmarking
"198.51.100.1", // TEST-NET-2 "198.51.100.1", // TEST-NET-2
"203.0.113.1", // TEST-NET-3 "203.0.113.1", // TEST-NET-3
"169.254.169.254", // Cloud metadata
"192.88.99.1", // 6to4 relay anycast "192.88.99.1", // 6to4 relay anycast
"64:ff9b::1", // NAT64 "64:ff9b::1", // NAT64
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254 "64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
"2001::1", // Teredo "2001::1", // Teredo
"2002::1", // 6to4 "2002::1", // 6to4
"2001:db8::1", // documentation "2001:db8::1", // documentation
"fe80::1", // link local address
} { } {
addr := net.ParseIP(ip) addr := net.ParseIP(ip)
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip) assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip) assert.Falsef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
} }
} }
+18 -2
View File
@@ -5,9 +5,12 @@ package lfs
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"code.gitea.io/gitea/modules/util"
) )
// DownloadCallback gets called for every requested LFS object to process its content // DownloadCallback gets called for every requested LFS object to process its content
@@ -23,10 +26,23 @@ type Client interface {
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
} }
// NewClient creates a LFS client // newClient creates a LFS client
func NewClient(endpoint *url.URL, httpTransport *http.Transport) Client { func newClient(endpoint *url.URL, httpTransport *http.Transport) Client {
if endpoint.Scheme == "file" { if endpoint.Scheme == "file" {
return newFilesystemClient(endpoint) return newFilesystemClient(endpoint)
} }
return newHTTPClient(endpoint, httpTransport) return newHTTPClient(endpoint, httpTransport)
} }
// NewClientFromEndpoint creates a LFS client after resolving its endpoint.
func NewClientFromEndpoint(cloneurl, lfsurl string, httpTransport *http.Transport) (Client, error) {
endpoint := DetermineEndpoint(cloneurl, lfsurl)
if endpoint == nil {
source := cloneurl
if lfsurl != "" {
source = lfsurl
}
return nil, fmt.Errorf("unable to determine LFS endpoint from %q", util.SanitizeCredentialURLs(source))
}
return newClient(endpoint, httpTransport), nil
}
+13 -2
View File
@@ -12,10 +12,21 @@ import (
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
u, _ := url.Parse("file:///test") u, _ := url.Parse("file:///test")
c := NewClient(u, nil) c := newClient(u, nil)
assert.IsType(t, &FilesystemClient{}, c) assert.IsType(t, &FilesystemClient{}, c)
u, _ = url.Parse("https://test.com/lfs") u, _ = url.Parse("https://test.com/lfs")
c = NewClient(u, nil) c = newClient(u, nil)
assert.IsType(t, &HTTPClient{}, c) assert.IsType(t, &HTTPClient{}, c)
} }
func TestNewClientFromEndpoint(t *testing.T) {
client, err := NewClientFromEndpoint("ssh://git@example.com/owner/repo.git", "", nil)
assert.NoError(t, err)
assert.NotNil(t, client)
client, err = NewClientFromEndpoint("ftp://example.com/owner/repo.git", "", nil)
assert.Nil(t, client)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unable to determine LFS endpoint")
}
+13 -1
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
giturl "code.gitea.io/gitea/modules/git/url"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
@@ -44,15 +45,20 @@ func endpointFromCloneURL(rawurl string) *url.URL {
} }
func endpointFromURL(rawurl string) *url.URL { func endpointFromURL(rawurl string) *url.URL {
if rawurl == "" {
return nil
}
if strings.HasPrefix(rawurl, "/") { if strings.HasPrefix(rawurl, "/") {
return endpointFromLocalPath(rawurl) return endpointFromLocalPath(rawurl)
} }
u, err := url.Parse(rawurl) gitURL, err := giturl.ParseGitURL(rawurl)
if err != nil { if err != nil {
log.Error("lfs.endpointFromUrl: %v", err) log.Error("lfs.endpointFromUrl: %v", err)
return nil return nil
} }
u := gitURL.URL
switch u.Scheme { switch u.Scheme {
case "http", "https": case "http", "https":
@@ -60,6 +66,12 @@ func endpointFromURL(rawurl string) *url.URL {
case "git": case "git":
u.Scheme = "https" u.Scheme = "https"
return u return u
case "ssh", "git+ssh":
u.Scheme = "https" // is it possible http?
u.Host = u.Hostname() // remove ssh port if any
u.Path = "/" + strings.TrimPrefix(u.Path, "/")
u.User = nil
return u
case "file": case "file":
return u return u
default: default:
+18
View File
@@ -64,6 +64,24 @@ func TestDetermineEndpoint(t *testing.T) {
lfsurl: "git://gitlfs.com/repo", lfsurl: "git://gitlfs.com/repo",
expected: str2url("https://gitlfs.com/repo"), expected: str2url("https://gitlfs.com/repo"),
}, },
// case 7
{
cloneurl: "ssh://git@git.com/owner/repo.git",
lfsurl: "",
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
},
// case 8
{
cloneurl: "git@git.com:owner/repo.git",
lfsurl: "",
expected: str2url("https://git.com/owner/repo.git/info/lfs"),
},
// case 9
{
cloneurl: "",
lfsurl: "ssh://git@gitlfs.com/owner/repo.git/info/lfs",
expected: str2url("https://gitlfs.com/owner/repo.git/info/lfs"),
},
} }
for n, c := range cases { for n, c := range cases {
+73 -35
View File
@@ -504,41 +504,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
} }
} }
// reqTeamMembership user should be an team member, or a site admin // 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
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("teamAccess: unprepared context")
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
return 0, false, false
}
orgID = ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
} else if isOwner {
return orgID, true, true
}
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return 0, false, false
}
return orgID, isTeamMember, true
}
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
}
// reqTeamReadAccess allows callers who can list the team to read its metadata.
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
func reqTeamReadAccess() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
orgID, privileged, ok := teamAccessPrivileged(ctx)
if !ok || privileged {
return
}
denyNonTeamMember(ctx, orgID)
}
}
// reqTeamMembership user should be a team member, or a site admin
func reqTeamMembership() func(ctx *context.APIContext) { func reqTeamMembership() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) { return func(ctx *context.APIContext) {
if ctx.IsUserSiteAdmin() { orgID, privileged, ok := teamAccessPrivileged(ctx)
return if !ok || privileged {
}
if ctx.Org.Team == nil {
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
return
}
orgID := ctx.Org.Team.OrgID
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
} else if isOwner {
return
}
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
ctx.APIErrorInternal(err)
return
} else if !isTeamMember {
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
if err != nil {
ctx.APIErrorInternal(err)
} else if isOrgMember {
ctx.APIError(http.StatusForbidden, "Must be a team member")
} else {
ctx.APIErrorNotFound()
}
return return
} }
denyNonTeamMember(ctx, orgID)
} }
} }
@@ -1662,7 +1700,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)
@@ -1699,12 +1737,12 @@ func Routes() *web.Router {
m.Group("/repos", func() { m.Group("/repos", func() {
m.Get("", reqToken(), org.GetTeamRepos) m.Get("", reqToken(), org.GetTeamRepos)
m.Combo("/{org}/{reponame}"). m.Combo("/{org}/{reponame}").
Put(reqToken(), org.AddTeamRepository). Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
Delete(reqToken(), org.RemoveTeamRepository). Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository).
Get(reqToken(), org.GetTeamRepo) Get(reqToken(), org.GetTeamRepo)
}) })
m.Get("/activities/feeds", org.ListTeamActivityFeeds) m.Get("/activities/feeds", org.ListTeamActivityFeeds)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamReadAccess(), checkTokenPublicOnly())
m.Group("/admin", func() { m.Group("/admin", func() {
m.Group("/cron", func() { m.Group("/cron", func() {
+3
View File
@@ -1268,6 +1268,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) ctx.APIError(http.StatusNotFound, err)
return return
} else if errors.Is(err, util.ErrPermissionDenied) {
ctx.APIError(http.StatusForbidden, err.Error())
return
} }
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
+27 -19
View File
@@ -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() {
return false 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.canWriteCodeUnitCached = &canWrite
ctx.checkedCanWriteCode = true
} }
return ctx.canWriteCode return *ctx.canWriteCodeUnitCached
} }
// AssertCanWriteCode returns true if pusher can write code // canWriteCodeRef returns true if pusher can write to the code ref (branch/tag/commit)
func (ctx *preReceiveContext) AssertCanWriteCode() bool { func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if !ctx.CanWriteCode() { 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() { 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
} }
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/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")))
}
+3 -9
View File
@@ -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
+14 -2
View File
@@ -368,9 +368,21 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
opts := &user_service.UpdateOptions{} opts := &user_service.UpdateOptions{}
// Reactivate user if they are deactivated // HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION: see services/auth/source/oauth2/source_sync.go
// Reactivate user only if they were disabled by the OAuth2 auto sync cron (invalid_grant),
// which clears AccessToken/RefreshToken/ExpiresAt on the ExternalLoginUser row
// An admin-disabled user has no such signature, so we leave IsActive alone
// and let verifyAuthWithOptions route them through the prohibit-login / activate page.
if !u.IsActive { if !u.IsActive {
opts.IsActive = optional.Some(true) extLogin, hasExt, err := user_model.GetExternalLogin(ctx, authSource.ID, gothUser.UserID)
if err != nil {
ctx.ServerError("GetExternalLogin", err)
return
}
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
} }
// Update GroupClaims // Update GroupClaims
+3 -9
View File
@@ -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
}
} }
} }
+1 -1
View File
@@ -261,7 +261,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) {
+22 -21
View File
@@ -58,8 +58,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
// httpBase does the common work for git http services, // httpBase does the common work for git http services,
// including early response, authentication, repository lookup and permission check. // including early response, authentication, repository lookup and permission check.
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler { func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if ctx.FormString("go-get") == "1" { if ctx.FormString("go-get") == "1" {
context.EarlyResponseForGoGetMeta(ctx) context.EarlyResponseForGoGetMeta(ctx)
return nil return nil
@@ -93,11 +91,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
isWiki := false isWiki := false
unitType := unit.TypeCode unitType := unit.TypeCode
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if strings.HasSuffix(reponame, ".wiki") { if strings.HasSuffix(repoName, ".wiki") {
isWiki = true isWiki = true
unitType = unit.TypeWiki unitType = unit.TypeWiki
reponame = reponame[:len(reponame)-5] repoName = repoName[:len(repoName)-5]
} }
owner := ctx.ContextUser owner := ctx.ContextUser
@@ -107,14 +105,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
} }
repoExist := true repoExist := true
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, reponame) repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
if err != nil { if err != nil {
if !repo_model.IsErrRepoNotExist(err) { if !repo_model.IsErrRepoNotExist(err) {
ctx.ServerError("GetRepositoryByName", err) ctx.ServerError("GetRepositoryByName", err)
return nil return nil
} }
if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, reponame); err == nil { if redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName); err == nil {
context.RedirectToRepo(ctx.Base, redirectRepoID) context.RedirectToRepo(ctx.Base, redirectRepoID)
return nil return nil
} }
@@ -127,23 +125,26 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil return nil
} }
// Only public pull don't need auth. // Only public pulls don't need auth: repo must exist, not require-sign-in
isPublicPull := repoExist && !repo.IsPrivate && isPull canAnonymousPull := false
askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict if isPull && repoExist && !setting.Service.RequireSignInViewStrict {
// allow anonymous pulls if owner is public and repo is public (not private)
// don't allow anonymous pulls if organization is not public if owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
if isPublicPull { canAnonymousPull = true
if err := repo.LoadOwner(ctx); err != nil { }
ctx.ServerError("LoadOwner", err) // then check "public anonymous access" permission
return nil if !canAnonymousPull && ctx.Doer == nil {
anonPerm, err := access_model.GetDoerRepoPermission(ctx, repo, nil)
if err != nil {
ctx.ServerError("GetDoerRepoPermission", err)
return nil
}
canAnonymousPull = anonPerm.CanAccess(accessMode, unitType)
} }
askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
} }
// check access // check access
if askAuth { if !canAnonymousPull { // not public pull, then either the pull needs auth, or the push needs "write" permission, so ask auth
// rely on the results of Contexter
if !ctx.IsSigned { if !ctx.IsSigned {
// TODO: support digit auth - which would be Authorization header with digit // TODO: support digit auth - which would be Authorization header with digit
if setting.OAuth2.Enabled { if setting.OAuth2.Enabled {
@@ -229,7 +230,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil return nil
} }
repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, repoName)
if err != nil { if err != nil {
log.Error("pushCreateRepo: %v", err) log.Error("pushCreateRepo: %v", err)
ctx.Status(http.StatusNotFound) ctx.Status(http.StatusNotFound)
+1
View File
@@ -145,6 +145,7 @@ func NewIssue(ctx *context.Context) {
} }
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues)
ctx.Data["IsIssuePoster"] = true // the current user will be the poster of the new issue
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
+2 -1
View File
@@ -176,7 +176,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")
+2 -2
View File
@@ -88,8 +88,8 @@ func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *us
} }
} }
// Delete stored tokens, since they are invalid. This // HINT: OAUTH-AUTO-SYNC-USER-ACTIVATION
// also provents us from checking this in subsequent runs. // Delete stored tokens, since they are invalid. This also prevents us from checking this in subsequent runs.
u.AccessToken = "" u.AccessToken = ""
u.RefreshToken = "" u.RefreshToken = ""
u.ExpiresAt = time.Time{} u.ExpiresAt = time.Time{}
+4 -3
View File
@@ -172,9 +172,10 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
if m.LFS && setting.LFS.StartServer { if m.LFS && setting.LFS.StartServer {
log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo) log.Trace("SyncMirrors [repo: %-v]: syncing LFS objects...", m.Repo)
endpoint := lfs.DetermineEndpoint(remoteURL.String(), m.LFSEndpoint) lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), m.LFSEndpoint, migrations.NewMigrationHTTPTransport())
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport()) if err != nil {
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil { log.Error("SyncMirrors [repo: %-v]: failed to initialize LFS client: %v", m.Repo.FullName(), err)
} else if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, m.Repo, gitRepo, lfsClient); err != nil {
log.Error("SyncMirrors [repo: %-v]: failed to synchronize LFS objects for repository: %v", m.Repo.FullName(), err) log.Error("SyncMirrors [repo: %-v]: failed to synchronize LFS objects for repository: %v", m.Repo.FullName(), err)
} }
} }
+4 -2
View File
@@ -144,8 +144,10 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
} }
defer gitRepo.Close() defer gitRepo.Close()
endpoint := lfs.DetermineEndpoint(remoteURL.String(), "") lfsClient, err := lfs.NewClientFromEndpoint(remoteURL.String(), "", migrations.NewMigrationHTTPTransport())
lfsClient := lfs.NewClient(endpoint, migrations.NewMigrationHTTPTransport()) if err != nil {
return err
}
if err := pushAllLFSObjects(ctx, gitRepo, lfsClient); err != nil { if err := pushAllLFSObjects(ctx, gitRepo, lfsClient); err != nil {
return util.SanitizeErrorCredentialURLs(err) return util.SanitizeErrorCredentialURLs(err)
} }
+13
View File
@@ -8,7 +8,9 @@ import (
"fmt" "fmt"
issue_model "code.gitea.io/gitea/models/issues" issue_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/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
+4 -2
View File
@@ -159,8 +159,10 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
} }
if opts.LFS { if opts.LFS {
endpoint := lfs.DetermineEndpoint(opts.CloneAddr, opts.LFSEndpoint) lfsClient, err := lfs.NewClientFromEndpoint(opts.CloneAddr, opts.LFSEndpoint, httpTransport)
lfsClient := lfs.NewClient(endpoint, httpTransport) if err != nil {
return repo, fmt.Errorf("NewClientFromEndpoint: %w", err)
}
if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil { if err = repo_module.StoreMissingLfsObjectsInRepository(ctx, repo, gitRepo, lfsClient); err != nil {
log.Error("Failed to store missing LFS objects for repository: %v", err) log.Error("Failed to store missing LFS objects for repository: %v", err)
return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err) return repo, fmt.Errorf("StoreMissingLfsObjectsInRepository: %w", err)
@@ -14,14 +14,15 @@ Still needs to figure out:
* Is "GitHub-like development sidebar (`#31899`)" good enough (or better) for your usage? * Is "GitHub-like development sidebar (`#31899`)" good enough (or better) for your usage?
*/}} */}}
{{if and (not .Issue.IsPull) (not .PageIsComparePull)}} {{if and (not .Issue.IsPull) (not .PageIsComparePull)}}
{{$canChangeRef := or .IsIssuePoster .HasIssuesOrPullsWritePermission}}
<input id="ref_selector" name="ref" type="hidden" value="{{.Reference}}"> <input id="ref_selector" name="ref" type="hidden" value="{{.Reference}}">
<div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}}" <div class="ui dropdown select-branch branch-selector-dropdown ellipsis-text-items {{if not $canChangeRef}}disabled{{end}}"
data-no-results="{{ctx.Locale.Tr "no_results_found"}}" data-no-results="{{ctx.Locale.Tr "no_results_found"}}"
{{if and .Issue (or .IsIssueWriter .HasIssuesOrPullsWritePermission)}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}} {{if and .Issue $canChangeRef}}data-url-update-issueref="{{$.RepoLink}}/issues/{{.Issue.Index}}/ref"{{end}}
> >
<div class="ui button branch-dropdown-button"> <div class="ui button branch-dropdown-button">
<span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span> <span class="text-branch-name gt-ellipsis">{{if .Reference}}{{$.RefEndName}}{{else}}{{ctx.Locale.Tr "repo.issues.no_ref"}}{{end}}</span>
{{if .HasIssuesOrPullsWritePermission}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}} {{if $canChangeRef}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}{{end}}
</div> </div>
<div class="menu"> <div class="menu">
<div class="ui icon search input"> <div class="ui icon search input">
+48
View File
@@ -10,6 +10,7 @@ import (
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
org_model "code.gitea.io/gitea/models/organization" org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
unit_model "code.gitea.io/gitea/models/unit" unit_model "code.gitea.io/gitea/models/unit"
@@ -260,3 +261,50 @@ func TestAPIOrgGeneral(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden) MakeRequest(t, req, http.StatusForbidden)
}) })
} }
// 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.
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)
})
}
+6
View File
@@ -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) {
+113
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
@@ -23,6 +24,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"xorm.io/builder"
) )
func TestOIDCIgnoresStaleExternalLoginLinks(t *testing.T) { func TestOIDCIgnoresStaleExternalLoginLinks(t *testing.T) {
@@ -135,6 +137,117 @@ func newFakeOIDCServerWithProfile(t *testing.T, sub, oid, email, name string) *h
return srv return srv
} }
// TestOAuth2CallbackReactivationGating exercises the gate in handleOAuth2SignIn:
// an inactive user can only be reactivated when who was disabled by auto-sync
func TestOAuth2CallbackReactivationGating(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&setting.OAuth2Client.Username, setting.OAuth2UsernameUserid)()
srv := newFakeOIDCServer(t, FakeOIDCConfig{Sub: "test-sub", Email: "test@example.com", Name: "Test User"})
addOAuth2Source(t, "test-oauth-source", oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
OpenIDConnectAutoDiscoveryURL: srv.URL + "/.well-known/openid-configuration",
})
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "test-oauth-source")
require.NoError(t, err)
u := &user_model.User{Name: "test-user", Email: "test@example.com"}
require.NoError(t, user_model.CreateUser(t.Context(), u, &user_model.Meta{}))
extLink := &user_model.ExternalLoginUser{
UserID: u.ID,
LoginSourceID: authSource.ID,
Provider: authSource.Name,
ExternalID: "test-sub",
}
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, extLink))
prepareUserExternalLink := func(t *testing.T, refreshToken string) {
err := user_model.UpdateUserCols(t.Context(), &user_model.User{ID: u.ID, IsActive: false}, "is_active")
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("refresh_token").
Update(&user_model.ExternalLoginUser{RefreshToken: refreshToken})
require.NoError(t, err)
require.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID}).IsActive)
}
t.Run("admin-disabled user is not reactivated", func(t *testing.T) {
prepareUserExternalLink(t, "non-empty-refresh-token")
doOIDCSignIn(t, authSource.Name)
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
assert.False(t, after.IsActive, "OAuth callback must not re-enable an administrator-disabled account")
})
t.Run("auto-sync-disabled user is reactivated", func(t *testing.T) {
prepareUserExternalLink(t, "" /* empty refresh token */)
doOIDCSignIn(t, authSource.Name)
after := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: u.ID})
assert.True(t, after.IsActive, "OAuth callback must reactivate a sync-disabled account on successful login")
})
}
// FakeOIDCConfig holds configuration for the fake OIDC server used in tests.
type FakeOIDCConfig struct {
Sub string
OID string
Email string
Name string
Groups []string
}
// newFakeOIDCServer starts a httptest.Server that implements the minimum OIDC endpoints needed to complete a sign-in flow
func newFakeOIDCServer(t *testing.T, cfg FakeOIDCConfig) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/.well-known/openid-configuration": // discovery document
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"userinfo_endpoint": srv.URL + "/userinfo",
})
case "/token": // returns an ID token with both "sub" and "oid" claims so tests can verify which one ends up as ExternalID
claims := map[string]any{
"iss": srv.URL,
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"sub": cfg.Sub,
"oid": cfg.OID,
}
payload, _ := json.Marshal(claims)
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
// build a JWT-shaped string whose payload encodes claims.
// goth's decodeJWT only base64-decodes the payload without verifying the signature, so no real signing infrastructure is needed.
idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig"
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "fake-access-token",
"token_type": "Bearer",
"id_token": idToken,
})
case "/userinfo":
// sub MUST match the id_token sub; goth rejects mismatches.
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": cfg.Sub,
"email": cfg.Email,
"name": cfg.Name,
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
// doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source. // doOIDCSignIn runs a mock OIDC sign-in flow for the given auth source.
func doOIDCSignIn(t *testing.T, sourceName string) { func doOIDCSignIn(t *testing.T, sourceName string) {
t.Helper() t.Helper()
+34
View File
@@ -10,7 +10,9 @@ import (
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
@@ -26,6 +28,8 @@ func TestGitSmartHTTP(t *testing.T) {
testGitSmartHTTPTokenScopes(t) testGitSmartHTTPTokenScopes(t)
testRenamedRepoRedirect(t) testRenamedRepoRedirect(t)
testGitArchiveRemote(t, u) testGitArchiveRemote(t, u)
t.Run("AnonymousAccess-Repo", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, false) })
t.Run("AnonymousAccess-Wiki", func(t *testing.T) { testGitSmartHTTPPrivateRepoAnonymousAccess(t, true) })
}) })
} }
@@ -144,3 +148,33 @@ func testGitArchiveRemote(t *testing.T, u *url.URL) {
t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test")) t.Run("Fetch HEAD archive subpath", doGitRemoteArchive(u.String(), "HEAD", "test"))
t.Run("list compression options", doGitRemoteArchive(u.String(), "--list")) t.Run("list compression options", doGitRemoteArchive(u.String(), "--list"))
} }
// testGitSmartHTTPPrivateRepoAnonymousAccess tests that a private repo with
// anonymous code access enabled can be cloned without credentials.
func testGitSmartHTTPPrivateRepoAnonymousAccess(t *testing.T, isWiki bool) {
// repo1 (ID=1) belongs to user2 and is public by default in fixtures
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerName: "user2", Name: "repo1"})
unitType := util.Iif(isWiki, unit.TypeWiki, unit.TypeCode)
repoLink := "/" + repo.FullName() + util.Iif(isWiki, ".wiki", "")
gitPullPath := repoLink + "/info/refs?service=git-upload-pack"
gitPushPath := repoLink + "/info/refs?service=git-receive-pack"
// make the repo private
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), &repo_model.Repository{ID: repo.ID, IsPrivate: true}, "is_private"))
// without anonymous access: anonymous pull must require auth
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
// enable anonymous read access on the unit
require.NoError(t, repo_model.UpdateRepoUnitPublicAccess(t.Context(), &repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, AnonymousAccessMode: perm.AccessModeRead}))
// with anonymous code access: anonymous pull must succeed without credentials
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusOK)
// push (receive-pack) must still require auth even with anonymous code access
MakeRequest(t, NewRequest(t, "GET", gitPushPath), http.StatusUnauthorized)
// RequireSignInViewStrict must override anonymous access
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
MakeRequest(t, NewRequest(t, "GET", gitPullPath), http.StatusUnauthorized)
}
+53
View File
@@ -682,6 +682,59 @@ func TestUpdateIssueDeadline(t *testing.T) {
assert.True(t, issueAfter.DeadlineUnix.IsZero()) assert.True(t, issueAfter.DeadlineUnix.IsZero())
} }
func TestUpdateIssueRefByPoster(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 is a non-admin, non-collaborator on user2/repo1.
// They create an issue, making them the poster.
posterSession := loginUser(t, "user4")
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Poster ref test", "body")
refURL := issueURL + "/ref"
// The poster (non-collaborator) must be able to update the ref.
req := NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
posterSession.MakeRequest(t, req, http.StatusOK)
// A different non-collaborator non-poster must be forbidden.
otherSession := loginUser(t, "user5")
req = NewRequestWithValues(t, "POST", refURL, map[string]string{"ref": "refs/heads/main"})
otherSession.MakeRequest(t, req, http.StatusForbidden)
}
func TestIssueRefSelectorEnabledForPoster(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 creates an issue in user2/repo1 (user4 has no write permission there).
posterSession := loginUser(t, "user4")
issueURL := testNewIssue(t, posterSession, "user2", "repo1", "Ref selector test", "body")
resp := posterSession.MakeRequest(t, NewRequest(t, "GET", issueURL), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// The branch selector must not carry the "disabled" CSS class for the poster.
sel := htmlDoc.Find(".branch-selector-dropdown")
assert.Equal(t, 1, sel.Length())
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled for the issue poster")
// The update-ref URL must be present so JS can send the POST request.
_, hasURL := sel.Attr("data-url-update-issueref")
assert.True(t, hasURL, "data-url-update-issueref must be set for the issue poster")
}
func TestIssueRefSelectorEnabledForNewIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user4 (non-collaborator on user2/repo1) must see an enabled ref selector
// when creating a new issue.
session := loginUser(t, "user4")
req := NewRequest(t, "GET", "/user2/repo1/issues/new")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
sel := htmlDoc.Find(".branch-selector-dropdown")
assert.Equal(t, 1, sel.Length())
assert.False(t, sel.HasClass("disabled"), "branch selector should be enabled on the new issue form")
}
func TestIssueReferenceURL(t *testing.T) { func TestIssueReferenceURL(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2") session := loginUser(t, "user2")
@@ -173,5 +173,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)
})
}) })
} }
+1 -1
View File
@@ -4,7 +4,7 @@ RUN_MODE = prod
[database] [database]
DB_TYPE = sqlite3 DB_TYPE = sqlite3
PATH = gitea.db PATH = gitea-test.db
[indexer] [indexer]
REPO_INDEXER_ENABLED = true REPO_INDEXER_ENABLED = true