Research/gitea-public-only-pat-org
0-dayNo CVEMediumPublic

Gitea, public-only PAT creates private org

Gitea 1.27.3 POST /api/v1/orgs has no rejectPublicOnly. A public-only PAT with write:organization creates a private organization. The same token cannot create a private user repository. No CVE yet.

Name
Gitea, public-only PAT creates private org
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-only PAT with org write. DEFAULT_ALLOW_CREATE_ORGANIZATION defaults true.
Published
29 Sept 2026
Updated
29 Sept 2026
Tags
0-day, gitea, authorization, pat, cwe-863, authenticated

public-only is not org-create

I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-public-only-pat-org (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-863. 6.5 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N).

How I found it

Same source pass as the hostmatcher 0.0.0.0/8 lab: v1.27.3 after the GHSA wave, then the handlers that still skip a gate a sibling already has. Visibility and token-scope bugs were a theme of that wave. public-only on a PAT is supposed to be the promise that the token cannot touch private resources.

I grepped rejectPublicOnly in api.go. POST /user/repos has it. POST /orgs has tokenRequiresScopes and reqToken. It does not have rejectPublicOnly. Scope check is there. Public-only is not. DEFAULT_ALLOW_CREATE_ORGANIZATION defaults true, so a normal account can create orgs.

I minted a PAT with scopes public-only,write:organization,write:repository,write:user. Private org create returned 201. Same token, private user-repo create, 403 this endpoint is not available for public-only tokens. That 403 is the control. If both had 201, the middleware would be gone entirely. If both had 403, there would be nothing to file.

I am not printing a PAT or an org-create JSON you can paste at someone else's instance. The missing middleware is the useful part.

Wrong turns already recorded: treating org HTTP 201 as enough (confirm visibility=private on GET); private user-repo create succeeding (then rejectPublicOnly is gone - lab requires 403); reading existing private git contents; a reverse shell. Theatre.

The missing middleware

Go
func rejectPublicOnly() func(ctx *context.APIContext) {
	return func(ctx *context.APIContext) {
		if !ctx.PublicOnly {
			return
		}
		ctx.APIError(http.StatusForbidden, "this endpoint is not available for public-only tokens")
	}
}

Sibling POST /user/repos uses it:

Go
m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos).
	Post(rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.Create)

POST /orgs does not:

Go
m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create)

Org-repo create only checks org visibility. Deprecated POST /org/{org}/repos skips public-only entirely. I labbed org create. That is enough.

What an attacker can do

Use a stolen or over-issued public-only PAT that also has write:organization. Create a private organization. That org is a hiding place the token was not supposed to be able to make. The same token still cannot create a private user repository, and this bug does not open someone else's existing private git.

Integrity, not a private-git dump. Useful when the victim issued "public-only" on purpose: CI, a bot, a contractor token they thought could not grow a private org.

The lab (run this at home)

Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18132. Bind it to loopback. Compose sets DEFAULT_ALLOW_CREATE_ORGANIZATION=true.

Dockerfile
# Loopback lab image pin for gitea-public-only-pat-org. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: gitea-public-only-pat-org

services:
  gitea:
    image: gitea/gitea:1.27.3
    ports:
      - "127.0.0.1:18132: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:18132/
      GITEA__service__DISABLE_REGISTRATION: "false"
      GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
      GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "true"
    volumes:
      - gitea_data:/data

volumes:
  gitea_data:
Plain text
git clone https://github.com/abraxas/gitea-public-only-pat-org
cd gitea-public-only-pat-org/lab
./run.sh

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

What the tree actually consumes

Mint a public-only token with org write. Create a private org. Create a private user repo with the same token. The 403 on the sibling is the control.

A few other ways to lose without learning anything:

  • Treating org HTTP 201 as enough. Confirm visibility=private on GET.
  • Private user-repo create succeeding. That would mean rejectPublicOnly is gone. Lab requires 403.
  • Reading existing private git contents. This bug creates a new private org. It does not open someone else's.
  • A reverse shell. Theatre. The witness is visibility=private plus the sibling 403.

Last lab run, trimmed:

Plain text
token-scopes=public-only,write:organization,write:repository,write:user
org-create status=201
org-visibility='private'
user-repo-create status=403 this endpoint is not available for public-only tokens
SUCCESS GITEA-PUBLIC-ONLY-PAT

The client that produced it is on GitHub. I am not reprinting the token.

What this is not

It is not "public-only tokens can read private repos." User-repo create is gated. Org create is not. It is integrity, not a private-git dump. It is not unauthenticated. It is not RCE.

The fix

Put rejectPublicOnly() on POST /orgs, and treat org-repo create as public-only-sensitive too. Re-run the loopback client against a patched build: public-only org create must 403 the same way private user-repo create already does.

I am not going to print a token-create body you can paste at someone else's instance. The missing middleware 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 · stargazers hidden · follow existence · artifact v4 ReadAll · restore file:// · keys IDOR / git redirect.

References

Gitea, public-only PAT creates private org · Abraxas Labs