---
id: CVE-2026-58445
aliases:
  - GHSA-pgqf-926r-548m
title: >-
  Gitea: Cross-repository label-ID enumeration oracle via unscoped
  DeleteIssueLabel API
summary: >-
  Gitea: Cross-repository label-ID enumeration oracle via unscoped
  DeleteIssueLabel API
severity: low
cvss: 2.7
cwe:
  - CWE-203
  - CWE-639
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-pgqf-926r-548m'
references:
  - url: 'https://github.com/go-gitea/gitea/security/advisories/GHSA-pgqf-926r-548m'
  - url: 'https://github.com/go-gitea/gitea/releases/tag/v1.27.0'
  - url: 'https://github.com/advisories/GHSA-pgqf-926r-548m'
tags:
  - ghsa
  - go
ingestedAt: '2026-07-21T20:54:26.807Z'
epss: 0.00231
epssPercentile: 0.14254
---

## Overview

## 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`.)

## Affected packages

- `code.gitea.io/gitea < 1.27.0`

## Remediation

Upgrade to a patched release:

- `code.gitea.io/gitea 1.27.0`
