GHSA-PGQF-926R-548M

Vulnerability from github – Published: 2026-07-21 20:39 – Updated: 2026-07-21 20:39
VLAI
Summary
Gitea: Cross-repository label-ID enumeration oracle via unscoped DeleteIssueLabel API
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:

// 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.
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/99999999422. 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:

// 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.)

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "code.gitea.io/gitea"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.27.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-58445"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-203",
      "CWE-639"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-21T20:39:22Z",
    "nvd_published_at": null,
    "severity": "LOW"
  },
  "details": "## Summary\n\nThe API endpoint `DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id}` loads the label by ID with a **global,\nunscoped** lookup and never verifies the label belongs to the URL\u0027s repository (or its owning organization). Because\nthe response status differs by whether the label ID exists **anywhere on the instance** (204) versus not (422), an\nauthenticated user can use the endpoint as a **cross-repository label-ID existence / enumeration oracle**, including\nfor labels in repositories and organizations they cannot access.\n\n## Severity\n\n- The leaked information is minimal (existence/count of label IDs instance-wide); **no label name, color, or owning\n  repository is disclosed, and no cross-repository write occurs.**\n\n## Affected / patched versions\n\n- **Affected:** through **1.26.3** (latest at time of report).\n- **Patched:** none yet.\n\n## Details\n\n`DeleteIssueLabel` resolves the label with a global loader and never checks its scope:\n\n```go\n// routers/api/v1/repo/issue_label.go  (DeleteIssueLabel)\nlabel, err := issues_model.GetLabelByID(ctx, ctx.PathParamInt64(\"id\"))   // global, unscoped\n```\n\n`GetLabelByID` (`models/issues/label.go`) is `e.ID(labelID).Get(l)` with **no** `repo_id` / `org_id` filter. The\nhandler never verifies `label.RepoID == ctx.Repo.Repository.ID` (nor the org-label equivalent), and the downstream\n`issue_service.RemoveLabel` (`services/issue/label.go`) only re-checks the doer\u0027s write permission on the **issue\u0027s\nown** repository \u2014 never that the label belongs to it.\n\nEvery sibling label handler is correctly scoped \u2014 `GetLabel` / `EditLabel` / `DeleteLabel` (repo and org) use\n`GetLabelInRepoByID` / `GetLabelInOrgByID` and return 404 for a foreign ID. `DeleteIssueLabel` is the only outlier.\n\n**Why it is only an oracle:** `deleteIssueLabel` (`models/issues/issue_label.go`) deletes the `issue_label` row keyed\nby `(issue.ID, label.ID)`. For a foreign label, no such row exists \u2192 the function returns early **before** any\nmutation or comment creation. So there is no cross-repo write and no leak of the label\u0027s name. But the HTTP status\ndiffers:\n\n- label ID exists anywhere on the instance (incl. private repos/orgs) \u2192 **204 No Content**\n- label ID does not exist \u2192 **422** (`ErrLabelNotExist`)\n\nLabel IDs are sequential auto-increment, so this enumerates the instance-wide label population and probes existence\nof specific IDs across tenant boundaries.\n\n## Proof of Concept\n\nVerified end-to-end on a build of the `v1.26.3` tag.\n\n- `alice` (private repo `alice/secret`) creates a label \u2192 internal id **1**.\n- Attacker `bob` (separate user; public repo `bob/pub` with issue #1; **no access** to `alice/secret`) holds a token\n  with `write:issue` on his own repo.\n\n```text\nbob DELETE /api/v1/repos/bob/pub/issues/1/labels/1          -\u003e HTTP 204   (alice\u0027s PRIVATE label id exists)\nbob DELETE /api/v1/repos/bob/pub/issues/1/labels/99999999   -\u003e HTTP 422   (no such label)\n```\n\nDiffering 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\u0027s label is untouched \u2014 no write.)\n\nReproduction steps:\n1. Create two users `alice`, `bob`. As `alice`, create a private repo and a label on it (note the label `id` from\n   the API response).\n2. As `bob`, create any repo with an issue, and a token with `write:issue`.\n3. `curl -u bob:$T -X DELETE https://\u003cgitea\u003e/api/v1/repos/bob/pub/issues/1/labels/\u003calice_label_id\u003e` \u2192 **204**.\n4. `curl -u bob:$T -X DELETE https://\u003cgitea\u003e/api/v1/repos/bob/pub/issues/1/labels/99999999` \u2192 **422**.\n5. The differing status across an ID `bob` cannot otherwise see is the oracle.\n\n## Impact\n\nCross-tenant authorization-key bypass producing a label-ID existence/enumeration oracle: an authenticated user can\ndetermine whether arbitrary label IDs (including in private repositories and organizations they cannot access) exist,\nand enumerate the instance-wide label population.\n\n## Remediation\n\nScope the loader like every sibling handler, returning 404 for a label that is not in the URL repository (or its\nowning organization), so the status no longer distinguishes existence:\n\n```go\n// routers/api/v1/repo/issue_label.go \u2014 in DeleteIssueLabel, after loading the label\nif label.RepoID != ctx.Repo.Repository.ID \u0026\u0026 label.OrgID != ctx.Repo.Repository.OwnerID {\n    ctx.APIErrorNotFound()\n    return\n}\n```\n\n(Equivalently, resolve via `GetLabelInRepoByID` and, for org repositories, also accept the repo owner\u0027s org labels \u2014\nmirroring the scoping in `GetLabel`/`EditLabel`/`DeleteLabel`.)",
  "id": "GHSA-pgqf-926r-548m",
  "modified": "2026-07-21T20:39:22Z",
  "published": "2026-07-21T20:39:22Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-pgqf-926r-548m"
    },
    {
      "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:H/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Gitea: Cross-repository label-ID enumeration oracle via unscoped DeleteIssueLabel API"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…