Gitea, follow existence oracle
Gitea 1.27.3 PUT /api/v1/user/following/{username} skips IsUserVisibleToViewer. Follow of a hidden user is 204. Unknown names 404. GET /users/{name} already 404s hidden users. Status-class oracle only. No CVE yet.
- Name
- Gitea, follow existence oracle
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- medium
- Disclosure Status
- public
- Vendor
- Gitea
- Affected
- Gitea through v1.27.3 (146cc3e); unpublished. Needs a signed-in account and a limited/hidden user on the instance.
- Published
- 29 Sept 2026
- Updated
- 29 Sept 2026
- Tags
- 0-day, gitea, enumeration, visibility, cwe-203, authenticated
204 means the account exists
I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-follow-existence (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh. Authorized lab only. It talks to loopback.
This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-203. 4.3 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N).
How I found it
Same visibility pass as stargazers. GetInfo already 404s hidden users and lies about why: fake ErrUserNotExist error message to not leak information about existence. When a handler works that hard to lie, you look at every other route that takes {username}.
PUT /user/following/{username} loads the target with UserAssignmentAPI() and calls Follow. No IsUserVisibleToViewer. Missing name: UserAssignmentAPI 404s user redirect does not exist. Hidden name that exists: 204. Restricted attacker GET /users/hiddenlimited is 404. That is the oracle. Status class only. The profile JSON stays closed.
I registered a restricted attacker, followed the hidden name, followed a name that does not exist, and compared status codes. Public users are supposed to 204. That is not SUCCESS. Hidden 204 vs unknown 404 while GET is 404 is SUCCESS.
I am not printing a username wordlist you can PUT at someone else's /user/following. The skipped IsUserVisibleToViewer is the useful part.
Wrong turns already recorded: Follow hidden returning 404 (then visibility is on this route); profile JSON for the hidden user (GetInfo already 404s - this bug is status class, not a dump); treating Follow 204 on a public user as SUCCESS; a reverse shell. Theatre.
Follow tells the truth
GetInfo already 404s hidden users:
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
// fake ErrUserNotExist error message to not leak information about existence
ctx.APIErrorNotFound()
return
}PUT /user/following/{username} does not:
m.Group("/following", func() {
m.Get("", user.ListMyFollowing)
m.Group("/{username}", func() {
m.Get("", user.CheckMyFollowing)
m.Put("", user.Follow)
m.Delete("", user.Unfollow)
}, context.UserAssignmentAPI())
})func Follow(ctx *context.APIContext) {
if err := user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser); err != nil {
if errors.Is(err, user_model.ErrBlockedUser) {
ctx.APIError(http.StatusForbidden, err.Error())
} else {
ctx.APIErrorInternal(err)
}
return
}
ctx.Status(http.StatusNoContent)
}Check and Unfollow sit on the same group. Same missing check.
What an attacker can do
Sign in (default open registration is cheap). Prove a hidden or limited account exists even when the profile API 404s. 204 means the name is real. 404 means you guessed a name that is not there.
No profile dump. No private git. Combined with unauth stargazers, you can find a hidden login on a public repo and then confirm it still exists after they hide the profile.
The lab (run this at home)
Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18134. Bind it to loopback. Compose allows public,limited,private visibility.
# Loopback lab image pin for gitea-follow-existence. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: gitea-follow-existence
services:
gitea:
image: gitea/gitea:1.27.3
ports:
- "127.0.0.1:18134: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:18134/
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
GITEA__service__ALLOWED_USER_VISIBILITY_MODES: public,limited,private
volumes:
- gitea_data:/data
volumes:
gitea_data:git clone https://github.com/abraxas/gitea-follow-existence
cd gitea-follow-existence/lab
./run.shThe proof of concept is written for 127.0.0.1:18134. Do not publish the port off loopback.
What the tree actually consumes
Restricted attacker. GET hidden profile 404. Follow hidden name 204. Follow unknown name 404. GET public name 200 as a sanity check.
A few other ways to lose without learning anything:
- Follow hidden returning 404. That would mean visibility is on this route.
- Profile JSON for the hidden user.
GetInfoalready 404s. This bug is status class, not a dump. - Treating Follow 204 on a public user as SUCCESS. Public users are supposed to 204.
- A reverse shell. Theatre. The witness is hidden=204 vs unknown=404 while GET is 404.
Last lab run, trimmed:
get-hidden status=404
follow-hidden status=204
follow-unknown status=404 user redirect does not exist
oracle hidden=204 unknown=404
SUCCESS GITEA-FOLLOW-ORACLEThe client that produced it is on GitHub.
What this is not
It is not "Gitea never 404s hidden profiles." GET /users/{username} already lies with not found. Follow does not. It is not unauthenticated. It is not a private-git read. It is not RCE.
The fix
Call IsUserVisibleToViewer in Follow (and Check/Unfollow) before FollowUser. Re-run the loopback client against a patched build: Follow of a hidden name must 404 the same way GetInfo already does.
I am not going to print a PUT of /user/following/{username} aimed at a live host. The skipped visibility 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: hostmatcher 0.0.0.0/8 · public-only PAT org · stargazers hidden · artifact v4 ReadAll · restore file:// · keys IDOR / git redirect.
References
- Proof of concept: abraxas/gitea-follow-existence · gitea-follow-existence-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-203
- Tree: gitea v1.27.3 ·
follower.go·api.gofollowing group ·GetInfo - SECURITY.md
- Product: Gitea