GHSA-pgqf-926r-548m
Gitea: Cross-repository label-ID enumeration oracle via unscoped DeleteIssueLabel API
Quick fix
GHSA-pgqf-926r-548m — code.gitea.io/gitea: upgrade to the fixed version with the command below.
go get code.gitea.io/gitea@v1.27.0 Details
## Summary
The 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.
## Severity
- The 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:
```go // routers/api/v1/repo/issue_label.go (DeleteIssueLabel) label, err := issues_model.GetLabelByID(ctx, ctx.PathParamInt64("id")) // global, unscoped ```
`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 Concept
Verified 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.
```text bob DELETE /api/v1/repos/bob/pub/issues/1/labels/1 -> HTTP 204 (alice's PRIVATE label id exists) bob DELETE /api/v1/repos/bob/pub/issues/1/labels/99999999 -> HTTP 422 (no such label) ```
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: 1. Create two users `alice`, `bob`. As `alice`, create a private repo and a label on it (note the label `id` from the API response). 2. As `bob`, create any repo with an issue, and a token with `write:issue`. 3. `curl -u bob:$T -X DELETE https://<gitea>/api/v1/repos/bob/pub/issues/1/labels/<alice_label_id>` → **204**. 4. `curl -u bob:$T -X DELETE https://<gitea>/api/v1/repos/bob/pub/issues/1/labels/99999999` → **422**. 5. The differing status across an ID `bob` cannot otherwise see is the oracle.
## Impact
Cross-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.
## Remediation
Scope 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:
```go // routers/api/v1/repo/issue_label.go — in DeleteIssueLabel, after loading the label if label.RepoID != ctx.Repo.Repository.ID && label.OrgID != ctx.Repo.Repository.OwnerID { ctx.APIErrorNotFound() return } ```
(Equivalently, resolve via `GetLabelInRepoByID` and, for org repositories, also accept the repo owner's org labels — mirroring the scoping in `GetLabel`/`EditLabel`/`DeleteLabel`.)
Are you affected?
Enter the version of the package you're using.