mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-23 07:10:41 +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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user