
Forgejo 16.0.5 - High - Remote - IDOR - GET /api/v1/user/keys/{id} returns other users SSH and deploy keys
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · forgejo-keys-idor
Class: IDOR Reach: Remote
Forgejo 16.0.5 (8d24b7d) - Forgejo
GET /api/v1/user/keys/{id} sits in the current-user group (reqToken, user scope). The handler still loads public_key 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.
A read:user token walks 1, 2, 3 and inventories other people's SSH and deploy keys.
| ID | no CVE yet |
| Class | IDOR |
| Reach | Remote (authenticated read:user / session) |
| CWE | CWE-639 |
| CVSS | High: 7.5 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
| Product | Forgejo |
| Affected | through v16.0.5 (8d24b7d) |
| Auth | authenticated (read:user token or session) |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
Register or use any account, mint a read:user token (or use a session cookie), walk /api/v1/user/keys/{id}. They get titles, fingerprints, and public key material for keys they do not own. They do not get the private key. They do not get a shell. They inventory who has access to what.
| Privilege | Method / sink | Recon / integrity / availability | 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 (confidentiality of public-key inventory) | Lab-proven. Witness title FORGEJO-KEYS-IDOR-WITNESS on id 1; attacker list is only id 2 |
| Inventory deploy keys the same way | Same handler, KeyTypeDeploy rows in public_key (OwnerID 0) | Recon | Inferred from the table. Lab planted a user SSH key, not a deploy key |
| Read SSH certificate principals | Same handler, KeyTypePrincipal rows. ListPublicKeys excludes this type | Recon | Inferred from the table |
| See keys that belong to limited or private users | No visibility check on this lookup | Recon | Inferred from the handler |
Extra JSON (user, key_type, read_only) | appendPrivateInformation only if admin or owner | Recon | Lab-proven closed: non-owner response had no user field |
Closed on this route: private key material, a shell, GET /users/{username}/keys (username-scoped public list), GPG GET /api/v1/user/gpg_keys/{id} (404 on a foreign id), DeletePublicKey (owner/admin 403). Cross-repo GET /repos/{owner}/{repo}/keys/{id} 404s when RepoID does not match.
v16.0.5 security patches (#14370) were git-apply RCE and OpenID CSRF. They did not close this lookup. Same class as gitea-user-keys-idor, independently still open here.
I pinned Forgejo v16.0.5 after the 2026-09 wave. Gitea 1.27.3 still had this lookup. Forgejo has diverged, so I re-read the current-user key handler next to GPG instead of copying the Gitea pack. GPG 404s a foreign id. SSH does not.
Wrong turns already recorded: treating the attacker's own list as the leak (it is not - list is scoped, GET-by-id is not); treating DISABLE_REGISTRATION=true on the installer as a close (any existing account is enough); a reverse shell. Theatre.
python3 forgejo-keys-idor-Abraxas-Labs.py
or:
cd lab
./run.sh
Target only http://127.0.0.1:19310. Image codeberg.org/forgejo/forgejo:16.0.5. Two accounts. Witness FORGEJO-KEYS-IDOR-WITNESS.
IOC planted victim-id=1 attacker-id=2
IOC attacker-list status=200 ... title=attacker-own-key
IOC get id=1 status=200 ... FORGEJO-KEYS-IDOR-WITNESS
IOC gpg-neg id=1 status=404
SUCCESS forgejo-keys-idor victim-id=1 get-http=200 list-has-id=no gpg-http=404 FORGEJO-KEYS-IDOR-WITNESS
404 unless key.OwnerID == ctx.Doer().ID (or site admin). Reject KeyTypeDeploy / KeyTypePrincipal on this route. Match GetGPGKeyForUserByID.
GetPublicKeyGNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.