mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-29 09:09:39 +09: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:
co-authored by
TheFox0x7
techknowlogick
Lunny Xiao
wxiaoguang
Zettat123
parent
d2bd1589fe
commit
f69e15afe7
@@ -8,11 +8,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
@@ -48,6 +50,17 @@ func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task
|
||||
return task, ok, false, err
|
||||
}
|
||||
|
||||
// releaseTaskForRunnerCleanup releases a claimed task using a fresh, bounded context. The request context
|
||||
// is typically already canceled when we reach the release paths below, and a DB transaction on a canceled
|
||||
// context fails immediately, which would strand the claimed job in running state.
|
||||
func releaseTaskForRunnerCleanup(t *actions_model.ActionTask) {
|
||||
ctx, cancel := context.WithTimeout(graceful.GetManager().ShutdownContext(), 10*time.Second)
|
||||
defer cancel()
|
||||
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
|
||||
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
|
||||
}
|
||||
}
|
||||
|
||||
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
|
||||
var (
|
||||
task *runnerv1.Task
|
||||
@@ -92,9 +105,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
||||
// The job was already claimed but assembling its payload failed; release the
|
||||
// claim so the job returns to the waiting queue instead of being stranded in
|
||||
// running state with no runner ever executing it.
|
||||
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
|
||||
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
|
||||
}
|
||||
releaseTaskForRunnerCleanup(t)
|
||||
return nil, false, err
|
||||
}
|
||||
actionTask = t
|
||||
@@ -110,9 +121,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
||||
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
|
||||
// Release the claim so another runner can pick the job up.
|
||||
if err := ctx.Err(); err != nil {
|
||||
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
|
||||
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
|
||||
}
|
||||
releaseTaskForRunnerCleanup(t)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -32,3 +34,41 @@ func TestTryPickTaskThrottled(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
assert.True(t, throttled)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunnerCleanup verifies the cleanup used by PickTask releases a claimed task through a
|
||||
// fresh context. PickTask reaches this path when the request context is already canceled, and on
|
||||
// PostgreSQL/MySQL a DB transaction on a canceled context fails immediately; reusing it would strand the
|
||||
// claimed job in running state, so the cleanup must not use the request context.
|
||||
func TestReleaseTaskForRunnerCleanup(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "cleanup-run", RepoID: 1, OwnerID: 2, WorkflowID: "test.yaml",
|
||||
TriggerUserID: 2, Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||
Name: "cleanup-job", Attempt: 1, JobID: "cleanup-job", Status: actions_model.StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n cleanup-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
runner := &actions_model.ActionRunner{Name: "cleanup-runner", AgentLabels: []string{"ubuntu-latest"}}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||
|
||||
// the cleanup helper uses its own context, so the claimed job is returned to the waiting queue
|
||||
releaseTaskForRunnerCleanup(task)
|
||||
released := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, actions_model.StatusWaiting, released.Status)
|
||||
assert.Zero(t, released.TaskID)
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, e
|
||||
if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 {
|
||||
// If an attacker steals a token and uses the token to create a new session the hash gets updated.
|
||||
// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
|
||||
// Revoke the token so the attacker's rotated token (which shares this ID) can no longer be used.
|
||||
if err := auth_model.DeleteAuthTokenByID(ctx, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrAuthTokenInvalidHash
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -62,7 +63,9 @@ func TestCheckAuthToken(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
|
||||
assert.Nil(t, at2)
|
||||
|
||||
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
|
||||
// a hash mismatch signals a compromised token, which must be revoked
|
||||
_, err = auth_model.GetAuthTokenByID(t.Context(), at.ID)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
})
|
||||
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
|
||||
@@ -49,9 +49,10 @@ type APIContext struct {
|
||||
}
|
||||
|
||||
// TokenCanAccessRepo reports whether the current API token is allowed to access the repository.
|
||||
// A public-only token cannot reach a private repo; any other token is unrestricted by this check.
|
||||
// A public-only token cannot reach a private repo or a repo owned by a non-public (limited or
|
||||
// private) owner; any other token is unrestricted by this check.
|
||||
func (ctx *APIContext) TokenCanAccessRepo(repo *repo_model.Repository) bool {
|
||||
return repo == nil || !ctx.PublicOnly || !repo.IsPrivate
|
||||
return !ctx.PublicOnly || !publicOnlyTokenDeniedRepo(ctx, repo)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
@@ -12,6 +13,39 @@ import (
|
||||
"gitea.dev/models/unit"
|
||||
)
|
||||
|
||||
// isOwnerHidden reports whether repo's owner is not publicly visible (a limited or private owner), so
|
||||
// the owner's repositories must be hidden from callers that may only reach genuinely public resources.
|
||||
func isOwnerHidden(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
if err := repo.LoadOwner(ctx); err != nil || repo.Owner == nil {
|
||||
return true // fail closed if the owner visibility can't be determined
|
||||
}
|
||||
return !repo.Owner.Visibility.IsPublic()
|
||||
}
|
||||
|
||||
// publicOnlyTokenDeniedRepo reports whether a public-only API token must be denied access to
|
||||
// repo. A public-only token may only reach genuinely public resources, so it is denied for
|
||||
// private repos and for repos owned by a non-public (limited or private) owner.
|
||||
func publicOnlyTokenDeniedRepo(ctx context.Context, repo *repo_model.Repository) bool {
|
||||
if repo == nil {
|
||||
return false
|
||||
}
|
||||
return repo.IsPrivate || isOwnerHidden(ctx, repo)
|
||||
}
|
||||
|
||||
// TokenIsPublicOnly reports whether the request is authenticated by a public-only API token. A
|
||||
// non-token request, or a token with no recorded scope, is not public-only.
|
||||
func TokenIsPublicOnly(ctx *Context) bool {
|
||||
if ctx.Data["IsApiToken"] != true {
|
||||
return false
|
||||
}
|
||||
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
publicOnly, _ := scope.PublicOnly()
|
||||
return publicOnly
|
||||
}
|
||||
|
||||
// CheckTokenScopes checks whether the authenticated API token contains any of the given scopes.
|
||||
func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_model.AccessTokenScope) {
|
||||
if ctx.Data["IsApiToken"] != true {
|
||||
@@ -29,7 +63,7 @@ func CheckTokenScopes(ctx *Context, repo *repo_model.Repository, scopes ...auth_
|
||||
return
|
||||
}
|
||||
|
||||
if publicOnly && repo != nil && repo.IsPrivate {
|
||||
if publicOnly && publicOnlyTokenDeniedRepo(ctx, repo) {
|
||||
ctx.HTTPError(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -310,7 +309,9 @@ func (g *RepositoryDumper) CreateReleases(_ context.Context, releases ...*base.R
|
||||
}
|
||||
defer rc.Close()
|
||||
} else {
|
||||
resp, err := http.Get(*asset.DownloadURL)
|
||||
// use the migration client so the fetch (including any redirect) is
|
||||
// validated against the migration host allow/block list
|
||||
resp, err := getMigrationHTTPClient().Get(*asset.DownloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -450,8 +451,10 @@ func (g *RepositoryDumper) handlePullRequest(ctx context.Context, pr *base.PullR
|
||||
}
|
||||
|
||||
// SECURITY: We will assume that the pr.PatchURL has been checked
|
||||
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe
|
||||
resp, err := http.Get(u) // TODO: This probably needs to use the downloader as there may be rate limiting issues here
|
||||
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe.
|
||||
// Use the migration client so an http(s) PatchURL (and any redirect it follows) is
|
||||
// validated against the migration host allow/block list at dial time.
|
||||
resp, err := getMigrationHTTPClient().Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo
|
||||
baseURL,
|
||||
gitea_sdk.SetToken(token),
|
||||
gitea_sdk.SetBasicAuth(username, password),
|
||||
gitea_sdk.SetHTTPClient(NewMigrationHTTPClient()),
|
||||
gitea_sdk.SetHTTPClient(newMigrationHTTPClient()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Failed to create NewGiteaDownloader for: %s. Error: %v", baseURL, err))
|
||||
@@ -273,7 +273,7 @@ func (g *GiteaDownloader) convertGiteaRelease(rel *gitea_sdk.Release) *base.Rele
|
||||
Created: rel.CreatedAt,
|
||||
}
|
||||
|
||||
httpClient := NewMigrationHTTPClient()
|
||||
httpClient := newMigrationHTTPClient()
|
||||
|
||||
for _, asset := range rel.Attachments {
|
||||
assetID := asset.ID // Don't optimize this, for closure we need a local variable
|
||||
|
||||
@@ -340,7 +340,9 @@ func (g *GiteaLocalUploader) CreateReleases(ctx context.Context, releases ...*ba
|
||||
return err
|
||||
}
|
||||
} else if asset.DownloadURL != nil {
|
||||
rc, err = uri.Open(*asset.DownloadURL)
|
||||
// use the migration client so the fetch (including any redirect) is
|
||||
// validated against the migration host allow/block list
|
||||
rc, err = uri.OpenWithClient(*asset.DownloadURL, getMigrationHTTPClient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -585,8 +587,10 @@ func (g *GiteaLocalUploader) updateGitForPullRequest(ctx context.Context, pr *ba
|
||||
}
|
||||
|
||||
// SECURITY: We will assume that the pr.PatchURL has been checked
|
||||
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe
|
||||
ret, err := uri.Open(pr.PatchURL) // TODO: This probably needs to use the downloader as there may be rate limiting issues here
|
||||
// pr.PatchURL maybe a local file - but note EnsureSafe should be asserting that this safe.
|
||||
// Use the migration client so an http(s) PatchURL (and any redirect it follows) is
|
||||
// validated against the migration host allow/block list at dial time.
|
||||
ret, err := uri.OpenWithClient(pr.PatchURL, getMigrationHTTPClient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func (g *GithubDownloaderV3) convertGithubRelease(ctx context.Context, rel *gith
|
||||
r.Published = rel.PublishedAt.Time
|
||||
}
|
||||
|
||||
httpClient := NewMigrationHTTPClient()
|
||||
httpClient := newMigrationHTTPClient()
|
||||
|
||||
for _, asset := range rel.Assets {
|
||||
assetID := asset.GetID() // Don't optimize this, for closure we need a local variable TODO: no need to do so in new Golang
|
||||
|
||||
@@ -94,7 +94,7 @@ type GitlabDownloader struct {
|
||||
// Use either a username/password, personal token entered into the username field, or anonymous/public access
|
||||
// Note: Public access only allows very basic access
|
||||
func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, token string) (*GitlabDownloader, error) {
|
||||
gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
|
||||
gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(newMigrationHTTPClient()))
|
||||
if err != nil {
|
||||
log.Trace("Error logging into gitlab: %v", err)
|
||||
return nil, err
|
||||
@@ -314,7 +314,7 @@ func (g *GitlabDownloader) convertGitlabRelease(ctx context.Context, rel *gitlab
|
||||
PublisherName: rel.Author.Username,
|
||||
}
|
||||
|
||||
httpClient := NewMigrationHTTPClient()
|
||||
httpClient := newMigrationHTTPClient()
|
||||
|
||||
for _, asset := range rel.Assets.Links {
|
||||
assetID := asset.ID // Don't optimize this, for closure we need a local variable
|
||||
|
||||
@@ -10,20 +10,33 @@ import (
|
||||
"gitea.dev/modules/hostmatcher"
|
||||
"gitea.dev/modules/proxy"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// NewMigrationHTTPClient returns a HTTP client for migration
|
||||
func NewMigrationHTTPClient() *http.Client {
|
||||
// migrationHTTPClient is the shared migration client. Callers that would otherwise build a client per
|
||||
// request use it (via getMigrationHTTPClient) so a single connection pool is reused across downloads —
|
||||
// e.g. many release assets from the same host — instead of a fresh pool and TLS handshake each time. It
|
||||
// is built lazily on first use and reset by Init whenever the allow/block lists change; OnceValue keeps
|
||||
// concurrent callers sharing a single client instead of racing to create their own.
|
||||
var migrationHTTPClient = util.OnceValue[*http.Client]{Func: newMigrationHTTPClient}
|
||||
|
||||
// newMigrationHTTPClient returns a HTTP client for migration
|
||||
func newMigrationHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: NewMigrationHTTPTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMigrationHTTPTransport returns a HTTP transport for migration
|
||||
// getMigrationHTTPClient returns the shared migration client, building it on first use so no request
|
||||
// escapes the SSRF-validated transport even before Init has run.
|
||||
func getMigrationHTTPClient() *http.Client {
|
||||
return migrationHTTPClient.Value()
|
||||
}
|
||||
|
||||
// NewMigrationHTTPTransport returns a HTTP transport for migration. The target is validated against the
|
||||
// allow/block lists on both the direct-dial and proxy paths, so a configured proxy cannot be used to
|
||||
// reach an otherwise-forbidden target (SSRF).
|
||||
func NewMigrationHTTPTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Migrations.SkipTLSVerify},
|
||||
Proxy: proxy.Proxy(),
|
||||
DialContext: hostmatcher.NewDialContext("migration", allowList, blockList, setting.Proxy.ProxyURLFixed),
|
||||
}
|
||||
return hostmatcher.NewHTTPTransport("migration", allowList, blockList, proxy.Proxy(), setting.Proxy.ProxyURLFixed,
|
||||
&tls.Config{InsecureSkipVerify: setting.Migrations.SkipTLSVerify})
|
||||
}
|
||||
|
||||
@@ -528,5 +528,9 @@ func Init() error {
|
||||
blockList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
||||
}
|
||||
|
||||
// reset the shared client so it is rebuilt from the freshly parsed lists on next use; download paths
|
||||
// then reuse one connection pool instead of creating a client (and pool) per request
|
||||
migrationHTTPClient.Reset()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
|
||||
log.Error("SyncMirrors [repo: %-v]: GetRemoteURL Error %v", m.Repo, remoteErr)
|
||||
return nil, false
|
||||
}
|
||||
// re-validate on every sync: the host may now resolve to an internal IP (rebinding) or the
|
||||
// allow/block list may have changed. ssh/file are skipped (not an HTTP SSRF vector).
|
||||
switch remoteURL.URL.Scheme {
|
||||
case "http", "https", "git":
|
||||
if allowErr := migrations.IsMigrateURLAllowed(remoteURL.String(), m.Repo.MustOwner(ctx)); allowErr != nil {
|
||||
log.Error("SyncMirrors [repo: %-v]: remote URL is not allowed: %v", m.Repo, allowErr)
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
envs := proxy.EnvWithProxy(remoteURL.URL)
|
||||
timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second
|
||||
|
||||
|
||||
@@ -120,9 +120,14 @@ func updateRepoForVisibilityChanged(ctx context.Context, repo *repo_model.Reposi
|
||||
return err
|
||||
}
|
||||
|
||||
// the repo is no longer publicly visible, so drop stars and watches from users who can no longer
|
||||
// see it, matching the direct repository-private transition (see services/repository)
|
||||
if err := repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create/Remove git-daemon-export-ok for git-daemon...
|
||||
|
||||
@@ -68,4 +68,23 @@ func TestOrg(t *testing.T) {
|
||||
require.NoError(t, ChangeOrganizationVisibility(t.Context(), org, structs.VisibleTypePrivate))
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.ID, Visibility: structs.VisibleTypePrivate})
|
||||
})
|
||||
|
||||
t.Run("ChangeVisibilityClearsWatchesAndStars", func(t *testing.T) {
|
||||
// org3 is a public organization owning the public repo32
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
|
||||
require.Equal(t, structs.VisibleTypePublic, org.Visibility)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32, OwnerID: org.ID})
|
||||
|
||||
// an outside user watches and stars the repo while the org is still visible
|
||||
watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
require.NoError(t, repo_model.WatchRepo(t.Context(), watcher, repo, true))
|
||||
require.NoError(t, repo_model.StarRepo(t.Context(), watcher, repo, true))
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
|
||||
|
||||
require.NoError(t, ChangeOrganizationVisibility(t.Context(), org, structs.VisibleTypePrivate))
|
||||
|
||||
// making the org private must drop watches, not only stars, from users who can no longer see it
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: watcher.ID, RepoID: repo.ID})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ func Test_Projects(t *testing.T) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 3})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
// user15 is on org3's team7 (write access to the public repo32 only), so it can see org3 public repos
|
||||
// but has no access to the private repo3 — a genuine "no permission to the private repo" org member.
|
||||
user15 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
|
||||
|
||||
t.Run("User projects", func(t *testing.T) {
|
||||
pi1 := project_model.ProjectIssue{
|
||||
@@ -142,13 +145,27 @@ func Test_Projects(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) {
|
||||
// user2 is on org3's Owners team and has owner access to the private repo3, so it is not a
|
||||
// valid "no permission" subject; user15 has no access to repo3 but can see the public repo32.
|
||||
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
|
||||
Owner: org3.AsUser(),
|
||||
Doer: user15,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user15 can only visit public repo issues
|
||||
})
|
||||
|
||||
t.Run("Org owner team member", func(t *testing.T) {
|
||||
// user2 is on org3's Owners team, so it has access to the private repo3 and must see both the
|
||||
// public and the private issue — the owner-team access that team.authorize grants at runtime.
|
||||
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
|
||||
Owner: org3.AsUser(),
|
||||
Doer: user2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user2 can only visit public repo issues
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 2) // owner-team member visits both public and private issues
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -273,6 +273,11 @@ func updateRepository(ctx context.Context, repo *repo_model.Repository, visibili
|
||||
if err = repo_model.ClearRepoStars(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// watchers who lost access must not keep watching the now-private repo
|
||||
if err = repo_model.ClearRepoWatches(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create/Remove git-daemon-export-ok for git-daemon...
|
||||
|
||||
@@ -90,3 +90,26 @@ func TestMakeRepoPrivateClearsWatches(t *testing.T) {
|
||||
assert.True(t, updatedRepo.IsPrivate)
|
||||
assert.Zero(t, updatedRepo.NumWatches)
|
||||
}
|
||||
|
||||
// TestUpdateRepositoryClearsWatchesOnVisibilityChange ensures the shared updateRepository
|
||||
// helper (used by the API EditRepo path) also clears watches when a repo goes private.
|
||||
func TestUpdateRepositoryClearsWatchesOnVisibilityChange(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.False(t, repo.IsPrivate)
|
||||
|
||||
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, watchers)
|
||||
|
||||
repo.IsPrivate = true
|
||||
require.NoError(t, updateRepository(t.Context(), repo, true))
|
||||
|
||||
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
|
||||
updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||
assert.Zero(t, updatedRepo.NumWatches)
|
||||
}
|
||||
|
||||
@@ -308,20 +308,14 @@ func webhookProxy(allowList *hostmatcher.HostMatchList) func(req *http.Request)
|
||||
// Init starts the hooks delivery thread
|
||||
func Init() error {
|
||||
timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second
|
||||
allowedHostMatcher := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Webhook.AllowedHostList)
|
||||
|
||||
allowedHostListValue := setting.Webhook.AllowedHostList
|
||||
if allowedHostListValue == "" {
|
||||
allowedHostListValue = hostmatcher.MatchBuiltinExternal
|
||||
}
|
||||
allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", allowedHostListValue)
|
||||
|
||||
// NewHTTPTransport enforces the allow-list on direct connections; when webhookProxy routes a request
|
||||
// through a configured proxy, restricting the proxied target is the proxy server's responsibility.
|
||||
webhookHTTPClient = &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify},
|
||||
Proxy: webhookProxy(allowedHostMatcher),
|
||||
DialContext: hostmatcher.NewDialContext("webhook", allowedHostMatcher, nil, setting.Webhook.ProxyURLFixed),
|
||||
},
|
||||
Transport: hostmatcher.NewHTTPTransport("webhook", allowedHostMatcher, nil, webhookProxy(allowedHostMatcher), setting.Webhook.ProxyURLFixed,
|
||||
&tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify}),
|
||||
}
|
||||
|
||||
hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
|
||||
|
||||
+10
-13
@@ -404,20 +404,17 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
|
||||
config["color"] = s.Color
|
||||
}
|
||||
|
||||
authorizationHeader, err := w.HeaderAuthorization()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &api.Hook{
|
||||
ID: w.ID,
|
||||
Name: w.Name,
|
||||
Type: w.Type,
|
||||
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
|
||||
Active: w.IsActive,
|
||||
Config: config,
|
||||
Events: w.EventsArray(),
|
||||
AuthorizationHeader: authorizationHeader,
|
||||
ID: w.ID,
|
||||
Name: w.Name,
|
||||
Type: w.Type,
|
||||
URL: fmt.Sprintf("%s/settings/hooks/%d", repoLink, w.ID),
|
||||
Active: w.IsActive,
|
||||
Config: config,
|
||||
Events: w.EventsArray(),
|
||||
// the stored authorization header is a secret and must never be returned by the API,
|
||||
// consistent with the webhook secret which is also omitted from the response
|
||||
AuthorizationHeader: "",
|
||||
Updated: w.UpdatedUnix.AsTime(),
|
||||
Created: w.CreatedUnix.AsTime(),
|
||||
BranchFilter: w.BranchFilter,
|
||||
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// for tests, allow only loopback IPs
|
||||
setting.Webhook.AllowedHostList = hostmatcher.MatchBuiltinLoopback
|
||||
unittest.MainTest(m, &unittest.TestOptions{
|
||||
SetUp: func() error {
|
||||
// for tests, allow only loopback IPs. This must run after the test config is loaded (which
|
||||
// resets the shared Security.AllowedHostList) and before Init() builds the delivery client.
|
||||
setting.Security.AllowedHostList = hostmatcher.MatchBuiltinLoopback
|
||||
setting.LoadQueueSettings()
|
||||
return Init()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user