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
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:
m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos).
Post(rejectPublicOnly(), bind(api.CreateRepoOption{}), repo.Create)POST /orgs does not:
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.
# Loopback lab image pin for gitea-public-only-pat-org. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: 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:git clone https://github.com/abraxas/gitea-public-only-pat-org
cd gitea-public-only-pat-org/lab
./run.shThe 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=privateon GET. - Private user-repo create succeeding. That would mean
rejectPublicOnlyis 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=privateplus the sibling 403.
Last lab run, trimmed:
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-PATThe 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
- Proof of concept: abraxas/gitea-public-only-pat-org · gitea-public-only-pat-org-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-863
- Tree: gitea v1.27.3 ·
api.go(rejectPublicOnly,POST /orgs,POST /user/repos) - SECURITY.md
- Product: Gitea