fix: various security fixes (#38406)

Addresses a batch of privately reported security issues, grouped by
area:

- **SSRF** - migration PR-patch/asset fetches, OAuth2 avatar & OpenID
discovery, pull-mirror URL re-validation, and the outbound proxy path.
- **Access-token scope** - prevent scope escalation on token creation;
keep public-only tokens confined (feeds, packages, Actions listings,
star/watch lists, limited/private owners).
- **Access control / disclosure** - go-get default-branch leak, webhook
authorization-header leak, watch clearing on private transitions,
label/attachment scoping.
- **Denial of service** - input bounds for npm dist-tags, Debian control
files, Arch file lists, and SSH keys.

### 📌 Attention for site admins

Not breaking - existing configs keep working - but two changes are worth
a look:

- **New SSRF protection** Outbound requests (migrations, OAuth2 avatars,
OpenID discovery, pull mirrors, proxy path) are now validated against
the allow/block host lists. If your instance legitimately reaches
internal hosts, you may need to add them to
`[security].ALLOWED_HOST_LIST` (and the relevant `ALLOW_LOCALNETWORKS`
settings).
- **Deprecation** `[webhook].ALLOWED_HOST_LIST` is deprecated and will
be removed in a future release. Use `[security].ALLOWED_HOST_LIST`
instead; the old key still works for now.

---------

Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
bircni
2026-07-12 19:14:09 +02:00
committed by GitHub
parent d2bd1589fe
commit f69e15afe7
93 changed files with 1715 additions and 138 deletions
+24 -3
View File
@@ -19,9 +19,11 @@ import (
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/container"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/proxy"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
source_service "gitea.dev/services/auth/source"
@@ -296,7 +298,23 @@ func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.Us
ctx.Redirect(setting.AppSubURL + "/user/link_account")
}
var oauth2AvatarHTTPClient = &http.Client{Timeout: 30 * time.Second}
// oauth2AvatarAllowList parses the host allow-list applied to avatar fetches from the global
// [security] ALLOWED_HOST_LIST, defaulting an empty setting to the built-in "external" set. An empty
// host-match list would otherwise disable the allow-list check entirely and permit any host, including
// loopback/private addresses (SSRF).
func oauth2AvatarAllowList() *hostmatcher.HostMatchList {
return hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
}
// oauth2AvatarHTTPClient builds the SSRF-protected client for avatar fetches. It is constructed per call
// so a changed allowlist takes effect (avatar fetches are infrequent, so this is not a hot path).
func oauth2AvatarHTTPClient() *http.Client {
allowList := oauth2AvatarAllowList()
return &http.Client{
Timeout: 30 * time.Second,
Transport: hostmatcher.NewHTTPTransport("oauth2-avatar", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
}
}
func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_model.User) {
if !setting.OAuth2Client.UpdateAvatar || len(avatarURL) == 0 {
@@ -310,7 +328,7 @@ func oauth2UpdateAvatarIfNeed(ctx *context.Context, avatarURL string, u *user_mo
// Some hosts (e.g. Wikimedia) reject Go's default User-Agent.
req.Header.Set("User-Agent", "Gitea "+setting.AppVer)
resp, err := oauth2AvatarHTTPClient.Do(req)
resp, err := oauth2AvatarHTTPClient().Do(req)
if err != nil {
log.Warn("fetch %q failed: %v", avatarURL, err)
return
@@ -373,7 +391,10 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
ctx.ServerError("GetExternalLogin", err)
return
}
isDisabledByAutoSync := hasExt && extLogin.RefreshToken == ""
// the cron clears all three token fields when it disables a user, so require the
// full signature; a RefreshToken alone is empty for many normal logins (e.g. GitHub
// or OIDC without offline_access), which would otherwise reactivate admin-disabled users
isDisabledByAutoSync := hasExt && extLogin.AccessToken == "" && extLogin.RefreshToken == "" && extLogin.ExpiresAt.IsZero()
if isDisabledByAutoSync {
opts.IsActive = optional.Some(true)
}
+13
View File
@@ -98,6 +98,19 @@ func InfoOAuth(ctx *context.Context) {
return
}
// enforce the same user scope the REST API requires before returning identity
// claims; OIDC access tokens map to the "all" scope, so standard OIDC clients
// are unaffected and only explicitly-restricted tokens are rejected
tokenScope, _ := ctx.Data["ApiTokenScope"].(auth.AccessTokenScope)
if allowed, err := tokenScope.HasScope(auth.AccessTokenScopeReadUser); err != nil {
ctx.ServerError("HasScope", err)
return
} else if !allowed {
ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm="Gitea OAuth2"`)
ctx.PlainText(http.StatusForbidden, "token does not have required scope: read:user")
return
}
response := &userInfoResponse{
Sub: strconv.FormatInt(ctx.Doer.ID, 10),
Name: ctx.Doer.DisplayName(),
+46
View File
@@ -4,15 +4,22 @@
package auth
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/hostmatcher"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/oauth2_provider"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createAndParseToken(t *testing.T, grant *auth.OAuth2Grant) *oauth2_provider.OIDCToken {
@@ -73,3 +80,42 @@ func TestNewAccessTokenResponse_OIDCToken(t *testing.T) {
assert.Equal(t, user.Email, oidcToken.Email)
assert.Equal(t, user.IsActive, oidcToken.EmailVerified)
}
func TestOAuth2AvatarClientBlocksLoopback(t *testing.T) {
var hit atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit.Store(true)
_, _ = w.Write([]byte("img"))
}))
defer srv.Close()
// the httptest server binds a loopback address, which the SSRF-protected dialer must refuse
resp, err := oauth2AvatarHTTPClient().Get(srv.URL)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.False(t, hit.Load(), "avatar client must refuse to dial a loopback address")
}
func TestOAuth2AvatarAllowListRestricts(t *testing.T) {
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
allowList := oauth2AvatarAllowList()
assert.True(t, allowList.MatchHostName("avatars.example.com"), "the configured host must be allowed")
assert.False(t, allowList.MatchHostName("8.8.8.8"), "an unrelated external host must be rejected")
// the default `external` allow-list still permits external hosts
setting.Security.AllowedHostList = hostmatcher.MatchBuiltinExternal
assert.True(t, oauth2AvatarAllowList().MatchHostName("8.8.8.8"), "default allow-list permits external hosts")
}
func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
// external-only allow-list must reject link-local cloud metadata (169.254.169.254) at dial time
resp, err := oauth2AvatarHTTPClient().Get("http://169.254.169.254/latest/meta-data/")
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err)
assert.ErrorContains(t, err, "can only call allowed HTTP servers",
"avatar client must refuse a link-local cloud-metadata address")
}
+5
View File
@@ -41,6 +41,11 @@ func showUserFeed(ctx *context.Context, formatType string) {
includePrivate = isOrgMember
}
// a public-only API token must not surface private activity, even for its own owner
if includePrivate && context.TokenIsPublicOnly(ctx) {
includePrivate = false
}
actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
+50 -4
View File
@@ -11,7 +11,11 @@ import (
"path"
"strings"
auth_model "gitea.dev/models/auth"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/context"
@@ -51,10 +55,8 @@ func goGet(ctx *context.Context) {
return
}
branchName := setting.Repository.DefaultBranch
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
if err == nil && len(repo.DefaultBranch) > 0 {
branchName = repo.DefaultBranch
if repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName); err == nil {
branchName = goGetDefaultBranch(ctx, repo)
}
prefix := setting.AppURL + path.Join(url.PathEscape(ownerName), url.PathEscape(repoName), "src", "branch", util.PathEscapeSegments(branchName))
@@ -91,3 +93,47 @@ func goGet(ctx *context.Context) {
ctx.RespHeader().Set("Content-Type", "text/html")
_, _ = ctx.Write([]byte(res))
}
// goGetDefaultBranch returns the repository's real default branch only when the caller may genuinely
// reach it, otherwise the neutral instance default, so the meta response does not disclose the branch
// name (or the repo's existence) to callers who cannot see the repository.
func goGetDefaultBranch(ctx *context.Context, repo *repo_model.Repository) string {
def := setting.Repository.DefaultBranch
if len(repo.DefaultBranch) == 0 {
return def
}
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
return def
}
// a token that was not granted repository read scope must not learn repository details, even when the
// account behind it could read the repo through the web UI
if !goGetTokenCanReadRepo(ctx) {
return def
}
// a public-only token may only reach genuinely public resources (a public repo under a public owner)
if context.TokenIsPublicOnly(ctx) && (repo.IsPrivate || !repo.Owner.Visibility.IsPublic()) {
return def
}
// the caller must be able to read the code and see the owner: a limited/private owner hides its repos
// from anonymous/non-member callers even when the repo itself is public
perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
if err != nil || !perm.CanRead(unit.TypeCode) || !user_model.IsUserVisibleToViewer(ctx, repo.Owner, ctx.Doer) {
return def
}
return repo.DefaultBranch
}
// goGetTokenCanReadRepo reports whether the request may learn repository details. A non-token request
// always may; a token request may only when its scope grants repository read, so a PAT that was never
// scoped for repositories cannot disclose the branch even if its owner can read the repo.
func goGetTokenCanReadRepo(ctx *context.Context) bool {
if ctx.Data["IsApiToken"] != true {
return true
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
return false
}
has, err := scope.HasScope(auth_model.AccessTokenScopeReadRepository)
return err == nil && has
}
+4 -2
View File
@@ -179,9 +179,11 @@ func UpdateIssueLabel(ctx *context.Context) {
}
}
case "attach", "detach", "toggle", "toggle-alt":
label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id"))
// scope the label to this repo (or its org) so a foreign label ID is 404, not an oracle
labelID := ctx.FormInt64("id")
label, err := issues_model.GetLabelInRepoOrOrgByID(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner.ID, ctx.Repo.Owner.IsOrganization(), labelID)
if err != nil {
if issues_model.IsErrRepoLabelNotExist(err) {
if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound, "GetLabelByID")
} else {
ctx.ServerError("GetLabelByID", err)
+8
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
@@ -394,6 +395,13 @@ func Home(ctx *context.Context) {
return
}
// a scoped or public-only API token authenticating this web request must still satisfy
// the repository read scope before private repo content is served
context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
if ctx.Written() {
return
}
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
+4
View File
@@ -170,6 +170,10 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
date := ctx.FormString("date")
pagingNum = setting.UI.FeedPagingNum
showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
// a public-only API token must not surface private activity, even for its own owner
if showPrivate && context.TokenIsPublicOnly(ctx) {
showPrivate = false
}
items, feedCount, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.Doer,
+24
View File
@@ -79,6 +79,30 @@ func ApplicationsPost(ctx *context.Context) {
return
}
// a token-authenticated request must not mint a token with a broader scope than its own, nor
// drop the public-only restriction. Web routes accept basic-auth PATs/OAuth tokens too, so this
// must mirror the REST API guard in routers/api/v1/user/app.go.
if ctx.Data["IsApiToken"] == true {
apiTokenScope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if !ok {
ctx.HTTPError(http.StatusForbidden, "the authenticating token has no scope")
return
}
hasScope, err := apiTokenScope.CanCreateChildScope(t.Scope)
if err != nil {
ctx.ServerError("CanCreateChildScope", err)
return
}
if !hasScope {
ctx.HTTPError(http.StatusForbidden, "cannot create an access token with a broader scope than the authenticating token")
return
}
if t.Scope, err = t.Scope.EnforcePublicOnlyFrom(apiTokenScope); err != nil {
ctx.ServerError("EnforcePublicOnlyFrom", err)
return
}
}
if err := auth_model.NewAccessToken(ctx, t); err != nil {
ctx.ServerError("NewAccessToken", err)
return