Compare commits

...
8 Commits
Author SHA1 Message Date
bircniandGitHub b969123b7f chore: don't auto refresh the merge box when user has interacted with it (#38436)
Backport #38435

Otherwise, the user just isn't able to use "auto merge" form
2026-07-13 02:54:47 -07:00
bircniandGitHub c6627af968 docs: update changelog for 1.27.0 (#38427) 2026-07-13 06:31:20 +02:00
de4b8277e9 fix: various security fixes (#38406) (#38426)
Backport #38406 by @bircni

Addresses a batch of privately reported security issues, grouped by
area:

- **SSRF** - migration PR-patch/asset fetches, OAuth2 avatar & OpenID
discovery, pull-mirror URL re-validation, and the outbound proxy path.
- **Access-token scope** - prevent scope escalation on token creation;
keep public-only tokens confined (feeds, packages, Actions listings,
star/watch lists, limited/private owners).
- **Access control / disclosure** - go-get default-branch leak, webhook
authorization-header leak, watch clearing on private transitions,
label/attachment scoping.
- **Denial of service** - input bounds for npm dist-tags, Debian control
files, Arch file lists, and SSH keys.

### 📌 Attention for site admins

Not breaking - existing configs keep working - but two changes are worth
a look:

- **New SSRF protection** Outbound requests (migrations, OAuth2 avatars,
OpenID discovery, pull mirrors, proxy path) are now validated against
the allow/block host lists. If your instance legitimately reaches
internal hosts, you may need to add them to
`[security].ALLOWED_HOST_LIST` (and the relevant `ALLOW_LOCALNETWORKS`
settings).
- **Deprecation** `[webhook].ALLOWED_HOST_LIST` is deprecated and will
be removed in a future release. Use `[security].ALLOWED_HOST_LIST`
instead; the old key still works for now.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-12 17:41:58 +00:00
acbc75e2bd fix(util): reject invalid characters between time-estimate units (#38416) (#38423)
Backport #38416 by @TowyTowy

### What / why

`TimeEstimateParse` (used by the issue time-estimate form) only checked
that the first token starts at the beginning of the string and the last
token ends at its end, but never checked the gaps between consecutive
tokens. Non-whitespace garbage embedded between two valid units was
silently dropped and the string accepted with a wrong value instead of
being reported as invalid.

Examples that were wrongly accepted before this change:

- `1h 2x 3m` → 3780s (parsed as 1h3m)
- `1h_2m`    → 3720s
- `1h,1m`    → 3660s

All three now return an "invalid time string" error, while valid inputs
such as `1h 1m 1s` and `1h1m1s` keep working.

### How

Reject any non-whitespace content between two matched units.

Signed-off-by: TowyTowy <towy@airreps.link>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: TowyTowy <85077986+TowyTowy@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 09:59:28 -07:00
c3325be816 fix: represent a deleted assignee team as a Ghost team (#38413) (#38419)
Backport #38413

Fixes #35472.

Co-authored-by: Harsh Satyajit Thakur <f20240223@goa.bits-pilani.ac.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 13:06:24 +00:00
d4250bafd9 fix(turnstile): route CAPTCHA verification through the configured proxy (#38412) (#38420)
Backport #38412

Fixes #38217

Co-authored-by: Lovepreet Singh <lovepreet.singh61182@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 05:39:14 -07:00
6815d1646c fix: refresh pull request merge box when the commit status is pending (#38410) (#38411)
Backport #38410

Before: the merge box is only refreshed when the PR is being
conflict-checked.

After: also refresh the merge box when the commit status is pending
(e.g.: Action task is running)

By the way, slightly increase the refresh interval to 5s

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-12 07:15:25 +08:00
8ba4d4ebec fix: actions task state concurrent update (#38405) (#38409)
Backport #38405

fix #38333

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-11 16:51:32 +02:00
105 changed files with 2013 additions and 190 deletions
+36 -1
View File
@@ -4,13 +4,23 @@ 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
been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0-rc0) - 2026-06-28
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
* BREAKING
* Feat(actions)!: improve support for reusable workflows (#37478)
* Use Content-Security-Policy: script nonce (#37232)
* SECURITY
* Fix: various security fixes (#38406) (#38426)
* Fix(security): harden access checks and migration validation (#38324) (#38400)
* Fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)
* Fix(pull): re-evaluate review official flag on target branch change (#38319) (#38402)
* Fix(api): stop leaking private repo metadata after access revocation (#38321) (#38390)
* Fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
* Fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
* Fix: golang html template url escaping (#38363) (#38369)
* Fix(release): validate web attachment renames against allowed types (#38314) (#38328)
* Fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
* Fix(oauth): restrict introspection to the token's client (#38042)
* Fix(api): don't expose private org membership via public_members (#38145)
@@ -44,6 +54,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Feat(ssh): auto generate additional ssh keys (#33974)
* ENHANCEMENTS
* Enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
* Enhance: allow builtin default git config options to be overridden (#38172)
* Enhance: allow MathML core elements (#38034)
* Enhance(markup): improve issue title rendering (#37908)
@@ -71,12 +82,34 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Add API endpoint to reply to pull request review comments (#36683)
* PERFORMANCE
* Perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
* Perf: Various performance regression fixes (#38078)
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
* Batch-load related data in actions run, job, and task API endpoints (#37032)
* BUGFIXES
* Fix(util): reject invalid characters between time-estimate units (#38416) (#38423)
* Fix: represent a deleted assignee team as a Ghost team (#38413) (#38419)
* Fix(turnstile): route CAPTCHA verification through the configured proxy (#38412) (#38420)
* Fix: refresh pull request merge box when the commit status is pending (#38410) (#38411)
* Fix: actions task state concurrent update (#38405) (#38409)
* Fix(actions): keep workflow run trailing on one row with long branch names (#38382) (#38403)
* Fix(web): use locale-aware date formatting for contribution calendar tooltips (#38398) (#38401)
* Fix: co-author detection (#38392) (#38397)
* Fix: incorrect co-author detection on commit page (#38386) (#38387)
* Fix(ui): restore commits table column widths (#38379) (#38383)
* Fix: minio init check (#38355) (#38361)
* Fix: org project view assignee list (#38357) (#38360)
* Fix(actions): release claimed task if context is cancelled during `FetchTask` (#38343) (#38347)
* Fix(actions): make runner list pagination order deterministic (#38313) (#38327)
* Fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
* Fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
* Fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302)
* Fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
* Fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
* Fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
* Fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
* Fix: update npm dependencies, fix misc issues (#38257)
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
* Fix: codemirror regressions (#38248)
@@ -134,6 +167,8 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Refactor flash message and remove SanitizeHTML template func (#37179)
* TESTING
* Test(e2e): fix race in pdf file render test (#38380) (#38381)
* Test: compare key file contents instead of `FileInfo` in `TestInitKeys` (#38330) (#38331)
* Test: speed up two tests (#37905)
* Test: Fix random failure test (#37887)
* Test: fix flaky `issue-comment` close test (#37880)
+7 -7
View File
@@ -536,6 +536,13 @@ INTERNAL_TOKEN =
;; Leave it empty to apply the default policy, or set it to "unset" to disable Content-Security-Policy.
;CONTENT_SECURITY_POLICY_GENERAL =
;; Webhook and oauth2 clients can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
;ALLOWED_HOST_LIST = external
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[camo]
@@ -1754,13 +1761,6 @@ LEVEL = Info
;; Deliver timeout in seconds
;DELIVER_TIMEOUT = 5
;;
;; Webhook can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
;; Since 1.15.7. Default to * for 1.15.x, external for 1.16 and later
;ALLOWED_HOST_LIST = external
;;
;; Allow insecure certification
;SKIP_TLS_VERIFY = false
;;
+7
View File
@@ -99,6 +99,10 @@ type FindRunJobOptions struct {
UpdatedBefore timeutil.TimeStamp
ConcurrencyGroup string
OrderBy db.SearchOrderBy
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
AccessibleRepoIDsSubQuery *builder.Builder
}
var JobOrderByMap = map[string]map[string]db.SearchOrderBy{
@@ -132,6 +136,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
}
cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup})
}
if opts.AccessibleRepoIDsSubQuery != nil {
cond = cond.And(builder.In("`action_run_job`.repo_id", opts.AccessibleRepoIDsSubQuery))
}
return cond
}
+7
View File
@@ -70,6 +70,10 @@ type FindRunOptions struct {
Status []Status
ConcurrencyGroup string
CommitSHA string
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
AccessibleRepoIDsSubQuery *builder.Builder
}
func (opts FindRunOptions) ToConds() builder.Cond {
@@ -101,6 +105,9 @@ func (opts FindRunOptions) ToConds() builder.Cond {
if opts.CommitSHA != "" {
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
}
if opts.AccessibleRepoIDsSubQuery != nil {
cond = cond.And(builder.In("`action_run`.repo_id", opts.AccessibleRepoIDsSubQuery))
}
return cond
}
+35 -17
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/unit"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/globallock"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
@@ -438,6 +439,18 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
return err
}
func getRunIDByTaskID(ctx context.Context, taskID int64) (runID int64, _ error) {
if has, err := db.GetEngine(ctx).Cols("action_run_job.run_id").
Table("action_task").
Join("INNER", "action_run_job", "action_run_job.id = action_task.job_id").
Where(builder.Eq{"action_task.id": taskID}).Get(&runID); err != nil {
return runID, err
} else if !has {
return runID, util.ErrNotExist
}
return runID, nil
}
// UpdateTaskByState updates the task by the state.
// It will always update the task if the state is not final, even there is no change.
// So it will update ActionTask.Updated to avoid the task being judged as a zombie task.
@@ -447,21 +460,26 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
stepStates[v.Id] = v
}
return db.WithTx2(ctx, func(ctx context.Context) (*ActionTask, error) {
e := db.GetEngine(ctx)
task := &ActionTask{}
if has, err := e.ID(state.Id).Get(task); err != nil {
return nil, err
// Only one request can update the task because the final state needs to be calculated with all job states.
// Otherwise, concurrent requests with transaction will make the SQL read stale job state and result in wrong final state.
taskID := state.Id
runID, err := getRunIDByTaskID(ctx, taskID)
if err != nil {
return nil, err
}
task := &ActionTask{}
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
return err
} else if !has {
return nil, util.ErrNotExist
return util.ErrNotExist
} else if runnerID != task.RunnerID {
return nil, errors.New("invalid runner for task")
return errors.New("invalid runner for task")
}
if task.Status.IsDone() {
// the state is final, do nothing
return task, nil
return nil
}
// state.Result is not unspecified means the task is finished
@@ -474,7 +492,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
}
task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix())
if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
return nil, err
return err
}
if _, err := UpdateRunJob(ctx, &ActionRunJob{
ID: task.JobID,
@@ -482,18 +500,18 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
Status: task.Status,
Stopped: task.Stopped,
}, nil, "status", "stopped"); err != nil {
return nil, err
return err
}
} else {
// Force update ActionTask.Updated to avoid the task being judged as a zombie task
task.Updated = timeutil.TimeStampNow()
if err := UpdateTask(ctx, task, "updated"); err != nil {
return nil, err
return err
}
}
if err := task.LoadAttributes(ctx); err != nil {
return nil, err
return err
}
for _, step := range task.Steps {
@@ -510,13 +528,13 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
} else if step.Started != 0 {
step.Status = StatusRunning
}
if _, err := e.ID(step.ID).Update(step); err != nil {
return nil, err
if _, err := db.GetEngine(ctx).ID(step.ID).Update(step); err != nil {
return err
}
}
return task, nil
return nil
})
return task, err
}
func StopTask(ctx context.Context, taskID int64, status Status) error {
+19 -1
View File
@@ -36,7 +36,18 @@ import (
const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----"
const (
// the longest RSA key ssh-keygen allows to generate is 16384 bits (2048 bytes), we still relax the limit a little here
maxKeyBinaryBytes = 4096
maxKeyContentBase64Bytes = maxKeyBinaryBytes * 4 / 3
maxKeyContentExtraBytes = 4 * 1024 // header, footer, comment
maxKeyContentBytes = maxKeyContentBase64Bytes + maxKeyContentExtraBytes
)
func extractTypeFromBase64Key(key string) (string, error) {
if len(key) > maxKeyContentBase64Bytes {
return "", util.NewInvalidArgumentErrorf("SSH public key base64 is too long")
}
b, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(b) < 4 {
return "", fmt.Errorf("invalid key format: %w", err)
@@ -52,6 +63,10 @@ func extractTypeFromBase64Key(key string) (string, error) {
// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253).
func parseKeyString(content string) (string, error) {
if len(content) > maxKeyContentBytes {
return "", util.NewInvalidArgumentErrorf("SSH public key content is too long")
}
// remove whitespace at start and end
content = strings.TrimSpace(content)
@@ -63,6 +78,8 @@ func parseKeyString(content string) (string, error) {
// Transform all legal line endings to a single "\n".
content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content)
var b strings.Builder
b.Grow(len(content))
lines := strings.Split(content, "\n")
continuationLine := false
@@ -74,9 +91,10 @@ func parseKeyString(content string) (string, error) {
if continuationLine || strings.ContainsAny(line, ":-") {
continuationLine = strings.HasSuffix(line, "\\")
} else {
keyContent += line
b.WriteString(line)
}
}
keyContent = b.String()
t, err := extractTypeFromBase64Key(keyContent)
if err != nil {
+11 -1
View File
@@ -473,10 +473,20 @@ func runErr(t *testing.T, stdin []byte, args ...string) {
}
}
func Test_PublicKeysAreExternallyManaged(t *testing.T) {
func TestPublicKeysAreExternallyManaged(t *testing.T) {
key1 := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
externals, err := PublicKeysAreExternallyManaged(t.Context(), []*PublicKey{key1})
assert.NoError(t, err)
assert.Len(t, externals, 1)
assert.False(t, externals[0])
}
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
func TestCheckPublicKeyStringOversized(t *testing.T) {
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
assert.ErrorContains(t, err, "SSH public key content is too long")
content := "---- BEGIN SSH2 PUBLIC KEY ----\n" + strings.Repeat("a", maxKeyContentBase64Bytes+1) + "\n--- END SSH2 PUBLIC KEY ----"
_, err = parseKeyString(content)
assert.ErrorContains(t, err, "SSH public key base64 is too long")
}
+30
View File
@@ -304,6 +304,36 @@ func (s AccessTokenScope) PublicOnly() (bool, error) {
return bitmap.hasScope(AccessTokenScopePublicOnly)
}
// CanCreateChildScope reports whether a request authenticated by this (parent) scope may mint a token
// carrying the child scope. It rejects any grantable scope the parent does not hold, closing the
// scope-escalation path. public-only is a restriction rather than a grantable permission, so it is
// ignored here (a child may always be public-only); EnforcePublicOnlyFrom handles carrying it down.
func (s AccessTokenScope) CanCreateChildScope(child AccessTokenScope) (bool, error) {
requested := child.StringSlice()
scopes := make([]AccessTokenScope, 0, len(requested))
for _, sc := range requested {
childScope := AccessTokenScope(sc)
if childScope == AccessTokenScopePublicOnly {
continue
}
scopes = append(scopes, childScope)
}
return s.HasScope(scopes...)
}
// EnforcePublicOnlyFrom adds the public-only restriction to s when the authorizing parent scope is
// public-only, so a public-only token cannot mint a child token that drops the restriction.
func (s AccessTokenScope) EnforcePublicOnlyFrom(parent AccessTokenScope) (AccessTokenScope, error) {
publicOnly, err := parent.PublicOnly()
if err != nil {
return "", err
}
if !publicOnly {
return s, nil
}
return AccessTokenScope(string(s) + "," + string(AccessTokenScopePublicOnly)).Normalize()
}
// HasScope returns true if the string has the given scope
func (s AccessTokenScope) HasScope(scopes ...AccessTokenScope) (bool, error) {
bitmap, err := s.parse()
+23
View File
@@ -89,3 +89,26 @@ func TestAccessTokenScope_HasScope(t *testing.T) {
})
}
}
func TestAccessTokenScope_EnforcePublicOnlyFrom(t *testing.T) {
tests := []struct {
in AccessTokenScope
parent AccessTokenScope
out AccessTokenScope
}{
// public-only parent forces the restriction onto the minted scope
{"write:user", "write:user,public-only", "public-only,write:user"},
// already public-only stays public-only
{"public-only,read:user", "public-only", "public-only,read:user"},
// non-public-only parent leaves the scope untouched
{"write:user", "write:user", "write:user"},
{"all", "all", "all"},
}
for _, test := range tests {
t.Run(string(test.parent)+"->"+string(test.in), func(t *testing.T) {
got, err := test.in.EnforcePublicOnlyFrom(test.parent)
assert.NoError(t, err)
assert.Equal(t, test.out, got)
})
}
}
+25 -30
View File
@@ -27,6 +27,7 @@ import (
"gitea.dev/modules/markup"
"gitea.dev/modules/optional"
"gitea.dev/modules/references"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
@@ -626,11 +627,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
issue, err := GetIssueByID(ctx, c.IssueID)
if err != nil {
return err
}
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := range attachments {
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
return err
}
attachments[i].IssueID = c.IssueID
attachments[i].CommentID = c.ID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
@@ -643,36 +651,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
}
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
var err error
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
if c.AssigneeID > 0 && c.Assignee == nil {
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return err
}
c.Assignee = user_model.NewGhostUser()
}
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
if err = c.LoadIssue(ctx); err != nil {
return err
}
if err = c.Issue.LoadRepo(ctx); err != nil {
}
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
if err != nil {
return err
}
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
return err
}
if c.Issue.Repo.Owner.IsOrganization() {
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
if err != nil && !organization.IsErrTeamNotExist(err) {
return err
}
}
}
return nil
}
@@ -795,8 +785,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
}
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
if c.AssigneeID > 0 {
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
if c.Assignee != nil {
if c.RemovedAssignee {
if c.PosterID == c.AssigneeID {
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
@@ -805,14 +794,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
}
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
}
teamName := "Ghost Team"
if c.AssigneeTeam != nil {
teamName = c.AssigneeTeam.Name
if c.RemovedAssignee {
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
}
// impossible fallback
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
if c.RemovedAssignee {
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
}
// CreateComment creates comment with context
+16 -1
View File
@@ -45,12 +45,27 @@ func TestCreateComment(t *testing.T) {
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
}
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
comment := &issues_model.Comment{
Type: issues_model.CommentTypeAssignees,
IssueID: issue.ID,
AssigneeTeamID: 999999, // non-existing team ID
}
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
assert.NotNil(t, comment.AssigneeTeam)
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
}
func Test_UpdateCommentAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
attachment := repo_model.Attachment{
Name: "test.txt",
RepoID: issue.RepoID, // must match the comment's repo, else the cross-repo guard rejects it
Name: "test.txt",
}
assert.NoError(t, db.Insert(t.Context(), &attachment))
+32
View File
@@ -263,14 +263,46 @@ func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *
return err
}
// validateAttachmentForIssue rejects a foreign or already-linked attachment before it is linked to
// issue: a known UUID could otherwise re-link (and expose) another repo's private attachment. A
// legacy attachment predating repo_id-on-upload is adopted into the issue's repo.
func validateAttachmentForIssue(ctx context.Context, issue *Issue, attachment *repo_model.Attachment) error {
if attachment.RepoID == 0 && attachment.CreatedUnix < repo_model.LegacyAttachmentMissingRepoIDCutoff {
attachment.RepoID = issue.RepoID
if err := repo_model.UpdateAttachmentByUUID(ctx, attachment, "repo_id"); err != nil {
return fmt.Errorf("update attachment repo_id [id: %d]: %w", attachment.ID, err)
}
}
if attachment.RepoID != issue.RepoID {
return util.NewPermissionDeniedErrorf("attachment belongs to a different repository")
}
if attachment.IssueID != 0 && attachment.IssueID != issue.ID {
return util.NewPermissionDeniedErrorf("attachment is already linked to another issue")
}
if attachment.ReleaseID != 0 {
return util.NewPermissionDeniedErrorf("attachment is already linked to a release")
}
return nil
}
// UpdateIssueAttachments update attachments by UUIDs for the issue
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
if len(uuids) == 0 {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
issue, err := GetIssueByID(ctx, issueID)
if err != nil {
return err
}
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := range attachments {
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
return err
}
attachments[i].IssueID = issueID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues_test
import (
"testing"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateIssueAttachmentsCrossRepo(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// attachment id 2 belongs to repo 2 / issue 4; issue 1 lives in repo 1
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
foreign := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
require.NotEqual(t, issue1.RepoID, foreign.RepoID)
// re-linking a foreign repo's attachment by UUID must be rejected
err := issues_model.UpdateIssueAttachments(t.Context(), issue1.ID, []string{foreign.UUID})
assert.ErrorIs(t, err, util.ErrPermissionDenied)
// the foreign attachment must be left untouched
reloaded := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
assert.Equal(t, foreign.IssueID, reloaded.IssueID)
}
+13 -6
View File
@@ -6,6 +6,7 @@ package issues
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
@@ -27,12 +28,6 @@ type ErrRepoLabelNotExist struct {
RepoID int64
}
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
func IsErrRepoLabelNotExist(err error) bool {
_, ok := err.(ErrRepoLabelNotExist)
return ok
}
func (err ErrRepoLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
}
@@ -312,6 +307,18 @@ func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (
return l, nil
}
// GetLabelInRepoOrOrgByID returns the label with labelID scoped to the repo, falling back to the
// repo's owning organization when ownerIsOrg is set. It returns ErrRepoLabelNotExist /
// ErrOrgLabelNotExist when the label is in neither scope, so a foreign-but-existing label ID is
// indistinguishable from a nonexistent one (no cross-repo enumeration oracle).
func GetLabelInRepoOrOrgByID(ctx context.Context, repoID, ownerID int64, ownerIsOrg bool, labelID int64) (*Label, error) {
label, err := GetLabelInRepoByID(ctx, repoID, labelID)
if err != nil && errors.Is(err, util.ErrNotExist) && ownerIsOrg {
return GetLabelInOrgByID(ctx, ownerID, labelID)
}
return label, err
}
// GetLabelInRepoByID returns a label by ID in given repository.
func GetLabelInRepoByID(ctx context.Context, repoID, labelID int64) (*Label, error) {
if labelID <= 0 || repoID <= 0 {
+5 -4
View File
@@ -12,6 +12,7 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -94,10 +95,10 @@ func TestGetLabelInRepoByName(t *testing.T) {
assert.Equal(t, "label1", label.Name)
_, err = issues_model.GetLabelInRepoByName(t.Context(), 1, "")
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
_, err = issues_model.GetLabelInRepoByName(t.Context(), unittest.NonexistentID, "nonexistent")
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestGetLabelInRepoByNames(t *testing.T) {
@@ -131,10 +132,10 @@ func TestGetLabelInRepoByID(t *testing.T) {
assert.EqualValues(t, 1, label.ID)
_, err = issues_model.GetLabelInRepoByID(t.Context(), 1, -1)
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
_, err = issues_model.GetLabelInRepoByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
assert.ErrorIs(t, err, util.ErrNotExist)
}
func TestGetLabelsInRepoByIDs(t *testing.T) {
+21
View File
@@ -6,6 +6,7 @@ package organization
import (
"context"
"errors"
"fmt"
"strings"
@@ -92,6 +93,15 @@ func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
const (
ghostTeamID = -1
ghostTeamName = "(deleted team)"
)
func newGhostTeam() *Team {
return &Team{ID: ghostTeamID, Name: ghostTeamName, LowerName: ghostTeamName}
}
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
// the team's metadata, based on the team's visibility tier and the parent org's
// visibility. Privileged callers (site admins, org owners, team members) are
@@ -270,6 +280,17 @@ func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) {
return t, nil
}
func GetPossibleTeamByID(ctx context.Context, teamID int64) (int64, *Team, error) {
t, err := GetTeamByID(ctx, teamID)
if errors.Is(err, util.ErrNotExist) {
t = newGhostTeam()
return t.ID, t, nil
} else if err != nil {
return 0, nil, err
}
return t.ID, t, nil
}
// IncrTeamRepoNum increases the number of repos for the given team by 1
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
+44 -4
View File
@@ -310,11 +310,17 @@ func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
}
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
// A team grants the unit either through an explicit team_unit row (access_mode > none) or by being an
// admin/owner team (team.authorize >= admin), which grants every unit regardless of team_unit rows —
// mirroring the HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
return userOrgTeamRepoBuilder(userID).
Join("INNER", "team_unit", "`team_unit`.team_id = `team_repo`.team_id").
Where(builder.Eq{"`team_unit`.`type`": unitType}).
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
Join("INNER", "team", "`team`.id = `team_repo`.team_id").
Join("LEFT", "team_unit", builder.Expr("`team_unit`.team_id = `team_repo`.team_id AND `team_unit`.`type` = ?", unitType)).
Where(builder.Or(
builder.Gte{"`team`.authorize": int(perm.AccessModeAdmin)},
builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)},
))
}
// userOrgTeamUnitRepoCond returns a condition to select repo ids where user's teams can access the special unit.
@@ -326,7 +332,7 @@ func userOrgTeamUnitRepoCond(idStr string, userID int64, unitType unit.Type) bui
func UserOrgUnitRepoCond(idStr string, userID, orgID int64, unitType unit.Type) builder.Cond {
return builder.In(idStr,
userOrgTeamUnitRepoBuilder(userID, unitType).
And(builder.Eq{"`team_unit`.org_id": orgID}),
And(builder.Eq{"`team`.org_id": orgID}),
)
}
@@ -755,6 +761,40 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
))
}
// PublicRepoUnderPublicOwnerCond restricts to public repos whose owner is publicly visible: the
// "genuinely public" set a public-only token or an anonymous caller may see (a public repo under a
// limited/private owner is not publicly reachable and must be excluded).
func PublicRepoUnderPublicOwnerCond() builder.Cond {
return builder.And(
builder.Eq{"`repository`.is_private": false},
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
)
}
// UserActionsAccessibleOwnerRepoCond selects the repos owned by ownerID whose Actions `user` may read.
// It is used to list an org/user's Actions runs and jobs (see the callers in routers/api/v1/shared).
// - owner_id = ownerID: only that owner's repos.
// - AccessibleRepositoryCondition(user, TypeActions): only repos whose Actions the user can read
// (admin/owner teams are handled inside it; a site admin is not, callers must skip the filter for one).
// - publicOnly (a public-only token): additionally limit to public repos under a public owner.
func UserActionsAccessibleOwnerRepoCond(ownerID int64, user *user_model.User, publicOnly bool) builder.Cond {
cond := builder.NewCond().And(
builder.Eq{"`repository`.owner_id": ownerID},
AccessibleRepositoryCondition(user, unit.TypeActions),
)
if publicOnly {
cond = cond.And(PublicRepoUnderPublicOwnerCond())
}
return cond
}
// FindUserActionsAccessibleOwnerRepoIDsSubQuery returns a subquery selecting the repository IDs the user
// can see for the given owner. Callers embed it in an `IN (...)` condition so that a large owner does not
// materialize every repo ID into the SQL statement, which could exceed database parameter limits.
func FindUserActionsAccessibleOwnerRepoIDsSubQuery(ownerID int64, user *user_model.User, publicOnly bool) *builder.Builder {
return builder.Select("id").From("repository").Where(UserActionsAccessibleOwnerRepoCond(ownerID, user, publicOnly))
}
// GetUserRepositories returns a list of repositories of given user.
func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
if len(opts.OrderBy) == 0 {
+48
View File
@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
@@ -466,3 +467,50 @@ func TestSearchRepositoryByTopicName(t *testing.T) {
})
}
}
func TestFindUserActionsAccessibleOwnerRepoIDs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// user2 is on org3's owner team, so it can access org3's private repo3 (which has the actions unit)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// org3 is a public org owning repo3 (private) and repo32 (public), both with the actions unit
const orgID = 3
all, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, false))
require.NoError(t, err)
assert.Contains(t, all, int64(3), "without public-only the private repo's actions are listed")
publicOnly, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, true))
require.NoError(t, err)
assert.NotContains(t, publicOnly, int64(3), "a public-only token must not list a private repo's actions")
assert.Contains(t, publicOnly, int64(32), "a public repo under a public owner stays listed")
}
// TestUserOrgUnitRepoCondTeamAuthorize pins the team.authorize behavior of userOrgTeamUnitRepoBuilder
// (exercised through UserOrgUnitRepoCond): an admin/owner team grants every unit even without an explicit
// team_unit row, while a non-admin team only grants a unit it has an explicit row for. This guards both
// directions — hiding repos from admin-team members, and over-broadening a plain team's access.
func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
accessibleRepoIDs := func(userID, orgID int64, unitType unit.Type) []int64 {
ids, err := repo_model.SearchRepositoryIDsByCondition(t.Context(),
repo_model.UserOrgUnitRepoCond("`repository`.id", userID, orgID, unitType))
require.NoError(t, err)
return ids
}
// Case A: user18 is only on org17's owner team (team5, authorize=owner), linked to the private repo24
// but with no Actions team_unit row. The owner authorize must still grant it, mirroring the runtime
// HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24),
"an owner team grants a unit it has no explicit team_unit row for")
// Cases B and C share one subject so the team_unit row is the only difference: user4 is only on org3's
// write team (team2, authorize=write, non-admin), linked to the private repo3. team2 has an explicit
// Projects row but none for Actions.
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3),
"a non-admin team grants a unit it has an explicit team_unit row for")
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3),
"a non-admin team must NOT grant a unit it has no team_unit row for")
}
+4 -6
View File
@@ -46,9 +46,8 @@ func (opts *StarredReposOptions) ToConds() builder.Cond {
// only include private repos the actor can still access, so metadata does not leak after access revocation
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
} else {
cond = cond.And(builder.Eq{
"repository.is_private": false,
})
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
cond = cond.And(PublicRepoUnderPublicOwnerCond())
}
return cond
}
@@ -96,9 +95,8 @@ func (opts *WatchedReposOptions) ToConds() builder.Cond {
// only include private repos the actor can still access, so metadata does not leak after access revocation
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
} else {
cond = cond.And(builder.Eq{
"repository.is_private": false,
})
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
cond = cond.And(PublicRepoUnderPublicOwnerCond())
}
return cond.And(builder.Neq{
"watch.mode": WatchModeDont,
+37
View File
@@ -84,3 +84,40 @@ func testUserRepoGetIssuePostersWithSearch(t *testing.T) {
require.Len(t, users, 1)
assert.Equal(t, "user2", users[0].Name)
}
func TestStarredWatchedReposExcludeNonPublicOwners(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
const viewerID = 2
// repo1: public repo under a public owner; repo38: public repo under a limited org (not publicly reachable)
const publicOwnerRepo, limitedOwnerRepo = 1, 38
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: publicOwnerRepo}))
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: limitedOwnerRepo}))
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: publicOwnerRepo, Mode: repo_model.WatchModeNormal}))
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: limitedOwnerRepo, Mode: repo_model.WatchModeNormal}))
listOpts := db.ListOptions{Page: 1, PageSize: 50}
starred, err := repo_model.GetStarredRepos(t.Context(), &repo_model.StarredReposOptions{
ListOptions: listOpts, StarrerID: viewerID, IncludePrivate: false,
})
require.NoError(t, err)
assert.NotContains(t, repoIDs(starred), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public star listing")
assert.Contains(t, repoIDs(starred), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
watched, _, err := repo_model.GetWatchedRepos(t.Context(), &repo_model.WatchedReposOptions{
ListOptions: listOpts, WatcherID: viewerID, IncludePrivate: false,
})
require.NoError(t, err)
assert.NotContains(t, repoIDs(watched), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public watch listing")
assert.Contains(t, repoIDs(watched), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
}
func repoIDs(repos []*repo_model.Repository) []int64 {
ids := make([]int64, len(repos))
for i, r := range repos {
ids[i] = r.ID
}
return ids
}
+20 -2
View File
@@ -4,8 +4,14 @@
package openid
import (
"net/http"
"sync"
"time"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"github.com/yohcop/openid-go"
)
@@ -19,11 +25,23 @@ import (
var (
nonceStore = openid.NewSimpleNonceStore()
discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
// openIDInstance does discovery/verification via an SSRF-protected client, so a user-supplied
// OpenID identifier can't reach internal/loopback/reserved addresses. It honors the operator's
// [security] ALLOWED_HOST_LIST (empty defaults to "external"), matching the avatar/webhook/migration
// clients, and validates the proxy path too. Lazy: reads proxy/settings once.
openIDInstance = sync.OnceValue(func() *openid.OpenID {
allowList := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
return openid.NewOpenID(&http.Client{
Timeout: 30 * time.Second,
Transport: hostmatcher.NewHTTPTransport("openid", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
})
})
)
// Verify handles response from OpenID provider
func Verify(fullURL string) (id string, err error) {
return openid.Verify(fullURL, discoveryCache, nonceStore)
return openIDInstance().Verify(fullURL, discoveryCache, nonceStore)
}
// Normalize normalizes an OpenID URI
@@ -33,5 +51,5 @@ func Normalize(url string) (id string, err error) {
// RedirectURL redirects browser
func RedirectURL(id, callbackURL, realm string) (string, error) {
return openid.RedirectURL(id, callbackURL, realm)
return openIDInstance().RedirectURL(id, callbackURL, realm)
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package openid
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenIDDiscoveryBlocksInternalHost(t *testing.T) {
var reached atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached.Store(true)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// RedirectURL performs server-side discovery of the identifier URL; a loopback URL
// must be refused at dial time instead of reaching the internal server
_, err := RedirectURL(srv.URL, "http://example.com/callback", "http://example.com/")
require.Error(t, err)
assert.False(t, reached.Load(), "OpenID discovery must not reach an internal/loopback host")
}
+16
View File
@@ -5,8 +5,10 @@ package hostmatcher
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
@@ -63,3 +65,17 @@ func NewDialContext(usage string, allowList, blockList *HostMatchList, proxy *ur
return dialer.DialContext(ctx, network, addrOrHost)
}
}
// NewHTTPTransport builds an http.Transport that validates the request target against the allow/block
// lists on the direct-dial path (DialContext). When an HTTP proxy is configured the proxy resolves and
// dials the target itself, so restricting the proxied target is the proxy server's responsibility, not
// Gitea's. proxyFunc selects the proxy URL per request (the http.Transport.Proxy selector, e.g.
// proxy.Proxy()); proxyURLFixed is the fixed proxy address the dialer must always permit; tlsConfig may
// be nil. blockList may be nil for callers that only maintain an allow-list.
func NewHTTPTransport(usage string, allowList, blockList *HostMatchList, proxyFunc func(*http.Request) (*url.URL, error), proxyURLFixed *url.URL, tlsConfig *tls.Config) *http.Transport {
return &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: proxyFunc,
DialContext: NewDialContext(usage, allowList, blockList, proxyURLFixed),
}
}
+11 -3
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"gitea.dev/modules/packages"
"gitea.dev/modules/util"
"gitea.dev/modules/validation"
@@ -46,6 +47,11 @@ var (
namePattern = regexp.MustCompile(`\A[a-zA-Z0-9@._+-]+\z`)
// (epoch:pkgver-pkgrel)
versionPattern = regexp.MustCompile(`\A(?:\d:)?[\w.+~]+(?:-[-\w.+~]+)?\z`)
// caps on the accumulated package file list (vars so tests can lower them); far above
// any legitimate package, but low enough to stop metadata amplification
maxFileEntries = 100000
maxFileNameBytes = 16 * 1024 * 1024
)
type Package struct {
@@ -124,7 +130,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
}
var p *Package
files := make([]string, 0, 10)
files := packages.NewBoundedFileList(maxFileEntries, maxFileNameBytes)
tr := tar.NewReader(inner)
for {
@@ -147,7 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, err
}
} else if !strings.HasPrefix(filename, ".") {
files = append(files, hd.Name)
if err := files.Add(hd.Name); err != nil {
return nil, err
}
}
}
@@ -155,7 +163,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, ErrMissingPKGINFOFile
}
p.FileMetadata.Files = files
p.FileMetadata.Files = files.Files()
p.FileCompressionExtension = compressionType
return p, nil
+25
View File
@@ -10,6 +10,9 @@ import (
"io"
"testing"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"github.com/klauspost/compress/zstd"
"github.com/stretchr/testify/assert"
"github.com/ulikunitz/xz"
@@ -167,3 +170,25 @@ func TestParsePackageInfo(t *testing.T) {
assert.ElementsMatch(t, []string{"usr/bin/paket1"}, p.FileMetadata.Backup)
})
}
// TestParsePackageTooManyFiles ensures the accumulated file list is bounded to prevent
// metadata amplification from a package with a huge number of (tiny) file entries.
func TestParsePackageTooManyFiles(t *testing.T) {
defer test.MockVariableValue(&maxFileEntries, 3)()
buf := test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
"file1": "content1",
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
})
_, err := ParsePackage(buf)
assert.NoError(t, err)
buf = test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
"file1": "content1",
"file2": "content2",
"file3": "content3",
"file4": "content4",
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
})
_, err = ParsePackage(buf)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
}
+13 -3
View File
@@ -135,7 +135,10 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, GlobalVars().ErrUnsupportedCompression
}
tr := tar.NewReader(inner)
// bound the decompressed control archive: it holds only the small control file
// and maintainer scripts, so a much larger stream is a decompression bomb
const maxControlTarSize = 32 * 1024 * 1024
tr := tar.NewReader(io.LimitReader(inner, maxControlTarSize))
for {
hd, err := tr.Next()
if err == io.EOF {
@@ -168,6 +171,7 @@ func ParseControlFile(r io.Reader) (*Package, error) {
key := ""
var depends strings.Builder
var control strings.Builder
var description strings.Builder
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
s := bufio.NewScanner(r)
@@ -189,10 +193,13 @@ func ParseControlFile(r io.Reader) (*Package, error) {
control.WriteString(line)
control.WriteByte('\n')
// a leading space or tab marks a folded continuation line that belongs to the previous field
// (identified by key), not a new "Key: value" pair; only the multi-line fields append here.
// Continuation lines may themselves contain a colon, so they must not be re-split on ":".
if line[0] == ' ' || line[0] == '\t' {
switch key {
case "Description":
p.Metadata.Description += line
description.WriteString(line)
case "Depends":
depends.WriteString(trimmed)
}
@@ -219,7 +226,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
p.Metadata.Maintainer = a.Name
}
case "Description":
p.Metadata.Description = value
description.Reset()
description.WriteString(value)
case "Depends":
depends.WriteString(value)
case "Homepage":
@@ -243,6 +251,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
return nil, GlobalVars().ErrInvalidArchitecture
}
p.Metadata.Description = description.String()
dependencies := strings.Split(depends.String(), ",")
for i := range dependencies {
dependencies[i] = strings.TrimSpace(dependencies[i])
+12
View File
@@ -232,3 +232,15 @@ func TestValidateDistributionOrComponent(t *testing.T) {
assert.True(t, IsValidDistributionOrComponent(name), "good=%q", name)
}
}
// TestParseControlFileMultilineDescription verifies a multi-line Description is assembled in order
// (the parser accumulates it in a strings.Builder); it guards the assembled value, not its timing.
func TestParseControlFileMultilineDescription(t *testing.T) {
var buf bytes.Buffer
buf.WriteString("Package: testpkg\nVersion: 1.0\nArchitecture: amd64\nDescription: short summary\n more details\n even more\n")
p, err := ParseControlFile(&buf)
assert.NoError(t, err)
assert.NotNil(t, p)
assert.Equal(t, "short summary more details even more", p.Metadata.Description)
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package packages
import "gitea.dev/modules/util"
// BoundedFileList accumulates file names from a package archive while enforcing caps on the number of
// entries and their total name length, returning an error once either cap would be exceeded.
type BoundedFileList struct {
files []string
nameBytes int
maxFiles int
maxBytes int
}
// NewBoundedFileList creates a BoundedFileList with the given caps; a non-positive cap falls back to the
// corresponding default.
func NewBoundedFileList(maxFiles, maxNameBytes int) *BoundedFileList {
return &BoundedFileList{maxFiles: maxFiles, maxBytes: maxNameBytes}
}
// Add appends name, returning util.ErrInvalidArgument once the entry count or accumulated byte length
// would exceed the configured cap.
func (b *BoundedFileList) Add(name string) error {
if len(b.files) >= b.maxFiles || b.nameBytes+len(name) > b.maxBytes {
return util.NewInvalidArgumentErrorf("package contains too many file entries")
}
b.nameBytes += len(name)
b.files = append(b.files, name)
return nil
}
// Files returns the accumulated file names.
func (b *BoundedFileList) Files() []string {
return b.files
}
+28 -2
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -53,12 +54,37 @@ func dialContextInternalAPI(ctx context.Context, network, address string) (conn
return conn, nil
}
// internalAPIConnectionIsLocal reports whether the internal API transport connects to a local target,
// where the self-signed local certificate cannot be verified so skipping verification is safe. It mirrors
// what dialContextInternalAPI actually dials: a unix socket whenever Protocol is HTTPUnix (always local,
// whatever LOCAL_ROOT_URL says), otherwise the LOCAL_ROOT_URL host directly. A non-loopback LOCAL_ROOT_URL
// is a real network hop, so its certificate must be verified, else the internal token can be MITM'd. An
// unparseable LOCAL_ROOT_URL is a hard misconfiguration and fails closed (verify).
func internalAPIConnectionIsLocal(protocol setting.Scheme, localURL string) bool {
if protocol == setting.HTTPUnix {
return true
}
u, err := url.Parse(localURL)
if err != nil {
return false
}
host := u.Hostname()
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
var internalAPITransport = sync.OnceValue(func() http.RoundTripper {
return &http.Transport{
DialContext: dialContextInternalAPI,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
ServerName: setting.Domain,
// Skip verification only for a local target (unix socket, or a loopback LOCAL_ROOT_URL), where the
// self-signed local cert can't be verified anyway; a non-loopback LOCAL_ROOT_URL is a real network
// hop and must be verified so the internal token can't be MITM'd. When verifying, Go's default
// ServerName (the dialed LOCAL_ROOT_URL host) is already correct, so it is not overridden.
InsecureSkipVerify: internalAPIConnectionIsLocal(setting.Protocol, setting.LocalURL),
},
}
})
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package private
import (
"testing"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestInternalAPIConnectionIsLocal(t *testing.T) {
cases := []struct {
name string
protocol setting.Scheme
localURL string
want bool
}{
// HTTPUnix always dials the unix socket (a local target), whatever LOCAL_ROOT_URL says
{"unix socket", setting.HTTPUnix, "https://gitea.example.com/", true},
{"localhost", setting.HTTP, "http://localhost:3000/", true},
{"loopback ipv4", setting.HTTPS, "https://127.0.0.1:3000/", true},
{"loopback ipv6", setting.HTTPS, "https://[::1]:3000/", true},
// a non-loopback LOCAL_ROOT_URL is a real network hop and must be verified
{"remote host", setting.HTTPS, "https://gitea.internal:443/", false},
{"remote ip", setting.HTTPS, "https://10.0.0.5:3000/", false},
// an unparseable LOCAL_ROOT_URL is a hard misconfiguration; fail closed to verification
{"invalid url", setting.HTTPS, "://bad", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, internalAPIConnectionIsLocal(c.protocol, c.localURL))
})
}
}
+2
View File
@@ -20,9 +20,11 @@ var Security = struct {
XContentTypeOptions string
ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future
AllowedHostList string
}{
XFrameOptions: "SAMEORIGIN",
XContentTypeOptions: "nosniff",
AllowedHostList: "external",
}
var (
+8 -1
View File
@@ -10,13 +10,20 @@ import (
)
func TestLoadSecurityFrom(t *testing.T) {
assert.Equal(t, "SAMEORIGIN", Security.XFrameOptions)
assert.Equal(t, "nosniff", Security.XContentTypeOptions)
assert.Equal(t, "external", Security.AllowedHostList)
cfg, err := NewConfigProviderFromData(`[security]
X_FRAME_OPTIONS = DENY
X_CONTENT_TYPE_OPTIONS = unset
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"`)
ALLOWED_HOST_LIST = foo
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"
`)
assert.NoError(t, err)
loadSecurityFrom(cfg)
assert.Equal(t, "DENY", Security.XFrameOptions)
assert.Equal(t, "unset", Security.XContentTypeOptions)
assert.Equal(t, "foo", Security.AllowedHostList)
assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug
}
+4 -1
View File
@@ -34,7 +34,10 @@ func loadWebhookFrom(rootCfg ConfigProvider) {
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
deprecatedSetting(rootCfg, "webhook", "ALLOWED_HOST_LIST", "security", "ALLOWED_HOST_LIST", "v28.0.0")
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString(Security.AllowedHostList)
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
+12 -1
View File
@@ -12,9 +12,20 @@ import (
"strings"
"gitea.dev/modules/json"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// httpClient returns an HTTP client that honors Gitea's proxy configuration.
var httpClient = util.OnceValue[*http.Client]{
Func: func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxy.Proxy()
return &http.Client{Transport: transport}
},
}
// Response is the structure of JSON returned from API
type Response struct {
Success bool `json:"success"`
@@ -40,7 +51,7 @@ func Verify(ctx context.Context, response string) (bool, error) {
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
resp, err := httpClient.Value().Do(req)
if err != nil {
return false, fmt.Errorf("Failed to send CAPTCHA response: %w", err)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package turnstile
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPClientHonorsProxy(t *testing.T) {
proxyURL, err := url.Parse("http://proxy.example.com:3128")
require.NoError(t, err)
defer test.MockVariableValue(&setting.Proxy.Enabled, true)()
defer test.MockVariableValue(&setting.Proxy.ProxyURL, proxyURL.String())()
defer test.MockVariableValue(&setting.Proxy.ProxyURLFixed, proxyURL)()
defer test.MockVariableValue(&setting.Proxy.ProxyHosts, []string{"**"})()
httpClient.Reset()
transport, ok := httpClient.Value().Transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, transport.Proxy)
// The Turnstile verification request must be routed through the configured proxy.
req := httptest.NewRequest(http.MethodPost, "https://any.example.com", nil)
got, err := transport.Proxy(req)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, proxyURL.String(), got.String())
}
+9 -1
View File
@@ -23,13 +23,21 @@ func (e ErrURISchemeNotSupported) Error() string {
// Open open a local file or a remote file
func Open(uriStr string) (io.ReadCloser, error) {
return OpenWithClient(uriStr, http.DefaultClient)
}
// OpenWithClient opens a local file or a remote file, using the given (non-nil) HTTP client
// for http/https URLs. Callers that must confine remote access (e.g. to defeat SSRF via
// redirects) should pass a client whose transport validates the peer at dial time; Open
// passes http.DefaultClient.
func OpenWithClient(uriStr string, client *http.Client) (io.ReadCloser, error) {
u, err := url.Parse(uriStr)
if err != nil {
return nil, err
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
f, err := http.Get(uriStr)
f, err := client.Get(uriStr)
if err != nil {
return nil, err
}
+50
View File
@@ -4,10 +4,18 @@
package uri
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadURI(t *testing.T) {
@@ -17,3 +25,45 @@ func TestReadURI(t *testing.T) {
assert.NoError(t, err)
defer f.Close()
}
// TestOpenWithClientValidatesRedirectTarget verifies OpenWithClient routes the
// whole request chain (including redirects) through the provided client, so a
// client whose transport refuses to dial an internal target blocks a redirect to
// it — whereas the default client (old Open behavior) follows it.
func TestOpenWithClientValidatesRedirectTarget(t *testing.T) {
var internalHit atomic.Bool
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
internalHit.Store(true)
_, _ = w.Write([]byte("secret"))
}))
defer internal.Close()
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, internal.URL, http.StatusFound)
}))
defer front.Close()
internalAddr := strings.TrimPrefix(internal.URL, "http://")
// a client that refuses to dial the internal target, mimicking the migration
// hostmatcher dialer that re-validates every hop
blockingClient := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if addr == internalAddr {
return nil, errors.New("blocked internal address")
}
return (&net.Dialer{}).DialContext(ctx, network, addr)
},
}}
_, err := OpenWithClient(front.URL, blockingClient)
require.Error(t, err)
assert.False(t, internalHit.Load(), "the redirect target must not be reached through the validating client")
// the default client (the previous behavior) follows the redirect to the internal target
internalHit.Store(false)
rc, err := Open(front.URL)
require.NoError(t, err)
_ = rc.Close()
assert.True(t, internalHit.Load(), "sanity check: the default client follows the redirect")
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"sync"
"sync/atomic"
)
type onceValueResult[T any] struct {
value T
panic any
}
// OnceValue is similar to Golang's "sync.OnceValue", but can be reset.
type OnceValue[T any] struct {
Func func() T
mu sync.Mutex
res atomic.Pointer[onceValueResult[T]]
}
func (o *OnceValue[T]) Value() T {
res := o.res.Load()
if res == nil {
o.mu.Lock()
defer o.mu.Unlock()
res = o.res.Load()
if res == nil {
res = &onceValueResult[T]{}
defer func() {
res.panic = recover()
o.res.Store(res)
if res.panic != nil {
panic(res.panic)
}
}()
res.value = o.Func()
}
}
if res.panic != nil {
panic(res.panic)
}
return res.value
}
func (o *OnceValue[T]) Reset() {
o.mu.Lock()
defer o.mu.Unlock()
o.res.Store(nil)
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOnceValue(t *testing.T) {
t.Run("RepeatCall", func(t *testing.T) {
callCount := 0
o := OnceValue[int]{Func: func() int {
callCount++
return 42
}}
assert.Equal(t, 42, o.Value())
assert.Equal(t, 42, o.Value())
assert.Equal(t, 1, callCount)
o.Reset()
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
})
t.Run("Panic", func(t *testing.T) {
callCount := 0
doPanic := true
o := OnceValue[int]{Func: func() int {
callCount++
if doPanic {
panic("some error")
}
return 42
}}
assert.PanicsWithValue(t, "some error", func() { o.Value() })
assert.PanicsWithValue(t, "some error", func() { o.Value() })
assert.Equal(t, 1, callCount)
doPanic = false
o.Reset()
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
assert.Equal(t, 42, o.Value())
assert.Equal(t, 2, callCount)
})
}
+7
View File
@@ -40,6 +40,7 @@ var timeStrGlobalVars = sync.OnceValue(func() *timeStrGlobalVarsType {
})
func TimeEstimateParse(timeStr string) (int64, error) {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
return 0, nil
}
@@ -51,7 +52,13 @@ func TimeEstimateParse(timeStr string) (int64, error) {
if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) {
return 0, fmt.Errorf("invalid time string: %s", timeStr)
}
prevEnd := 0
for _, match := range matches {
// only whitespace may separate two units, otherwise the string contains invalid content like "1h x 2m"
if strings.TrimSpace(timeStr[prevEnd:match[0]]) != "" {
return 0, fmt.Errorf("invalid time string: %s", timeStr)
}
prevEnd = match[1]
amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid time string: %v", err)
+3
View File
@@ -22,6 +22,9 @@ func TestTimeStr(t *testing.T) {
{"1s", 1, false},
{"1h 1m 1s", 3600 + 60 + 1, false},
{"1d1x", 0, true},
{"1h 2x 3m", 0, true},
{"1h_2m", 0, true},
{"1h,1m", 0, true},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
+3 -1
View File
@@ -73,7 +73,9 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
}
if publicOnly {
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
// a public-only token must not reach limited-visibility owners either,
// matching how orgs/users are enforced elsewhere in this file
if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() {
ctx.HTTPError(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
return
}
+8 -1
View File
@@ -333,11 +333,18 @@ func ListPackageTags(ctx *context.Context) {
func AddPackageTag(ctx *context.Context) {
packageName := packageNameFromParams(ctx)
body, err := io.ReadAll(ctx.Req.Body)
// the dist-tag body is only a quoted version string; bound it to avoid an unbounded
// read that could exhaust memory
const maxDistTagBodySize = 4 * 1024
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, maxDistTagBodySize+1))
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
if len(body) > maxDistTagBodySize {
apiError(ctx, http.StatusRequestEntityTooLarge, errors.New("request body too large"))
return
}
version := strings.Trim(string(body), "\"") // is as "version" in the body
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName, version)
+3 -1
View File
@@ -291,7 +291,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
return
}
case auth_model.AccessTokenScopeCategoryPackage:
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
// a public-only token must not reach limited-visibility owners either,
// matching the org/user public-only enforcement above
if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() {
ctx.APIError(http.StatusForbidden, "token scope is limited to public packages")
return
}
+5 -6
View File
@@ -178,13 +178,12 @@ func DeleteIssueLabel(ctx *context.APIContext) {
return
}
label, err := issues_model.GetLabelByID(ctx, ctx.PathParamInt64("id"))
// the label must belong to this repo (or its owning org); otherwise a foreign label ID
// is rejected the same way as a nonexistent one, closing a cross-repo enumeration oracle
labelID := ctx.PathParamInt64("id")
label, err := issues_model.GetLabelInRepoOrOrgByID(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner.ID, ctx.Repo.Owner.IsOrganization(), labelID)
if err != nil {
if issues_model.IsErrLabelNotExist(err) {
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}
+2 -1
View File
@@ -221,7 +221,8 @@ func AddTime(ctx *context.APIContext) {
// allow only RepoAdmin, Admin and User to add time
user, err = user_model.GetUserByName(ctx, form.User)
if err != nil {
ctx.APIErrorInternal(err)
ctx.APIErrorAuto(err)
return
}
}
}
+2 -10
View File
@@ -106,11 +106,7 @@ func GetLabel(ctx *context.APIContext) {
l, err = issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, intID)
}
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}
@@ -214,11 +210,7 @@ func EditLabel(ctx *context.APIContext) {
form := web.GetForm(ctx).(*api.EditLabelOption)
l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
ctx.APIErrorAuto(err)
return
}
+7
View File
@@ -1251,6 +1251,13 @@ func UpdatePullRequest(ctx *context.APIContext) {
return
}
// a public-only token must not update (push into) a private head repo,
// even when the base repo named in the route is public
if !ctx.TokenCanAccessRepo(pr.HeadRepo) {
ctx.APIErrorNotFound()
return
}
// keep API back-compat: when no style is given, default to "merge" rather than the repo's DefaultUpdateStyle,
// so existing API clients keep getting a merge update.
rebase := repo_model.UpdateStyle(ctx.FormString("style", string(repo_model.UpdateStyleMerge))) == repo_model.UpdateStyleRebase
+17
View File
@@ -21,8 +21,21 @@ import (
"gitea.dev/routers/api/v1/utils"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"xorm.io/builder"
)
// actionsOwnerAccessibleRepoIDsSubQuery returns the sub-query restricting an owner-scoped actions
// listing to the repos whose actions the caller can read, or nil when no restriction applies. A bare
// org member must not be able to enumerate runs/jobs of repos they have no access to. A site admin may
// skip the access filter, but a public-only token must stay confined to public repos even for an admin.
func actionsOwnerAccessibleRepoIDsSubQuery(ctx *context.APIContext, ownerID int64) *builder.Builder {
if ownerID > 0 && (ctx.Doer == nil || !ctx.Doer.IsAdmin || ctx.PublicOnly) {
return repo_model.FindUserActionsAccessibleOwnerRepoIDsSubQuery(ownerID, ctx.Doer, ctx.PublicOnly)
}
return nil
}
// ListJobs lists jobs for api route validated ownerID and repoID
// ownerID == 0 and repoID == 0 means all jobs
// ownerID == 0 and repoID != 0 means all jobs for the given repo
@@ -60,6 +73,8 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
opts.Statuses = append(opts.Statuses, values...)
}
opts.AccessibleRepoIDsSubQuery = actionsOwnerAccessibleRepoIDsSubQuery(ctx, opts.OwnerID)
jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
@@ -181,6 +196,8 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
}
excludePullRequests := ctx.FormBool("exclude_pull_requests")
opts.AccessibleRepoIDsSubQuery = actionsOwnerAccessibleRepoIDsSubQuery(ctx, opts.OwnerID)
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
+23
View File
@@ -126,6 +126,29 @@ func CreateAccessToken(ctx *context.APIContext) {
}
t.Scope = scope
// a token-authenticated request must not mint a token with a broader scope than its own
if ctx.Data["IsApiToken"] == true {
apiTokenScope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
ctx.APIError(http.StatusForbidden, "the authenticating token has no scope")
return
}
hasScope, err := apiTokenScope.CanCreateChildScope(scope)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !hasScope {
ctx.APIError(http.StatusForbidden, "cannot create an access token with a broader scope than the authenticating token")
return
}
// a public-only token must not mint a token that drops the public-only restriction
if t.Scope, err = t.Scope.EnforcePublicOnlyFrom(apiTokenScope); err != nil {
ctx.APIErrorInternal(err)
return
}
}
if err := auth_model.NewAccessToken(ctx, t); err != nil {
ctx.APIErrorInternal(err)
return
+11
View File
@@ -8,6 +8,7 @@ import (
"net/http"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/web"
"gitea.dev/services/context"
@@ -57,6 +58,11 @@ func AddEmail(ctx *context.APIContext) {
// "422":
// "$ref": "#/responses/validationError"
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
ctx.APIErrorNotFound("emails are not allowed to be changed")
return
}
form := web.GetForm(ctx).(*api.CreateEmailOption)
if len(form.Emails) == 0 {
ctx.APIError(http.StatusUnprocessableEntity, "Email list empty")
@@ -114,6 +120,11 @@ func DeleteEmail(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
ctx.APIErrorNotFound("emails are not allowed to be changed")
return
}
form := web.GetForm(ctx).(*api.DeleteEmailOption)
if len(form.Emails) == 0 {
ctx.Status(http.StatusNoContent)
+24 -3
View File
@@ -19,9 +19,11 @@ import (
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/container"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/proxy"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
source_service "gitea.dev/services/auth/source"
@@ -296,7 +298,23 @@ func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.Us
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
// oauth2AvatarAllowList parses the host allow-list applied to avatar fetches from the global
// [security] ALLOWED_HOST_LIST, defaulting an empty setting to the built-in "external" set. An empty
// host-match list would otherwise disable the allow-list check entirely and permit any host, including
// loopback/private addresses (SSRF).
func oauth2AvatarAllowList() *hostmatcher.HostMatchList {
return hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
}
// oauth2AvatarHTTPClient builds the SSRF-protected client for avatar fetches. It is constructed per call
// so a changed allowlist takes effect (avatar fetches are infrequent, so this is not a hot path).
func oauth2AvatarHTTPClient() *http.Client {
allowList := oauth2AvatarAllowList()
return &http.Client{
Timeout: 30 * time.Second,
Transport: hostmatcher.NewHTTPTransport("oauth2-avatar", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
}
}
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
@@ -310,7 +328,7 @@ func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_mo
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
resp, err := oauth2AvatarHTTPClient.Do(req)
resp, err := oauth2AvatarHTTPClient().Do(req)
if err != nil {
log.Warn("fetch %q failed: %v", avatarURL, err)
return
@@ -373,7 +391,10 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
ctx.ServerError("GetExternalLogin", err)
return
}
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
// the cron clears all three token fields when it disables a user, so require the
// full signature; a RefreshToken alone is empty for many normal logins (e.g. GitHub
// or OIDC without offline_access), which would otherwise reactivate admin-disabled users
isDisabledByAutoSync := hasExt && extLogin.AccessToken == "" && extLogin.RefreshToken == "" && extLogin.ExpiresAt.IsZero()
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
+13
View File
@@ -98,6 +98,19 @@ func InfoOAuth(ctx *context.Context) {
return
}
// enforce the same user scope the REST API requires before returning identity
// claims; OIDC access tokens map to the "all" scope, so standard OIDC clients
// are unaffected and only explicitly-restricted tokens are rejected
tokenScope, _ := ctx.Data["ApiTokenScope"].(auth.AccessTokenScope)
if allowed, err := tokenScope.HasScope(auth.AccessTokenScopeReadUser); err != nil {
ctx.ServerError("HasScope", err)
return
} else if !allowed {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusForbidden, "token does not have required scope: read:user")
return
}
response := &userInfoResponse{
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
Name: ctx.Doer.DisplayName(),
+46
View File
@@ -4,15 +4,22 @@
package auth
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
@@ -73,3 +80,42 @@ func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
assert.Equal(t, user.Email, oidcToken.Email)
assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
}
func TestOAuth2AvatarClientBlocksLoopback(t *testing.T) {
var hit atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit.Store(true)
_, _ = w.Write([]byte("img"))
}))
defer srv.Close()
// the httptest server binds a loopback address, which the SSRF-protected dialer must refuse
resp, err := oauth2AvatarHTTPClient().Get(srv.URL)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.False(t, hit.Load(), "avatar client must refuse to dial a loopback address")
}
func TestOAuth2AvatarAllowListRestricts(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
allowList := oauth2AvatarAllowList()
assert.True(t, allowList.MatchHostName("avatars.example.com"), "the configured host must be allowed")
assert.False(t, allowList.MatchHostName("8.8.8.8"), "an unrelated external host must be rejected")
// the default `external` allow-list still permits external hosts
setting.Security.AllowedHostList = hostmatcher.MatchBuiltinExternal
assert.True(t, oauth2AvatarAllowList().MatchHostName("8.8.8.8"), "default allow-list permits external hosts")
}
func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
// external-only allow-list must reject link-local cloud metadata (169.254.169.254) at dial time
resp, err := oauth2AvatarHTTPClient().Get("http://169.254.169.254/latest/meta-data/")
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.ErrorContains(t, err, "can only call allowed HTTP servers",
"avatar client must refuse a link-local cloud-metadata address")
}
+5
View File
@@ -41,6 +41,11 @@ func showUserFeed(ctx *context.Context, formatType string) {
includePrivate = isOrgMember
}
// a public-only API token must not surface private activity, even for its own owner
if includePrivate && context.TokenIsPublicOnly(ctx) {
includePrivate = false
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
+50 -4
View File
@@ -11,7 +11,11 @@ import (
"path"
"strings"
auth_model "gitea.dev/models/auth"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
@@ -51,10 +55,8 @@ func goGet(ctx *context.Context) {
return
}
branchName := setting.Repository.DefaultBranch
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err == nil && len(repo.DefaultBranch) > 0 {
branchName = repo.DefaultBranch
if repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName); err == nil {
branchName = goGetDefaultBranch(ctx, repo)
}
prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
@@ -91,3 +93,47 @@ func goGet(ctx *context.Context) {
ctx.RespHeader().Set("Content-Type", "text/html")
_, _ = ctx.Write([]byte(res))
}
// goGetDefaultBranch returns the repository's real default branch only when the caller may genuinely
// reach it, otherwise the neutral instance default, so the meta response does not disclose the branch
// name (or the repo's existence) to callers who cannot see the repository.
func goGetDefaultBranch(ctx *context.Context, repo *repo_model.Repository) string {
def := setting.Repository.DefaultBranch
if len(repo.DefaultBranch) == 0 {
return def
}
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
return def
}
// a token that was not granted repository read scope must not learn repository details, even when the
// account behind it could read the repo through the web UI
if !goGetTokenCanReadRepo(ctx) {
return def
}
// a public-only token may only reach genuinely public resources (a public repo under a public owner)
if context.TokenIsPublicOnly(ctx) && (repo.IsPrivate || !repo.Owner.Visibility.IsPublic()) {
return def
}
// the caller must be able to read the code and see the owner: a limited/private owner hides its repos
// from anonymous/non-member callers even when the repo itself is public
perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil || !perm.CanRead(unit.TypeCode) || !user_model.IsUserVisibleToViewer(ctx, repo.Owner, ctx.Doer) {
return def
}
return repo.DefaultBranch
}
// goGetTokenCanReadRepo reports whether the request may learn repository details. A non-token request
// always may; a token request may only when its scope grants repository read, so a PAT that was never
// scoped for repositories cannot disclose the branch even if its owner can read the repo.
func goGetTokenCanReadRepo(ctx *context.Context) bool {
if ctx.Data["IsApiToken"] != true {
return true
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return false
}
has, err := scope.HasScope(auth_model.AccessTokenScopeReadRepository)
return err == nil && has
}
+4 -2
View File
@@ -179,9 +179,11 @@ func UpdateIssueLabel(ctx *context.Context) {
}
}
case "attach", "detach", "toggle", "toggle-alt":
label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id"))
// scope the label to this repo (or its org) so a foreign label ID is 404, not an oracle
labelID := ctx.FormInt64("id")
label, err := issues_model.GetLabelInRepoOrOrgByID(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner.ID, ctx.Repo.Owner.IsOrganization(), labelID)
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound, "GetLabelByID")
} else {
ctx.ServerError("GetLabelByID", err)
+5 -2
View File
@@ -418,7 +418,7 @@ func ViewIssue(ctx *context.Context) {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}
if !setting.IsProd && issue.PullRequest != nil && !issue.PullRequest.IsChecking() && prViewInfo.MergeBoxData != nil {
if !setting.IsProd && issue.PullRequest != nil && prViewInfo.MergeBoxData != nil && prViewInfo.MergeBoxData.ReloadingInterval == 0 {
prViewInfo.MergeBoxData.ReloadingInterval = 1 // in dev env, force using the reloading logic to make sure it won't break
}
@@ -919,7 +919,6 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
}
}
data.ReloadingInterval = util.Iif(pull.IsChecking(), 2000, 0)
data.ShowMergeInstructions = canWriteToHeadRepo
data.ShowPullCommands = pull.HeadRepo != nil && !pull.HasMerged && !issue.IsClosed
@@ -941,6 +940,10 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
prConfig := issue.Repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
data.AutodetectManualMerge = prConfig.AutodetectManualMerge
needRefreshMergeBox := pull.IsChecking()
needRefreshMergeBox = needRefreshMergeBox || (data.StatusCheckData != nil && data.StatusCheckData.pullCommitStatusState.IsPending())
data.ReloadingInterval = util.Iif(needRefreshMergeBox, 5000, 0)
// Only show the merge box if the PR is not merged, or the branch is deletable.
// Otherwise, there is nothing to do, because the PR view page already contains enough information.
data.ShowMergeBox = !pull.HasMerged || data.IsPullBranchDeletable
+8
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
@@ -394,6 +395,13 @@ func Home(ctx *context.Context) {
return
}
// a scoped or public-only API token authenticating this web request must still satisfy
// the repository read scope before private repo content is served
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
if ctx.Written() {
return
}
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
+4
View File
@@ -170,6 +170,10 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
date := ctx.FormString("date")
pagingNum = setting.UI.FeedPagingNum
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
// a public-only API token must not surface private activity, even for its own owner
if showPrivate && context.TokenIsPublicOnly(ctx) {
showPrivate = false
}
items, feedCount, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
+24
View File
@@ -79,6 +79,30 @@ func ApplicationsPost(ctx *context.Context) {
return
}
// a token-authenticated request must not mint a token with a broader scope than its own, nor
// drop the public-only restriction. Web routes accept basic-auth PATs/OAuth tokens too, so this
// must mirror the REST API guard in routers/api/v1/user/app.go.
if ctx.Data["IsApiToken"] == true {
apiTokenScope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
ctx.HTTPError(http.StatusForbidden, "the authenticating token has no scope")
return
}
hasScope, err := apiTokenScope.CanCreateChildScope(t.Scope)
if err != nil {
ctx.ServerError("CanCreateChildScope", err)
return
}
if !hasScope {
ctx.HTTPError(http.StatusForbidden, "cannot create an access token with a broader scope than the authenticating token")
return
}
if t.Scope, err = t.Scope.EnforcePublicOnlyFrom(apiTokenScope); err != nil {
ctx.ServerError("EnforcePublicOnlyFrom", err)
return
}
}
if err := auth_model.NewAccessToken(ctx, t); err != nil {
ctx.ServerError("NewAccessToken", err)
return
+15 -6
View File
@@ -8,11 +8,13 @@ import (
"errors"
"fmt"
"sync"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
secret_model "gitea.dev/models/secret"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
@@ -48,6 +50,17 @@ func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task
return task, ok, false, err
}
// releaseTaskForRunnerCleanup releases a claimed task using a fresh, bounded context. The request context
// is typically already canceled when we reach the release paths below, and a DB transaction on a canceled
// context fails immediately, which would strand the claimed job in running state.
func releaseTaskForRunnerCleanup(t *actions_model.ActionTask) {
ctx, cancel := context.WithTimeout(graceful.GetManager().ShutdownContext(), 10*time.Second)
defer cancel()
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
}
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
var (
task *runnerv1.Task
@@ -92,9 +105,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
// The job was already claimed but assembling its payload failed; release the
// claim so the job returns to the waiting queue instead of being stranded in
// running state with no runner ever executing it.
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
actionTask = t
@@ -110,9 +121,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
// Release the claim so another runner can pick the job up.
if err := ctx.Err(); err != nil {
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
+40
View File
@@ -7,6 +7,8 @@ import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -32,3 +34,41 @@ func TestTryPickTaskThrottled(t *testing.T) {
assert.False(t, ok)
assert.True(t, throttled)
}
// TestReleaseTaskForRunnerCleanup verifies the cleanup used by PickTask releases a claimed task through a
// fresh context. PickTask reaches this path when the request context is already canceled, and on
// PostgreSQL/MySQL a DB transaction on a canceled context fails immediately; reusing it would strand the
// claimed job in running state, so the cleanup must not use the request context.
func TestReleaseTaskForRunnerCleanup(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run := &actions_model.ActionRun{
Title: "cleanup-run", RepoID: 1, OwnerID: 2, WorkflowID: "test.yaml",
TriggerUserID: 2, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "cleanup-job", Attempt: 1, JobID: "cleanup-job", Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: []byte("on: push\njobs:\n cleanup-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &actions_model.ActionRunner{Name: "cleanup-runner", AgentLabels: []string{"ubuntu-latest"}}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
// the cleanup helper uses its own context, so the claimed job is returned to the waiting queue
releaseTaskForRunnerCleanup(task)
released := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusWaiting, released.Status)
assert.Zero(t, released.TaskID)
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
}
+4
View File
@@ -57,6 +57,10 @@ func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, e
if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 {
// If an attacker steals a token and uses the token to create a new session the hash gets updated.
// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
// Revoke the token so the attacker's rotated token (which shares this ID) can no longer be used.
if err := auth_model.DeleteAuthTokenByID(ctx, t.ID); err != nil {
return nil, err
}
return nil, ErrAuthTokenInvalidHash
}
+4 -1
View File
@@ -10,6 +10,7 @@ import (
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -62,7 +63,9 @@ func TestCheckAuthToken(t *testing.T) {
assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
assert.Nil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
// a hash mismatch signals a compromised token, which must be revoked
_, err = auth_model.GetAuthTokenByID(t.Context(), at.ID)
assert.ErrorIs(t, err, util.ErrNotExist)
})
t.Run("Valid", func(t *testing.T) {
+3 -2
View File
@@ -49,9 +49,10 @@ type APIContext struct {
}
// TokenCanAccessRepo reports whether the current API token is allowed to access the repository.
// A public-only token cannot reach a private repo; any other token is unrestricted by this check.
// A public-only token cannot reach a private repo or a repo owned by a non-public (limited or
// private) owner; any other token is unrestricted by this check.
func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool {
return repo == nil || !ctx.PublicOnly || !repo.IsPrivate
return !ctx.PublicOnly || !publicOnlyTokenDeniedRepo(ctx, repo)
}
func init() {
+35 -1
View File
@@ -4,6 +4,7 @@
package context
import (
"context"
"net/http"
"slices"
@@ -12,6 +13,39 @@ import (
"gitea.dev/models/unit"
)
// isOwnerHidden reports whether repo's owner is not publicly visible (a limited or private owner), so
// the owner's repositories must be hidden from callers that may only reach genuinely public resources.
func isOwnerHidden(ctx context.Context, repo *repo_model.Repository) bool {
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
return true // fail closed if the owner visibility can't be determined
}
return !repo.Owner.Visibility.IsPublic()
}
// publicOnlyTokenDeniedRepo reports whether a public-only API token must be denied access to
// repo. A public-only token may only reach genuinely public resources, so it is denied for
// private repos and for repos owned by a non-public (limited or private) owner.
func publicOnlyTokenDeniedRepo(ctx context.Context, repo *repo_model.Repository) bool {
if repo == nil {
return false
}
return repo.IsPrivate || isOwnerHidden(ctx, repo)
}
// TokenIsPublicOnly reports whether the request is authenticated by a public-only API token. A
// non-token request, or a token with no recorded scope, is not public-only.
func TokenIsPublicOnly(ctx *Context) bool {
if ctx.Data["IsApiToken"] != true {
return false
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return false
}
publicOnly, _ := scope.PublicOnly()
return publicOnly
}
// CheckTokenScopes checks whether the authenticated API token contains any of the given scopes.
func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_model.AccessTokenScope) {
if ctx.Data["IsApiToken"] != true {
@@ -29,7 +63,7 @@ func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_
return
}
if publicOnly && repo != nil && repo.IsPrivate {
if publicOnly && publicOnlyTokenDeniedRepo(ctx, repo) {
ctx.HTTPError(http.StatusForbidden)
return
}
+7 -4
View File
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -310,7 +309,9 @@ func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.R
}
defer rc.Close()
} else {
resp, err := http.Get(*asset.DownloadURL)
// use the migration client so the fetch (including any redirect) is
// validated against the migration host allow/block list
resp, err := getMigrationHTTPClient().Get(*asset.DownloadURL)
if err != nil {
return err
}
@@ -450,8 +451,10 @@ func (g *RepositoryDumper) handlePullRequest(ctx context.Context, pr *base.PullR
}
// SECURITY: We will assume that the pr.PatchURL has been checked
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe
resp, err := http.Get(u) // TODO: This probably needs to use the downloader as there may be rate limiting issues here
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe.
// Use the migration client so an http(s) PatchURL (and any redirect it follows) is
// validated against the migration host allow/block list at dial time.
resp, err := getMigrationHTTPClient().Get(u)
if err != nil {
return err
}
+2 -2
View File
@@ -84,7 +84,7 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
baseURL,
gitea_sdk.SetToken(token),
gitea_sdk.SetBasicAuth(username, password),
gitea_sdk.SetHTTPClient(NewMigrationHTTPClient()),
gitea_sdk.SetHTTPClient(newMigrationHTTPClient()),
)
if err != nil {
log.Error(fmt.Sprintf("Failed to create NewGiteaDownloader for: %s. Error: %v", baseURL, err))
@@ -273,7 +273,7 @@ func (g *GiteaDownloader) convertGiteaRelease(rel *gitea_sdk.Release) *base.Rele
Created: rel.CreatedAt,
}
httpClient := NewMigrationHTTPClient()
httpClient := newMigrationHTTPClient()
for _, asset := range rel.Attachments {
assetID := asset.ID // Don't optimize this, for closure we need a local variable
+7 -3
View File
@@ -340,7 +340,9 @@ func (g *GiteaLocalUploader) CreateReleases(ctx context.Context, releases ...*ba
return err
}
} else if asset.DownloadURL != nil {
rc, err = uri.Open(*asset.DownloadURL)
// use the migration client so the fetch (including any redirect) is
// validated against the migration host allow/block list
rc, err = uri.OpenWithClient(*asset.DownloadURL, getMigrationHTTPClient())
if err != nil {
return err
}
@@ -585,8 +587,10 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(ctx context.Context, pr *ba
}
// SECURITY: We will assume that the pr.PatchURL has been checked
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe
ret, err := uri.Open(pr.PatchURL) // TODO: This probably needs to use the downloader as there may be rate limiting issues here
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe.
// Use the migration client so an http(s) PatchURL (and any redirect it follows) is
// validated against the migration host allow/block list at dial time.
ret, err := uri.OpenWithClient(pr.PatchURL, getMigrationHTTPClient())
if err != nil {
return err
}
+1 -1
View File
@@ -329,7 +329,7 @@ func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *gith
r.Published = rel.PublishedAt.Time
}
httpClient := NewMigrationHTTPClient()
httpClient := newMigrationHTTPClient()
for _, asset := range rel.Assets {
assetID := asset.GetID() // Don't optimize this, for closure we need a local variable TODO: no need to do so in new Golang
+2 -2
View File
@@ -94,7 +94,7 @@ type GitlabDownloader struct {
// Use either a username/password, personal token entered into the username field, or anonymous/public access
// Note: Public access only allows very basic access
func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, token string) (*GitlabDownloader, error) {
gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(newMigrationHTTPClient()))
if err != nil {
log.Trace("Error logging into gitlab: %v", err)
return nil, err
@@ -314,7 +314,7 @@ func (g *GitlabDownloader) convertGitlabRelease(ctx context.Context, rel *gitlab
PublisherName: rel.Author.Username,
}
httpClient := NewMigrationHTTPClient()
httpClient := newMigrationHTTPClient()
for _, asset := range rel.Assets.Links {
assetID := asset.ID // Don't optimize this, for closure we need a local variable
+21 -8
View File
@@ -10,20 +10,33 @@ import (
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// NewMigrationHTTPClient returns a HTTP client for migration
func NewMigrationHTTPClient() *http.Client {
// migrationHTTPClient is the shared migration client. Callers that would otherwise build a client per
// request use it (via getMigrationHTTPClient) so a single connection pool is reused across downloads —
// e.g. many release assets from the same host — instead of a fresh pool and TLS handshake each time. It
// is built lazily on first use and reset by Init whenever the allow/block lists change; OnceValue keeps
// concurrent callers sharing a single client instead of racing to create their own.
var migrationHTTPClient = util.OnceValue[*http.Client]{Func: newMigrationHTTPClient}
// newMigrationHTTPClient returns a HTTP client for migration
func newMigrationHTTPClient() *http.Client {
return &http.Client{
Transport: NewMigrationHTTPTransport(),
}
}
// NewMigrationHTTPTransport returns a HTTP transport for migration
// getMigrationHTTPClient returns the shared migration client, building it on first use so no request
// escapes the SSRF-validated transport even before Init has run.
func getMigrationHTTPClient() *http.Client {
return migrationHTTPClient.Value()
}
// NewMigrationHTTPTransport returns a HTTP transport for migration. The target is validated against the
// allow/block lists on both the direct-dial and proxy paths, so a configured proxy cannot be used to
// reach an otherwise-forbidden target (SSRF).
func NewMigrationHTTPTransport() *http.Transport {
return &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Migrations.SkipTLSVerify},
Proxy: proxy.Proxy(),
DialContext: hostmatcher.NewDialContext("migration", allowList, blockList, setting.Proxy.ProxyURLFixed),
}
return hostmatcher.NewHTTPTransport("migration", allowList, blockList, proxy.Proxy(), setting.Proxy.ProxyURLFixed,
&tls.Config{InsecureSkipVerify: setting.Migrations.SkipTLSVerify})
}
+4
View File
@@ -528,5 +528,9 @@ func Init() error {
blockList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
}
// reset the shared client so it is rebuilt from the freshly parsed lists on next use; download paths
// then reuse one connection pool instead of creating a client (and pool) per request
migrationHTTPClient.Reset()
return nil
}
+9
View File
@@ -115,6 +115,15 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
log.Error("SyncMirrors [repo: %-v]: GetRemoteURL Error %v", m.Repo, remoteErr)
return nil, false
}
// re-validate on every sync: the host may now resolve to an internal IP (rebinding) or the
// allow/block list may have changed. ssh/file are skipped (not an HTTP SSRF vector).
switch remoteURL.URL.Scheme {
case "http", "https", "git":
if allowErr := migrations.IsMigrateURLAllowed(remoteURL.String(), m.Repo.MustOwner(ctx)); allowErr != nil {
log.Error("SyncMirrors [repo: %-v]: remote URL is not allowed: %v", m.Repo, allowErr)
return nil, false
}
}
envs := proxy.EnvWithProxy(remoteURL.URL)
timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second
+5
View File
@@ -120,9 +120,14 @@ func updateRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Reposi
return err
}
// the repo is no longer publicly visible, so drop stars and watches from users who can no longer
// see it, matching the direct repository-private transition (see services/repository)
if err := repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
return err
}
if err := repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
return err
}
}
// Create/Remove git-daemon-export-ok for git-daemon...
+19
View File
@@ -68,4 +68,23 @@ func TestOrg(t *testing.T) {
require.NoError(t, ChangeOrganizationVisibility(t.Context(), org, structs.VisibleTypePrivate))
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.ID, Visibility: structs.VisibleTypePrivate})
})
t.Run("ChangeVisibilityClearsWatchesAndStars", func(t *testing.T) {
// org3 is a public organization owning the public repo32
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
require.Equal(t, structs.VisibleTypePublic, org.Visibility)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32, OwnerID: org.ID})
// an outside user watches and stars the repo while the org is still visible
watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
require.NoError(t, repo_model.WatchRepo(t.Context(), watcher, repo, true))
require.NoError(t, repo_model.StarRepo(t.Context(), watcher, repo, true))
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
require.NoError(t, ChangeOrganizationVisibility(t.Context(), org, structs.VisibleTypePrivate))
// making the org private must drop watches, not only stars, from users who can no longer see it
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: watcher.ID, RepoID: repo.ID})
})
}
+18 -1
View File
@@ -24,6 +24,9 @@ func Test_Projects(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org3 := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 3})
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
// user15 is on org3's team7 (write access to the public repo32 only), so it can see org3 public repos
// but has no access to the private repo3 — a genuine "no permission to the private repo" org member.
user15 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
t.Run("User projects", func(t *testing.T) {
pi1 := project_model.ProjectIssue{
@@ -142,13 +145,27 @@ func Test_Projects(t *testing.T) {
})
t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) {
// user2 is on org3's Owners team and has owner access to the private repo3, so it is not a
// valid "no permission" subject; user15 has no access to repo3 but can see the public repo32.
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
Owner: org3.AsUser(),
Doer: user15,
})
assert.NoError(t, err)
assert.Len(t, columnIssues, 1)
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user15 can only visit public repo issues
})
t.Run("Org owner team member", func(t *testing.T) {
// user2 is on org3's Owners team, so it has access to the private repo3 and must see both the
// public and the private issue — the owner-team access that team.authorize grants at runtime.
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
Owner: org3.AsUser(),
Doer: user2,
})
assert.NoError(t, err)
assert.Len(t, columnIssues, 1)
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user2 can only visit public repo issues
assert.Len(t, columnIssues[defaultColumn.ID], 2) // owner-team member visits both public and private issues
})
})
+5
View File
@@ -273,6 +273,11 @@ func updateRepository(ctx context.Context, repo *repo_model.Repository, visibili
if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
return err
}
// watchers who lost access must not keep watching the now-private repo
if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
return err
}
}
// Create/Remove git-daemon-export-ok for git-daemon...
+23
View File
@@ -90,3 +90,26 @@ func TestMakeRepoPrivateClearsWatches(t *testing.T) {
assert.True(t, updatedRepo.IsPrivate)
assert.Zero(t, updatedRepo.NumWatches)
}
// TestUpdateRepositoryClearsWatchesOnVisibilityChange ensures the shared updateRepository
// helper (used by the API EditRepo path) also clears watches when a repo goes private.
func TestUpdateRepositoryClearsWatchesOnVisibilityChange(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo.IsPrivate)
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
require.NoError(t, err)
require.NotEmpty(t, watchers)
repo.IsPrivate = true
require.NoError(t, updateRepository(t.Context(), repo, true))
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
assert.NoError(t, err)
assert.Empty(t, watchers)
updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Zero(t, updatedRepo.NumWatches)
}
+5 -11
View File
@@ -308,20 +308,14 @@ func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request)
// Init starts the hooks delivery thread
func Init() error {
timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second
allowedHostMatcher := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Webhook.AllowedHostList)
allowedHostListValue := setting.Webhook.AllowedHostList
if allowedHostListValue == "" {
allowedHostListValue = hostmatcher.MatchBuiltinExternal
}
allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", allowedHostListValue)
// NewHTTPTransport enforces the allow-list on direct connections; when webhookProxy routes a request
// through a configured proxy, restricting the proxied target is the proxy server's responsibility.
webhookHTTPClient = &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
Proxy: webhookProxy(allowedHostMatcher),
DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
},
Transport: hostmatcher.NewHTTPTransport("webhook", allowedHostMatcher, nil, webhookProxy(allowedHostMatcher), setting.Webhook.ProxyURLFixed,
&tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify}),
}
hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
+10 -13
View File
@@ -404,20 +404,17 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
config["color"] = s.Color
}
authorizationHeader, err := w.HeaderAuthorization()
if err != nil {
return nil, err
}
return &api.Hook{
ID: w.ID,
Name: w.Name,
Type: w.Type,
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
Active: w.IsActive,
Config: config,
Events: w.EventsArray(),
AuthorizationHeader: authorizationHeader,
ID: w.ID,
Name: w.Name,
Type: w.Type,
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
Active: w.IsActive,
Config: config,
Events: w.EventsArray(),
// the stored authorization header is a secret and must never be returned by the API,
// consistent with the webhook secret which is also omitted from the response
AuthorizationHeader: "",
Updated: w.UpdatedUnix.AsTime(),
Created: w.CreatedUnix.AsTime(),
BranchFilter: w.BranchFilter,
+3 -2
View File
@@ -15,10 +15,11 @@ import (
)
func TestMain(m *testing.M) {
// for tests, allow only loopback IPs
setting.Webhook.AllowedHostList = hostmatcher.MatchBuiltinLoopback
unittest.MainTest(m, &unittest.TestOptions{
SetUp: func() error {
// for tests, allow only loopback IPs. This must run after the test config is loaded (which
// resets the shared Security.AllowedHostList) and before Init() builds the delivery client.
setting.Security.AllowedHostList = hostmatcher.MatchBuiltinLoopback
setting.LoadQueueSettings()
return Init()
},
@@ -18,12 +18,16 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/migration"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
migrations_service "gitea.dev/services/migrations"
mirror_service "gitea.dev/services/mirror"
repo_service "gitea.dev/services/repository"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScheduleUpdate(t *testing.T) {
@@ -144,6 +148,11 @@ jobs:
func testScheduleUpdateMirrorSync(t *testing.T) {
doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) {
// the mirror sync re-validates the remote URL, which rejects the local test server unless local
// networks are allowed; migrations.Init rebuilds the host allow-list from the setting
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
require.NoError(t, migrations_service.Init())
// create mirror repo
opts := migration.MigrateOptions{
RepoName: "actions-schedule-mirror",
+42
View File
@@ -372,3 +372,45 @@ func testAPIActionsListRepoWorkflows(t *testing.T) {
assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated")
}
}
// TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not
// leak runs/jobs from repos the caller cannot access.
func TestAPIOrgActionsRunsAccessControl(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// org3 has action run 802 (and its jobs) in the private repo5; user28 is an org3 member
// (teams 12/13) with no access to repo5.
token := getUserToken(t, "user28", auth_model.AccessTokenScopeReadOrganization)
req := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
for _, r := range runs.Entries {
assert.NotEqual(t, int64(802), r.ID, "must not leak a run from an inaccessible repo")
}
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
for _, j := range jobs.Entries {
assert.NotEqual(t, int64(802), j.RunID, "must not leak a job from an inaccessible repo run")
}
// user1 is a site admin: it normally bypasses the per-repo access filter, but a public-only token
// must stay confined to public repos, so the run/job in the private repo5 must not be listed.
adminPublicOnly := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(adminPublicOnly)
resp = MakeRequest(t, req, http.StatusOK)
adminRuns := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
for _, r := range adminRuns.Entries {
assert.NotEqual(t, int64(802), r.ID, "a public-only admin token must not list a private repo's run")
}
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(adminPublicOnly)
resp = MakeRequest(t, req, http.StatusOK)
adminJobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
for _, j := range adminJobs.Entries {
assert.NotEqual(t, int64(802), j.RunID, "a public-only admin token must not list a private repo's job")
}
}
+33
View File
@@ -111,6 +111,39 @@ func TestAPIAddIssueLabels(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2})
}
// TestAPIDeleteIssueLabelCrossRepo ensures DeleteIssueLabel does not act on a label
// belonging to another repository, and that a foreign-but-existing label ID and a
// nonexistent label ID return the same status (no cross-repo enumeration oracle).
func TestAPIDeleteIssueLabelCrossRepo(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// label 5 exists but belongs to repo 10, not repo 1
foreignLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 5})
assert.NotEqual(t, repo.ID, foreignLabel.RepoID)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
base := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", repo.OwnerName, repo.Name, issue.Index)
// a foreign-but-existing label ID must not be accepted (was 204 before the fix)
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, foreignLabel.ID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// a nonexistent label ID must return the SAME status, so no oracle exists (was 422 before the fix)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 9999999)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// a label that belongs to the repo is still removable
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2, RepoID: repo.ID})
addReq := NewRequestWithJSON(t, "POST", base, &api.IssueLabelsOption{Labels: []any{2}}).AddTokenAuth(token)
MakeRequest(t, addReq, http.StatusOK)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 2)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
}
func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
@@ -195,4 +195,11 @@ func TestAPIAddTrackedTimes(t *testing.T) {
assert.EqualValues(t, 33, apiNewTime.Time)
assert.Equal(t, user2.ID, apiNewTime.UserID)
assert.EqualValues(t, 947688818, apiNewTime.Created.Unix())
// adding time for a nonexistent user must return 404, not panic with a 500
req = NewRequestWithJSON(t, "POST", urlStr, &api.AddTimeOption{
Time: 33,
User: "nonexistentuser",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
@@ -12,6 +12,7 @@ import (
neturl "net/url"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/packages"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
@@ -262,3 +263,31 @@ func TestPackageGeneric(t *testing.T) {
})
})
}
// TestPackageGenericPublicOnlyTokenLimitedOwner ensures a public-only token cannot
// access packages owned by a limited-visibility owner (only genuinely public owners).
func TestPackageGenericPublicOnlyTokenLimitedOwner(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user33 has limited visibility (visible only to authenticated users, not public)
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 33})
base := fmt.Sprintf("/api/packages/%s/generic/pkg/1.0.0", owner.Name)
// upload a package into the limited owner's namespace
req := NewRequestWithBody(t, "PUT", base+"/file.bin", bytes.NewReader([]byte{1, 2, 3})).
AddBasicAuth(owner.Name)
MakeRequest(t, req, http.StatusCreated)
// a public-only read:package token (even the owner's own) must be refused
publicOnlyToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage, auth_model.AccessTokenScopePublicOnly)
req = NewRequest(t, "GET", base+"/file.bin").AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusForbidden)
// same via the v1 package API surface (checkTokenPublicOnly)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/pkg/1.0.0", owner.Name)).AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusForbidden)
// a normal read:package token still works, proving only public-only is restricted
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadPackage)
req = NewRequest(t, "GET", base+"/file.bin").AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
@@ -208,6 +208,12 @@ func TestPackageNpm(t *testing.T) {
test(t, http.StatusNotFound, packageTag2, "1.2")
test(t, http.StatusOK, packageTag, packageVersion)
test(t, http.StatusOK, packageTag2, packageVersion)
// an oversized dist-tag body is rejected instead of being read unbounded
oversized := strings.Repeat("a", 5*1024)
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s", tagsRoot, packageTag), strings.NewReader(oversized)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusRequestEntityTooLarge)
})
t.Run("ListTags", func(t *testing.T) {
+19
View File
@@ -163,3 +163,22 @@ func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
}
}
}
// TestAPIRepoLimitedOwnerPublicOnly ensures a public-only token cannot reach a public repo
// owned by a limited-visibility owner (which is not reachable anonymously).
func TestAPIRepoLimitedOwnerPublicOnly(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// limited_org (limited visibility) owns the non-private repo public_repo_on_limited_org
const url = "/api/v1/repos/limited_org/public_repo_on_limited_org"
// a public-only token is confined to genuinely public resources
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
req := NewRequest(t, "GET", url).AddTokenAuth(publicOnlyToken)
MakeRequest(t, req, http.StatusNotFound)
// a normal token can still reach the limited owner's public repo
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, "GET", url).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
+9 -1
View File
@@ -40,9 +40,17 @@ func TestAPICreateHook(t *testing.T) {
apiHook := DecodeJSON(t, resp, &api.Hook{})
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
// the stored authorization header is a secret and must never be returned by the API
assert.Empty(t, apiHook.AuthorizationHeader)
assert.Equal(t, "CI notifications", apiHook.Name)
// a read-scoped token must not be able to read back the authorization header
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
getReq := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID)).
AddTokenAuth(readToken)
getResp := MakeRequest(t, getReq, http.StatusOK)
assert.NotContains(t, getResp.Body.String(), "s3cr3t")
newName := "Deploy hook"
patchReq := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID), api.EditHookOption{
Name: &newName,
+56
View File
@@ -30,6 +30,62 @@ func TestAPICreateAndDeleteToken(t *testing.T) {
deleteAPIAccessToken(t, newAccessToken, user)
}
// TestAPICreateTokenScopeEscalation ensures a token-authenticated request cannot
// mint a new token with a broader scope than the authenticating token.
func TestAPICreateTokenScopeEscalation(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// a write:user-scoped token authenticates the create requests below
writeUserToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser)
// requesting a broader scope ("all") than the authenticating token is rejected
req := NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
"name": "escalated",
"scopes": []string{"all"},
})
req.Request.SetBasicAuth(user.Name, writeUserToken)
MakeRequest(t, req, http.StatusForbidden)
// requesting a subset scope ("read:user") is allowed
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
"name": "subset",
"scopes": []string{"read:user"},
})
req.Request.SetBasicAuth(user.Name, writeUserToken)
MakeRequest(t, req, http.StatusCreated)
// password (non-token) auth may still create a token with any scope
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
"name": "by-password",
"scopes": []string{"all"},
}).AddBasicAuth(user.Name)
MakeRequest(t, req, http.StatusCreated)
// a public-only token must not mint a token that drops the public-only restriction
publicOnlyToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopePublicOnly)
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
"name": "still-public-only",
"scopes": []string{"write:user"},
})
req.Request.SetBasicAuth(user.Name, publicOnlyToken)
resp := MakeRequest(t, req, http.StatusCreated)
var createdToken api.AccessToken
DecodeJSON(t, resp, &createdToken)
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
// an unrestricted parent token may create a narrower public-only child: public-only is a restriction,
// not a grantable permission, so the subset check must not reject it
req = NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", map[string]any{
"name": "narrower-public-only",
"scopes": []string{"write:user", "public-only"},
})
req.Request.SetBasicAuth(user.Name, writeUserToken)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &createdToken)
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
}
// TestAPIDeleteMissingToken ensures that error is thrown when token not found
func TestAPIDeleteMissingToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+22
View File
@@ -8,12 +8,34 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
// TestAPIManageEmailsFeatureDisabled ensures the email management API honors the
// manage_credentials feature restriction, matching the web UI.
func TestAPIManageEmailsFeatureDisabled(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
addReq := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &api.CreateEmailOption{
Emails: []string{"user2-3@example.com"},
}).AddTokenAuth(token)
MakeRequest(t, addReq, http.StatusNotFound)
delReq := NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &api.DeleteEmailOption{
Emails: []string{"user2-2@example.com"},
}).AddTokenAuth(token)
MakeRequest(t, delReq, http.StatusNotFound)
}
func TestAPIListEmails(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+15 -5
View File
@@ -222,24 +222,34 @@ func TestOAuth2CallbackReactivationGating(t *testing.T) {
}
require.NoError(t, user_model.LinkExternalToUser(t.Context(), u, extLink))
prepareUserExternalLink := func(t *testing.T, refreshToken string) {
prepareUserExternalLink := func(t *testing.T, accessToken, refreshToken string, expiresAt time.Time) {
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})
_, err = db.GetEngine(t.Context()).Where(builder.Eq{"user_id": u.ID}).Cols("access_token", "refresh_token", "expires_at").
Update(&user_model.ExternalLoginUser{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt})
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")
prepareUserExternalLink(t, "an-access-token", "non-empty-refresh-token", time.Now().Add(time.Hour))
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("admin-disabled user without refresh token is not reactivated", func(t *testing.T) {
// GitHub / OIDC-without-offline_access sources never store a refresh token, so a
// stored access token (and no refresh token) is the normal admin-disabled state
prepareUserExternalLink(t, "an-access-token", "" /* no refresh token */, time.Now().Add(time.Hour))
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 admin-disabled account on a no-refresh-token source")
})
t.Run("auto-sync-disabled user is reactivated", func(t *testing.T) {
prepareUserExternalLink(t, "" /* empty refresh token */)
// the auto-sync cron clears all three token fields when it disables a user
prepareUserExternalLink(t, "", "", time.Time{})
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")
+49
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -58,3 +59,51 @@ func TestFeedUser(t *testing.T) {
})
})
}
// TestFeedUserPublicOnlyToken ensures a public-only API token cannot surface a user's
// private activity through their profile feed, even when authenticated as the owner.
func TestFeedUserPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 has activity on the private repo user2/repo2
const privateMarker = "user2/repo2"
// a normal read:user token authenticated as the owner sees private activity
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
reqFull := NewRequest(t, "GET", "/user2.rss")
reqFull.SetBasicAuth("user2", fullToken)
respFull := MakeRequest(t, reqFull, http.StatusOK)
assert.Contains(t, respFull.Body.String(), privateMarker)
// a public-only token must not surface the private activity
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
reqPublicOnly := NewRequest(t, "GET", "/user2.rss")
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
}
// TestProfileActivityPublicOnlyToken ensures the HTML profile activity tab does not
// surface a user's private activity to a public-only API token, mirroring the RSS/Atom
// guard. The /{username} route is AllowBasic, so a public-only PAT used as the Basic
// password must still be downgraded even for the feed owner.
func TestProfileActivityPublicOnlyToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2 has activity on the private repo user2/repo2
const privateMarker = "user2/repo2"
// a normal read:user token authenticated as the owner sees private activity
fullToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
reqFull := NewRequest(t, "GET", "/user2?tab=activity")
reqFull.SetBasicAuth("user2", fullToken)
respFull := MakeRequest(t, reqFull, http.StatusOK)
assert.Contains(t, respFull.Body.String(), privateMarker)
// a public-only token must not surface the private activity
publicOnlyToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
reqPublicOnly := NewRequest(t, "GET", "/user2?tab=activity")
reqPublicOnly.SetBasicAuth("user2", publicOnlyToken)
respPublicOnly := MakeRequest(t, reqPublicOnly, http.StatusOK)
assert.NotContains(t, respPublicOnly.Body.String(), privateMarker)
}
+84
View File
@@ -8,11 +8,15 @@ import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGoGet(t *testing.T) {
@@ -55,3 +59,83 @@ func TestGoGetForSSH(t *testing.T) {
assert.Equal(t, expected, resp.Body.String())
}
// TestGoGetPrivateRepoBranchNotLeaked ensures the go-get meta endpoint does not disclose a
// private repository's default branch name to unauthorized callers.
func TestGoGetPrivateRepoBranchNotLeaked(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is private; give it a non-default branch name
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
require.True(t, repo.IsPrivate)
repo.DefaultBranch = "secretbranch"
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
// an unauthenticated caller must see the neutral instance default, not the real branch
req := NewRequest(t, "GET", "/user2/repo2?go-get=1")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
assert.NotContains(t, resp.Body.String(), "secretbranch")
// the owner may still see the real default branch
session := loginUser(t, "user2")
req = NewRequest(t, "GET", "/user2/repo2?go-get=1")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "secretbranch")
}
// TestGoGetPublicRepoUnderLimitedOwnerBranchNotLeaked ensures the go-get meta endpoint does not disclose
// the default branch of a public repo whose owner is not visible to the caller (here a limited org, which
// is hidden from anonymous callers but visible to authenticated ones).
func TestGoGetPublicRepoUnderLimitedOwnerBranchNotLeaked(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// repo id 38 is a public repo owned by a limited org; give it a non-default branch name
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 38})
require.False(t, repo.IsPrivate)
require.NoError(t, repo.LoadOwner(t.Context()))
require.False(t, repo.Owner.Visibility.IsPublic())
repo.DefaultBranch = "secretbranch"
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
url := fmt.Sprintf("/%s/%s?go-get=1", repo.OwnerName, repo.Name)
// an anonymous caller cannot see the limited owner, so the neutral instance default is returned
resp := MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
assert.NotContains(t, resp.Body.String(), "secretbranch")
// an authenticated caller can see a limited org's public repo, so the real branch is shown
session := loginUser(t, "user2")
resp = session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
assert.Contains(t, resp.Body.String(), "secretbranch")
}
// TestGoGetPrivateRepoBranchNotLeakedToTokenWithoutRepoScope ensures a PAT that was not granted repository
// read scope cannot learn a private repo's default branch through go-get, even when the account behind the
// token could read the repository.
func TestGoGetPrivateRepoBranchNotLeakedToTokenWithoutRepoScope(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// user2/repo2 is private; give it a non-default branch name
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
require.True(t, repo.IsPrivate)
repo.DefaultBranch = "secretbranch"
require.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime(t.Context(), repo, "default_branch"))
// a token scoped only to read:misc does not grant repository read: the branch must stay hidden.
// Web routes authenticate a token via basic auth (username + token), which also records the scope.
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
req := NewRequest(t, "GET", "/user2/repo2?go-get=1")
req.Request.SetBasicAuth("user2", miscToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "/src/branch/master{/dir}")
assert.NotContains(t, resp.Body.String(), "secretbranch")
// a token that includes repository read scope may see the real branch
repoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, "GET", "/user2/repo2?go-get=1")
req.Request.SetBasicAuth("user2", repoToken)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "secretbranch")
}
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
"gitea.dev/tests"
)
// TestUpdateIssueLabelForeignLabel verifies that the web issue-label action rejects a
// label owned by a different repo/org with 404 (indistinguishable from a nonexistent
// id), closing the cross-repo label enumeration oracle also on the web side.
func TestUpdateIssueLabelForeignLabel(t *testing.T) {
defer tests.PrepareTestEnv(t)()
sess := loginUser(t, "user2")
// label 3 belongs to org3 — foreign to user2/repo1 (issue 1); must be 404, not a 500 oracle
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/labels?issue_ids=1", map[string]string{
"action": "attach",
"id": "3",
})
sess.MakeRequest(t, req, http.StatusNotFound)
// a label owned by the repo still attaches normally
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/labels?issue_ids=1", map[string]string{
"action": "attach",
"id": "2",
})
sess.MakeRequest(t, req, http.StatusOK)
}
+51
View File
@@ -4,7 +4,10 @@
package integration
import (
"net/http"
"net/http/httptest"
"slices"
"sync/atomic"
"testing"
"gitea.dev/models/db"
@@ -14,6 +17,9 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/migration"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
migrations "gitea.dev/services/migrations"
mirror_service "gitea.dev/services/mirror"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
@@ -124,3 +130,48 @@ func TestMirrorPull(t *testing.T) {
mirror = unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
assert.Equal(t, lastMirrorSync, mirror.LastSyncUnix)
}
// TestMirrorPullSSRFRevalidation ensures a pull mirror re-validates its remote URL against
// the migration allow/block list on every sync, so a mirror whose (network) remote now
// points at a disallowed internal host is never fetched.
func TestMirrorPullSSRFRevalidation(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repoPath := repo_model.RepoPath(user.Name, repo.Name)
// an "internal" server that records whether it was reached
var reached atomic.Bool
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached.Store(true)
w.WriteHeader(http.StatusNotFound)
}))
defer internal.Close()
mirrorRepo, err := repo_service.CreateRepositoryDirectly(ctx, user, user, repo_service.CreateRepoOptions{
Name: "ssrf_mirror",
IsMirror: true,
Status: repo_model.RepositoryBeingMigrated,
}, false)
require.NoError(t, err)
_, err = repo_service.MigrateRepositoryGitData(ctx, user, mirrorRepo, migration.MigrateOptions{
RepoName: "ssrf_mirror",
Mirror: true,
CloneAddr: repoPath,
}, nil)
require.NoError(t, err)
mirror, err := repo_model.GetMirrorByRepoID(ctx, mirrorRepo.ID)
require.NoError(t, err)
// repoint the mirror at the loopback server, which is disallowed once local networks are off
require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, internal.URL+"/repo.git"))
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, false)()
require.NoError(t, migrations.Init())
t.Cleanup(func() { _ = migrations.Init() })
assert.False(t, mirror_service.SyncPullMirror(ctx, mirrorRepo.ID))
assert.False(t, reached.Load(), "the disallowed internal remote must not be reached")
}
+2
View File
@@ -27,6 +27,8 @@ import (
func TestOAuth2AvatarFromPicture(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.OAuth2Client.UpdateAvatar, true)()
// the SSRF-protected avatar client blocks loopback by default; allow the loopback mock server here
defer test.MockVariableValue(&setting.Security.AllowedHostList, "loopback,external")()
mockServer := createOAuth2MockProvider()
defer mockServer.Close()
+19
View File
@@ -1405,3 +1405,22 @@ func testOAuthSourceSpecialChars(t *testing.T) {
testOAuth2(t, "/user/oauth2/test%2Bplus", http.StatusTemporaryRedirect)
testOAuth2(t, "/user/oauth2/test%20plus", http.StatusNotFound)
}
// TestOAuthUserInfoTokenScope verifies the OIDC userinfo endpoint enforces the
// read:user token scope, so a restrictively-scoped token cannot read identity claims.
func TestOAuthUserInfoTokenScope(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// a token without the user scope must be rejected
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
req := NewRequest(t, "GET", "/login/oauth/userinfo")
req.SetHeader("Authorization", "Bearer "+miscToken)
MakeRequest(t, req, http.StatusForbidden)
// a token with read:user is allowed and returns the identity claims
userToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser)
req = NewRequest(t, "GET", "/login/oauth/userinfo")
req.SetHeader("Authorization", "Bearer "+userToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "user2@example.com")
}

Some files were not shown because too many files have changed in this diff Show More