mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-16 01:43:24 +09:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ba37d9af | ||
|
|
db23633874 | ||
|
|
2bde4fa5d2 | ||
|
|
4aa0d704f2 | ||
|
|
b0eaf6c75b | ||
|
|
42eb7945d1 | ||
|
|
0b9623e188 | ||
|
|
6fb96fcfdf | ||
|
|
99f8b3d9a1 |
@@ -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
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ ROOT_PATH = /data/gitea/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ ROOT_PATH = $GITEA_WORK_DIR/data/log
|
|||||||
[security]
|
[security]
|
||||||
INSTALL_LOCK = $INSTALL_LOCK
|
INSTALL_LOCK = $INSTALL_LOCK
|
||||||
SECRET_KEY = $SECRET_KEY
|
SECRET_KEY = $SECRET_KEY
|
||||||
REVERSE_PROXY_LIMIT = 1
|
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
DISABLE_REGISTRATION = $DISABLE_REGISTRATION
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user