Research/gitea-user-keys-idor
0-dayNo CVEHighPublic

Gitea, authenticated user/keys IDOR

Gitea 1.27.3 GET /api/v1/user/keys/{id} loads public_key by sequential ID with no owner check. A read:user token inventories other people's SSH and deploy keys. GPG's sibling 404s. No CVE yet.

Name
Gitea, authenticated user/keys IDOR
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Gitea
Affected
Gitea through v1.27.3 (146cc3e); unpublished leftover after the 2026 GHSA wave. Needs a signed-in account with read:user. Default open registration.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, gitea, idor, ssh, deploy-key, cwe-639, authenticated

The current-user route was never current-user

I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-user-keys-idor (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, seed.sh, seed.py. Authorized lab only. It talks to loopback.

This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-639. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N). Reach: Remote. Class: IDOR. Authenticated. A read:user token. Default open registration makes that a new-account bug.

GET /api/v1/user/keys/{id} sits in the current-user group. reqToken. rejectPublicOnly. The handler still loads the row by numeric ID with no owner check. The same table holds user keys, deploy keys, and principals. GPG on this pin does it right: GetGPGKeyForUserByID(ctx, ctx.Doer.ID, id). Private extra fields are gated. The public key material, title, and fingerprint are not. IDs are sequential. Walk 1, 2, 3.

Same tag leftover: git HTTP redirect SSRF.

Why Gitea

I had just finished mapping the 2026 GHSA wave onto this tree. Diffpatch hook RCE is in CISA KEV. Org-mode #+INCLUDE was unauthenticated file read into RCE. Docker used to ship REVERSE_PROXY_TRUSTED_PROXIES = * next to X-WEBAUTH-USER. Fork-PR Actions had two approval skips. OAuth Basic ignored scopes. Smart HTTP ignored Bearer scopes. Migration http.Get skipped hostmatcher. That is a product that had a bad winter, then a patch release, then a lot of people who upgraded and assumed the year was over.

Gitea is the self-hosted git forge people actually run when they do not want GitLab's footprint. Default registration is open. The API is a second web UI. Current-user routes look like "my stuff." Numeric IDs look like "my row." Those two sentences are how IDORs hide after a list-scoping fix.

I only wanted Critical or High. Remote and local. Medium stays on the floor unless it chains. Forgejo is a different tree. GitLab is a different tree. This pin is go-gitea/gitea tag v1.27.3.

Pinning 1.27.3

Latest stable on the day I cloned. Tag object v1.27.3, commit 146cc3e. Image gitea/gitea:1.27.3. Stay on 1.27.3+ for the disclosed GHSAs. Do not set REVERSE_PROXY_TRUSTED_PROXIES = *.

I sat on that tag anyway. Patches that scope a list are a gift: you read the list handler, then you ask whether GET-by-id got the same check. The 1.27.3 key GHSA was username-scoped list plus individualPermsChecker. It was not this lookup.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. On 1.27.3 the interesting ones were already closed:

The key GHSA on this wave scoped lists. Username in the path, plus individualPermsChecker. I am not re-filing that.

Surfaces that returned nothing

I split the tree the way you split a forge: auth and tokens, repo visibility, issue and PR ACL, Actions runners, migrate and mirror, webhook SSRF, markup, LFS, packages, restore CLI, current-user API.

Most of that is Gitea doing what the 1.27.3 changelog said it would do.

GET /users/{username}/keys is scoped. The attacker's own GET /user/keys is scoped. GPG GET /user/gpg_keys/{id} 404s a foreign id. appendPrivateInformation only runs for owner or site admin. Live migrate does not os.Open an attacker file://. FilePathJoinAbs dump-root escape FAILs on this tag: join stays under the dump. Diffpatch clone is non-bare. Org ReadFile is stubbed. I am not dressing those up as this bug.

The temperature changed on the current-user SSH handler sitting next to the GPG handler that already knew the rule.

The owner check is only for the extra fields

GetPublicKey is in the current-user group. The route looks like "my key." The load is "any key."

Go
key, err := asymkey_model.GetPublicKeyByID(ctx, ctx.PathParamInt64("id"))
// ...
apiKey := convert.ToPublicKey(apiLink, key)
if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID {
    apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer)
}
ctx.JSON(http.StatusOK, apiKey)

GetPublicKeyByID is a primary-key fetch. No owner_id. No key type. public_key holds user SSH keys, deploy keys, and principals. ToPublicKey copies id, key, title, fingerprint. That is the leak. The if below it is the part someone remembered to gate.

GPG's sibling is the contrast:

Go
key, err := asymkey_model.GetGPGKeyForUserByID(ctx, ctx.Doer.ID, id)

Foreign GPG id is 404. Foreign SSH id is 200 plus the key. That is the whole argument. I am not reprinting the key.

I am not going to print a walk of /user/keys/1..N aimed at a live host. The missing owner check is the useful part.

The lab

Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18101. Bind it to loopback.

Dockerfile
# Loopback lab image pin for gitea-user-keys-idor. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: gitea-user-keys-idor

services:
  gitea:
    image: gitea/gitea:1.27.3
    ports:
      - "127.0.0.1:18101:3000"
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__database__PATH: /data/gitea/gitea.db
      GITEA__security__INSTALL_LOCK: "true"
      GITEA__server__DOMAIN: 127.0.0.1
      GITEA__server__HTTP_PORT: "3000"
      GITEA__server__ROOT_URL: http://127.0.0.1:18101/
      GITEA__service__DISABLE_REGISTRATION: "false"
      GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
    volumes:
      - gitea_data:/data

volumes:
  gitea_data:

run.sh brings the image up, waits for /api/v1/version, then seed.sh plants two users. Victim key title is GHSA-GITEA-KEY-IDOR-WITNESS. Attacker gets an unrelated key. poc.py lists the attacker's keys (must not contain the witness), then walks GET /api/v1/user/keys/{id} from 1 to 31 until the witness title shows up on an id that is not in that list.

Plain text
git clone https://github.com/abraxas/gitea-user-keys-idor
cd gitea-user-keys-idor/lab
./run.sh

The loopback client is lab/run.sh with a banner on it. Target only http://127.0.0.1:18101. Do not publish the port off loopback.

Last lab run, trimmed:

Plain text
add-key user=victim status=201 title=GHSA-GITEA-KEY-IDOR-WITNESS id=1
add-key user=attacker status=201 id=2
attacker-list status=200 ids=[2] (no witness)
get id=1 status=200 snippet contains GHSA-GITEA-KEY-IDOR-WITNESS
SUCCESS Gitea #1 user/keys IDOR

Attacker's own list does not contain the witness. GET /user/keys/1 as the attacker does. GPG with a foreign id 404s.

Wrong turns, in the order I made them

Treating the attacker's own list as the leak. List is scoped. GET-by-id is not. If the witness is already in GET /user/keys, the fixture leaked and the run is FAIL. The PoC fails closed on that.

Walking GET /users/{username}/keys. That is the username-scoped list the GHSA already fixed. Wrong route. The leftover is the current-user numeric lookup.

Expecting private extra fields. appendPrivateInformation is gated. A normal attacker does not get those. Title, fingerprint, and public key material are enough. Do not inflate the class because an admin field exists in the struct.

Planting a deploy key and calling user-SSH the whole bug. The table is shared. The lab planted a user SSH key. Deploy-key inventory is inferred from KeyTypeDeploy rows in public_key. I kept that split in the privilege table.

Treating limited/private visibility as already proven here. This handler has no IsUserVisibleToViewer. That is a fair inference. The stargazers leftover is a sibling lab, not this one.

A reverse shell. Theatre. The oracle is a witness title in JSON.

Client bugs that look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like 1.27.3 had finished the job.

What an attacker can do

Register (default open registration), mint a read:user token, walk /api/v1/user/keys/{id}. They inventory who has access to what. They do not get the private key. They do not get a shell.

Privilege Method + path Kind Lab-proven or inferred
Read another user's SSH public key title, fingerprint, and key material GET /api/v1/user/keys/{id} as a foreign owner Recon Lab-proven. Witness title GHSA-GITEA-KEY-IDOR-WITNESS on id 1; attacker list is only id 2
Inventory deploy keys the same way Same handler, KeyTypeDeploy rows in public_key Recon Inferred from the table. Lab planted a user SSH key, not a deploy key
See keys that belong to limited or private users No visibility check on this lookup Recon Inferred from the handler. Stargazers leftover is a sibling, not this lab
Walk sequential IDs across the instance IDs are autoincrement; range 1..N Recon Lab-proven on 1 and 2. The client walks 1..31
Private extra fields (appendPrivateInformation) Same GET, only if admin or owner Integrity / extra recon Lab-proven closed for a normal attacker
Private key material Not in this API Integrity Closed. Public key only
Shell / RCE / OS LPE Not this route Integrity / availability Closed
GET /users/{username}/keys dump of a hidden user Username-scoped list Recon Closed on this pin (the GHSA). Wrong route
GET /api/v1/user/gpg_keys/{id} foreign GPG GPG sibling Recon Closed. GetGPGKeyForUserByID 404s

No integrity. No availability. Confidentiality of SSH and deploy public keys: High. That is why it is in bar.

A public SSH key is not a password. It is still an inventory of every laptop, CI runner, and deploy key on the instance, including keys that belong to people whose profiles 404 and keys that open private repos. Combine it with unauth stargazers and you get names. Combine it with follow 204 vs 404 and you confirm the names still exist after they hide.

What this is not

It is not "CVE-2026 key list never shipped." The list is scoped. This lookup is not. It is not unauthenticated. It is not a private-git read. It is not RCE. It is not a private-key steal.

If the instance was ever ≤ 1.27.0, rotate INTERNAL_TOKEN, JWT, DB password, and runner tokens anyway. That is the disclosed diffpatch / Org-include incident, not this lookup.

The fix

404 unless key.OwnerID == ctx.Doer.ID (or site admin). Reject KeyTypeDeploy and KeyTypePrincipal on this route. Match GPG.

Re-run the loopback client against a patched build: GET /user/keys/1 as the attacker must 404 the same way GPG already does.

Until then, do not treat "I scoped the list" as "I scoped the row." Sequential IDs plus a current-user GET is an instance-wide catalog.

I am not going to print a walk of /user/keys/1..N aimed at a live host. The missing owner check is the useful part. If you own the box, run the proof of concept against loopback.

Same product, other unpublished labs on this tag: git HTTP redirect SSRF · hostmatcher 0.0.0.0/8 · public-only PAT org · stargazers hidden · follow existence · artifact v4 ReadAll · restore file://.

References