Research/gitea-follow-existence
0-dayNo CVEMediumPublic

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:

Go
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
	// fake ErrUserNotExist error message to not leak information about existence
	ctx.APIErrorNotFound()
	return
}
Go
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())
})
Go
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.

Dockerfile
# Loopback lab image pin for gitea-follow-existence. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: 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:
Plain text
git clone https://github.com/abraxas/gitea-follow-existence
cd gitea-follow-existence/lab
./run.sh

The 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. GetInfo already 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:

Plain text
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-ORACLE

The 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