{"id":"CVE-2026-56443","aliases":["GHSA-7p4h-3gxq-x3h3"],"title":"Gitea: Token public-only scope bypassed on Limited-visibility owners (Repository + Package categories) — residual after CVE-2026-25714 / PR #37118","summary":"Gitea: Token public-only scope bypassed on Limited-visibility owners (Repository + Package categories) — residual after CVE-2026-25714 / PR #37118","severity":"medium","cvss":4.3,"cwe":["CWE-863"],"vendor":"gitea","product":"code.gitea.io/gitea","ecosystem":"go","affected":["code.gitea.io/gitea < 1.27.0"],"patched":["code.gitea.io/gitea 1.27.0"],"published":"2026-07-21","updated":"2026-07-21","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-7p4h-3gxq-x3h3","references":[{"url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-7p4h-3gxq-x3h3"},{"url":"https://github.com/go-gitea/gitea/releases/tag/v1.27.0"},{"url":"https://github.com/advisories/GHSA-7p4h-3gxq-x3h3"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-21T20:54:27.484Z","epss":0.00362,"epssPercentile":0.29967,"slug":"CVE-2026-56443","body":"## Overview\n\n## Summary\n\nAfter [PR #37118](https://github.com/go-gitea/gitea/pull/37118) / **CVE-2026-25714**\n(`fix: Unify public-only token filtering in API queries and repo access checks`,\nmerged 2026-05-18, backport `#37773` to 1.26.2 — the May 2026 unification pass\nfor public-only token filtering, reporter Medoedus per the 1.26.2 release notes),\nthe `public-only` PAT scope is still bypassable on **Repository** and **Package**\nscope categories when the owner's `Visibility = Limited` (instance-internal).\n\nThe sibling `Org` / `User` / `ActivityPub` cases in the same `checkTokenPublicOnly`\nswitch correctly reject Limited owners via `!Visibility.IsPublic()`. The\nRepository / Package cases use `repo.IsPrivate` or `Owner.Visibility.IsPrivate()`,\nboth of which return `false` for `VisibleTypeLimited` — so a `public-only` PAT\nstrictly exceeds anonymous reach on a Limited owner.\n\nTested on `gitea/gitea:1.26.2`. The decisive marker is that PR #37118's\nunification IS applied in the version under test (User-category PROBE returns\n`403 \"token scope is limited to public users\"`). Despite that, the\nRepository-category PROBE on the same Limited owner with the same PAT returns\n`200` and serves content.\n\n## Affected entry points (4 spots)\n\n| File:Line | Function | Affected surface |\n|---|---|---|\n| `routers/api/v1/api.go:292` | `checkTokenPublicOnly` Package case | API v1 packages |\n| `routers/api/packages/api.go:76` | `reqPackageAccess` middleware | All 24 native package registries (`/api/packages/<type>/...`) |\n| `services/context/api.go` | `TokenCanAccessRepo` helper | All API v1 Repository-category endpoints — content, issues, PRs, releases, labels, milestones, etc. |\n| `services/context/permission.go:32` | `CheckTokenScopes` (called via `CheckRepoScopedToken`) | Web download endpoints `/raw`, `/media`, `/attachments`. LFS routes (`services/lfs/server.go:470/472`, `services/lfs/locks.go:62/151/216/284`) also chain through this helper. |\n\nAll four sinks check `repo.IsPrivate` or `Owner.Visibility.IsPrivate()` only.\n`VisibleTypeLimited` falls through.\n\n```go\n// modules/structs/visible_type.go\nfunc (vt VisibleType) IsPrivate() bool { return vt == VisibleTypePrivate }   // line 39-40\nfunc (vt VisibleType) IsLimited() bool { return vt == VisibleTypeLimited }   // line 33-34\n```\n\n## Same-file evidence (`routers/api/v1/api.go:246-299` after PR #37118)\n\n```go\ncase auth_model.AccessTokenScopeCategoryOrganization:\n    orgPrivate := ... && !ctx.Org.Organization.Visibility.IsPublic()        // !IsPublic ✓\ncase auth_model.AccessTokenScopeCategoryUser:\n    if ... && !ctx.ContextUser.Visibility.IsPublic() { ... }                // !IsPublic ✓\ncase auth_model.AccessTokenScopeCategoryActivityPub:\n    if ... && !ctx.ContextUser.Visibility.IsPublic() { ... }                // !IsPublic ✓\n\ncase auth_model.AccessTokenScopeCategoryPackage:\n    if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {     // IsPrivate ONLY ✗\n        ctx.APIError(http.StatusForbidden, \"token scope is limited to public packages\")\n        return\n    }\n```\n\n`TokenCanAccessRepo` (`services/context/api.go`) reduces to `!repo.IsPrivate`:\n\n```go\n// A public-only token cannot reach a private repo; any other token is unrestricted by this check.\nfunc (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool {\n    return repo == nil || !ctx.PublicOnly || !repo.IsPrivate\n}\n```\n\n`CheckTokenScopes` (`services/context/permission.go:32`):\n\n```go\nif publicOnly && repo != nil && repo.IsPrivate {\n    ctx.HTTPError(http.StatusForbidden)\n    return\n}\n```\n\n## PoC (Docker e2e VERIFIED on `gitea/gitea:1.26.2`, 2026-06-05)\n\nFull script in the report (`run-poc.sh`). Setup:\n\n1. Create user `limuser`. `PATCH /api/v1/admin/users/limuser` with body\n   `{\"visibility\":\"limited\", ...}` — response confirms `\"visibility\":\"limited\"`.\n2. Upload a generic package as `limuser`:\n   `PUT /api/packages/limuser/generic/secretpkg/1.0.0/secret.txt` with body\n   `secret-content-internal-only` → `201`.\n3. Create user `attacker`.\n4. Mint PAT for `attacker` with `scopes=[\"read:package\",\"read:user\",\"read:repository\",\"public-only\"]`.\n\nResult on `gitea/gitea:1.26.2` — nine PROBEs:\n\n```\nPROBE A  (download package via attacker PAT)\n    HTTP=200  Body: secret-content-internal-only\n\nPROBE C  (read README of limuser's PUBLIC repo)\n    HTTP=200  Body: {\"name\":\"README.md\", ...}\n\nPROBE F  (sanity — User category, same PAT, same owner)\n    HTTP=403  Body: {\"message\":\"token scope is limited to public users\"}\n\nPROBE G  (Repository category, same PAT, same owner)\n    HTTP=200  Body: {\"name\":\"README.md\", ...}                    ← bypass\n\nPROBE H  (list limuser's repos, User category)\n    HTTP=403  Body: {\"message\":\"token scope is limited to public users\"}\n\nPROBE M  (git HTTPS smart protocol — info/refs)\n    HTTP=200  Body: 001e# service=git-upload-pack ... HEAD ...   ← full clone enabled\n\nPROBE N  (write attempt: POST contents/hacked.txt)\n    HTTP=403  Body: {\"message\":\"user should have a permission to write to the target branch\"}\n                                                                    Integrity:N confirmed\n\nPROBE O  (Limited ORG — same bypass class)\n    Org category    : HTTP=403  {\"message\":\"token scope is limited to public orgs\"}\n    Repo category   : HTTP=200  README content                  ← bypass\n\nAnonymous baseline (no auth) on every above endpoint: HTTP=401/404\n```\n\nGitea's own server error string in PROBE F / H / O — *\"token scope is limited\nto public users\"* / *\"public orgs\"* — is the explicit declaration of intent.\nRepository / Package category violates that intent on the same Limited owner.\n\n## Why this is not a duplicate of CVE-2026-25714\n\nCVE-2026-25714 / PR #37118 (the May 2026 unification pass for public-only token\nfiltering, merged 2026-05-18, backported to 1.26.2 via PR #37773) realigned\n`checkTokenPublicOnly`'s Org / User / ActivityPub cases on `!Visibility.IsPublic()`\nand introduced the `TokenCanAccessRepo` helper for the Repository / Issue /\nNotification cases.\n\nPROBE F on `1.26.2` returns `403 \"token scope is limited to public users\"` for\nthe User category — i.e. PR #37118's unification IS in effect on the version\nunder test. The Repository / Package leak occurs *after* that fix; the Limited\ngap is the next residual issue on the same hygiene effort (the Package case was\nnot touched, and `TokenCanAccessRepo` reduces to `!repo.IsPrivate` without\nconsulting owner visibility), not the same bug.\n\n## Suggested fix (4 spots, 1-line shape each)\n\n```go\n// routers/api/v1/api.go:292  (checkTokenPublicOnly Package case)\n- if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {\n+ if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() {\n\n// routers/api/packages/api.go:76  (reqPackageAccess middleware)\n- if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {\n+ if ctx.Package != nil && !ctx.Package.Owner.Visibility.IsPublic() {\n\n// services/context/api.go  (TokenCanAccessRepo helper)\n- return repo == nil || !ctx.PublicOnly || !repo.IsPrivate\n+ return repo == nil || !ctx.PublicOnly ||\n+     (!repo.IsPrivate && repo.Owner != nil && repo.Owner.Visibility.IsPublic())\n\n// services/context/permission.go:32  (CheckTokenScopes)\n- if publicOnly && repo != nil && repo.IsPrivate {\n+ if publicOnly && repo != nil &&\n+     (repo.IsPrivate || (repo.Owner != nil && !repo.Owner.Visibility.IsPublic())) {\n```\n\nThis aligns the Repository / Package categories with the User / Org / ActivityPub\nsiblings already shipped in PR #37118.\n## Reporter\n\nJebeenLee\n\n## Affected packages\n\n- `code.gitea.io/gitea < 1.27.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `code.gitea.io/gitea 1.27.0`","depth":"sunlit","depthScore":24,"depthScoreParts":{"impact":23.7,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}