mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-28 09:55:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user