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:
- CVE-2026-60004
diffpatchhook RCE. Clone is non-bare. CISA KEV. RotateINTERNAL_TOKEN, JWT, DB password, and runner tokens if the instance was ever ≤ 1.27.0. - CVE-2026-59774 unauth Org-mode
#+INCLUDE.ReadFileis a stub that echoes the include as text. - CVE-2026-20896 Docker reverse-proxy
*. Template no longer hard-codes it. Config residual if an operator restored*. - CVE-2026-22874
reservedIPNetson the Go transport. CGNAT, TEST-NET, NAT64, Teredo, Azure WireServer. Not this leftover. The missing0.0.0.0/8is a sibling. - CVE-2026-59765 Go
uri.Openhostmatcher. Live migrate HTTP is gated.file://still callsos.Openon restore. That is a different leftover. - CVE-2026-66877 / CVE-2026-71184 fork-PR Actions approval. Closed on this tag.
- CVE-2026-28699 / CVE-2026-28744 OAuth Basic and Smart HTTP Bearer scopes. Closed.
- CVE-2026-26231 maintainer-edit reverse fork. Closed.
- CVE-2026-28740 LFS object reuse. Closed.
- CVE-2026-60010 repo-side team-link. Closed.
- CVE-2026-73278 / CVE-2026-73535 2FA on federated login. Closed.
- CVE-2026-68957 restricted vs limited issue content. 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."
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:
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.
# Loopback lab image pin for gitea-user-keys-idor. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: 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.
git clone https://github.com/abraxas/gitea-user-keys-idor
cd gitea-user-keys-idor/lab
./run.shThe 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:
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 IDORAttacker'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
- Proof of concept: abraxas/gitea-user-keys-idor · gitea-user-keys-idor-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · seed.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-639
- Tree: gitea v1.27.3 (
146cc3e) ·GetPublicKey·GetPublicKeyByID - Nearby patched (not this lookup): CVE-2026-60004 · CVE-2026-59774
- Same tag: gitea-git-redir-ssrf
- SECURITY.md
- Product: Gitea