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 17:14:09 +00:00
committed by GitHub
co-authored by TheFox0x7 techknowlogick Lunny Xiao wxiaoguang Zettat123
parent d2bd1589fe
commit f69e15afe7
93 changed files with 1714 additions and 137 deletions
+15 -6
View File
@@ -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
}
+40
View File
@@ -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})
}
+4
View File
@@ -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
}
+4 -1
View File
@@ -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) {
+3 -2
View File
@@ -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() {
+35 -1
View File
@@ -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
}
+7 -4
View File
@@ -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
}
+2 -2
View File
@@ -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
+7 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+21 -8
View File
@@ -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})
}
+4
View File
@@ -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
}
+9
View File
@@ -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
+5
View File
@@ -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...
+19
View File
@@ -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})
})
}
+18 -1
View File
@@ -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
})
})
+5
View File
@@ -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...
+23
View File
@@ -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)
}
+5 -11
View File
@@ -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
View File
@@ -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,
+3 -2
View File
@@ -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()
},