GHSA-pgqf-926r-548m Gitea: Cross-repository label-ID enumeration oracle via unscoped DeleteIssueLabel API
SummaryThe API endpoint `DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id}` loads the label by ID with a **global,
unscoped** lookup and never verifies the label belongs to the URL's repository (or its owning organization). Because
the response status differs by whether the label ID exists **anywhere on the instance** (204) versus not (422), an
authenticated user can use the endpoint as a **cross-repository label-ID existence / enumeration oracle**, including
for labels in repositories and organizations they cannot access.SeverityThe leaked information is minimal (existence/count of label IDs instance-wide); **no label name, color, or owning
repository is disclosed, and no cross-repository write occurs.**Affected / patched versions**Affected:** through **1.26.3** (latest at time of report).**Patched:** none yet.Details`DeleteIssueLabel` resolves the label with a global loader and never checks its scope:`GetLabelByID` (`models/issues/label.go`) is `e.ID(labelID).Get(l)` with **no** `repo_id` / `org_id` filter. The
handler never verifies `label.RepoID == ctx.Repo.Repository.ID` (nor the org-label equivalent), and the downstream
`issue_service.RemoveLabel` (`services/issue/label.go`) only re-checks the doer's write permission on the **issue's
own** repository — never that the label belongs to it.Every sibling label handler is correctly scoped — `GetLabel` / `EditLabel` / `DeleteLabel` (repo and org) use
`GetLabelInRepoByID` / `GetLabelInOrgByID` and return 404 for a foreign ID. `DeleteIssueLabel` is the only outlier.**Why it is only an oracle:** `deleteIssueLabel` (`models/issues/issue_label.go`) deletes the `issue_label` row keyed
by `(issue.ID, label.ID)`. For a foreign label, no such row exists → the function returns early **before** any
mutation or comment creation. So there is no cross-repo write and no leak of the label's name. But the HTTP status
differs:label ID exists anywhere on the instance (incl. private repos/orgs) → **204 No Content**label ID does not exist → **422** (`ErrLabelNotExist`)Label IDs are sequential auto-increment, so this enumerates the instance-wide label population and probes existence
of specific IDs across tenant boundaries.Proof of ConceptVerified end-to-end on a build of the `v1.26.3` tag.`alice` (private repo `alice/secret`) creates a label → internal id **1**.Attacker `bob` (separate user; public repo `bob/pub` with issue #1; **no access** to `alice/secret`) holds a token
with `write:issue` on his own repo.Differing only by the label ID: `204` vs `422` distinguishes "label ID exists" from "does not exist." `bob` has zero rights to `alice/secret` but can still learn label id 1 exists. (Alice's label is untouched — no write.)Reproduction steps:Create two users `alice`, `bob`. As `alice`, create a private repo and a label on it (note the label `id` from
the API response).As `bob`, create any repo with an issue, and a token with `write:issue`.`curl -u bob:$T -X DELETE https://<gitea>/api/v1/repos/bob/pub/issues/1/labels/<alice_label_id>` → **204**.`curl -u bob:$T -X DELETE https://<gitea>/api/v1/repos/bob/pub/issues/1/labels/99999999` → **422**.The differing status across an ID `bob` cannot otherwise see is the oracle.ImpactCross-tenant authorization-key bypass producing a label-ID existence/enumeration oracle: an authenticated user can
determine whether arbitrary label IDs (including in private repositories and organizations they cannot access) exist,
and enumerate the instance-wide label population.RemediationScope the loader like every sibling handler, returning 404 for a label that is not in the URL repository (or its
owning organization), so the status no longer distinguishes existence:(Equivalently, resolve via `GetLabelInRepoByID` and, for org repositories, also accept the repo owner's org labels —
mirroring the scoping in `GetLabel`/`EditLabel`/`DeleteLabel`.)
CVSS v3.1 score: 2.7