Research/gitea-stargazers-hidden
0-dayNo CVEMediumPublic

Gitea, unauthenticated stargazers leak hidden users

Gitea 1.27.3 GetStargazers and GetWatchers omit isUserVisibleToViewerCond. A limited user who starred a public repo appears in unauth JSON while GET /users/{name} is 404. Follower lists already filter. No CVE yet.

Name
Gitea, unauthenticated stargazers leak hidden users
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 public repository and at least one limited/hidden user who starred or watched it.
Published
29 Sept 2026
Updated
29 Sept 2026
Tags
0-day, gitea, disclosure, visibility, cwe-200, unauthenticated

stars skip the visibility filter

I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-stargazers-hidden (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-200. 5.3 Medium (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).

How I found it

Visibility was a theme of the 1.27.3 GHSA wave. Limited and private users are supposed to vanish from anonymous viewers. GetInfo already 404s them. Follower lists already apply isUserVisibleToViewerCond in SQL. When a product 404s a profile on purpose, the next question is which list endpoints still join user without that condition.

GetStargazers is a join on star with no visibility filter. ListStargazers then convert.ToUsers the lot. ListSubscribers is the same shape for watchers. No isUserVisibleToViewerCond.

I planted hidstar as limited, starred and watched a public repo, and hit the instance with no token. Unauth GET /api/v1/users/hidstar is 404. Unauth stargazers JSON includes hidstar. Unauth watchers include hidstar. Unauth followers HTML does not. The profile is shy. The star table is not.

I am not printing a stargazers walk you can paste at someone else's public repo. The missing And(isUserVisibleToViewerCond(viewer)) is the useful part.

Wrong turns already recorded: unauth GET /users/hidstar returning 200 (then visibility is off entirely - lab requires 404); followers HTML listing hidstar (that path already applies the condition); treating a 200 on /stargazers with only public names as SUCCESS; a reverse shell. Theatre. The leak is the limited login in the JSON.

The list that forgot the filter

GetInfo already hides limited users from anonymous viewers:

Go
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
	// fake ErrUserNotExist error message to not leak information about existence
	ctx.APIErrorNotFound()
	return
}

Follower lists do the same in SQL. GetUserFollowers:

Go
sess := db.GetEngine(ctx).
	Select("`user`.*").
	Join("LEFT", "follow", "`user`.id=follow.user_id").
	Where("follow.follow_id=?", u.ID).
	And("`user`.type=?", UserTypeIndividual).
	And(isUserVisibleToViewerCond(viewer))

GetStargazers does not:

Go
func GetStargazers(ctx context.Context, repo *Repository, opts db.ListOptions) ([]*user_model.User, error) {
	sess := db.GetEngine(ctx).Where("star.repo_id = ?", repo.ID).
		Join("LEFT", "star", "`user`.id = star.uid")
	// ...
	return users, sess.Find(&users)
}

What an attacker can do

Without logging in, read a public repo's stargazers and watchers and learn usernames that GET /users/{name} hides. That is recon on limited and private accounts that starred or watched something public: people who set visibility so they would not show up in search, then clicked star anyway.

It is a username, not an email, not a private-git read, not RCE. Combined with the Follow 204 vs 404 oracle, you can confirm a hidden login exists even when the profile 404s.

The lab (run this at home)

Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18133. Bind it to loopback. Compose allows public,limited,private visibility and leaves stars on.

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

services:
  gitea:
    build:
      context: .
      dockerfile: Dockerfile
    image: gitea-stargazers-hidden:1.27.3
    ports:
      - "127.0.0.1:18133: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:18133/
      GITEA__service__DISABLE_REGISTRATION: "false"
      GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
      GITEA__service__ALLOWED_USER_VISIBILITY_MODES: public,limited,private
      GITEA__repository__DISABLE_STARS: "false"
    volumes:
      - gitea_stargazers_hidden_data:/data

volumes:
  gitea_stargazers_hidden_data:
Plain text
git clone https://github.com/abraxas/gitea-stargazers-hidden
cd gitea-stargazers-hidden/lab
./run.sh

The proof of concept is written for 127.0.0.1:18133. Do not publish the port off loopback.

What the tree actually consumes

Limited user stars and watches a public repo. Unauth profile GET 404s. Unauth stargazers and subscribers list the login. Followers web HTML is the control that already filters.

A few other ways to lose without learning anything:

  • Unauth GET /users/hidstar returning 200. Then visibility is off entirely. Lab requires 404.
  • Followers HTML listing hidstar. That path already applies the condition.
  • Treating a 200 on /stargazers with only public names as SUCCESS. The leak is the limited login in the JSON.
  • A reverse shell. Theatre. The witness is hidstar in unauth JSON.

Last lab run, trimmed:

Plain text
unauth-user-get status=404
unauth-stargazers status=200 stargazer-names=['hidstar']
unauth-watchers leak=True
unauth-followers-web limited-in-html=False
SUCCESS GITEA-STARGAZERS-HIDDEN

The client that produced it is on GitHub.

What this is not

It is not "Gitea never hides limited users." Profile GET and follower lists already do. Stars and watchers do not. It is a username on a public repo, not email, not a private-git read, not RCE.

The fix

Apply isUserVisibleToViewerCond in GetStargazers / GetRepoWatchers. Re-run the loopback client against a patched build: unauth stargazers and watchers must omit hidstar the same way followers already do.

I am not going to print an unauth curl of /stargazers aimed at a live host. The missing SQL condition 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 · follow existence · artifact v4 ReadAll · restore file:// · keys IDOR / git redirect.

References