diff --git a/util/http/http.go b/util/http/http.go
index 42981d62867fa..2572e739f009d 100644
--- a/util/http/http.go
+++ b/util/http/http.go
@@ -18,8 +18,8 @@ import (
const maxCookieLength = 4093
// max number of chunks a cookie can be broken into. To be compatible with
-// widest range of browsers, we shouldn't create more than 30 cookies per domain
-var maxCookieNumber = env.ParseNumFromEnv(common.EnvMaxCookieNumber, 10, 0, 30)
+// widest range of browsers, you shouldn't create more than 30 cookies per domain
+var maxCookieNumber = env.ParseNumFromEnv(common.EnvMaxCookieNumber, 20, 0, math.MaxInt64)
// MakeCookieMetadata generates a string representing a Web cookie. Yum!
func MakeCookieMetadata(key, value string, flags ...string) ([]string, error) {
diff --git a/util/http/http_test.go b/util/http/http_test.go
index cb37f74b39716..9655c5b42c249 100644
--- a/util/http/http_test.go
+++ b/util/http/http_test.go
@@ -15,10 +15,18 @@ func TestCookieMaxLength(t *testing.T) {
// keys will be of format foo, foo-1, foo-2 ..
cookies, err = MakeCookieMetadata("foo", strings.Repeat("_", (maxCookieLength-5)*maxCookieNumber))
- assert.EqualError(t, err, "the authentication token is 40880 characters long and requires 11 cookies but the max number of cookies is 10. Contact your Argo CD administrator to increase the max number of cookies")
+ assert.EqualError(t, err, "the authentication token is 81760 characters long and requires 21 cookies but the max number of cookies is 20. Contact your Argo CD administrator to increase the max number of cookies")
assert.Equal(t, 0, len(cookies))
}
+func TestCookieWithAttributes(t *testing.T) {
+ flags := []string{"SameSite=lax", "httpOnly"}
+
+ cookies, err := MakeCookieMetadata("foo", "bar", flags...)
+ assert.NoError(t, err)
+ assert.Equal(t, "foo=bar; SameSite=lax; httpOnly", cookies[0])
+}
+
func TestSplitCookie(t *testing.T) {
cookieValue := strings.Repeat("_", (maxCookieLength-6)*4)
cookies, err := MakeCookieMetadata("foo", cookieValue)
diff --git a/util/settings/settings.go b/util/settings/settings.go
index a9d49b78cd5df..06fd0488ad711 100644
--- a/util/settings/settings.go
+++ b/util/settings/settings.go
@@ -71,6 +71,10 @@ type ArgoCDSettings struct {
WebhookBitbucketServerSecret string `json:"webhookBitbucketServerSecret,omitempty"`
// WebhookGogsSecret holds the shared secret for authenticating Gogs webhook events
WebhookGogsSecret string `json:"webhookGogsSecret,omitempty"`
+ // WebhookAzureDevOpsUsername holds the username for authenticating Azure DevOps webhook events
+ WebhookAzureDevOpsUsername string `json:"webhookAzureDevOpsUsername,omitempty"`
+ // WebhookAzureDevOpsPassword holds the password for authenticating Azure DevOps webhook events
+ WebhookAzureDevOpsPassword string `json:"webhookAzureDevOpsPassword,omitempty"`
// Secrets holds all secrets in argocd-secret as a map[string]string
Secrets map[string]string `json:"secrets,omitempty"`
// KustomizeBuildOptions is a string of kustomize build parameters
@@ -411,6 +415,10 @@ const (
settingsWebhookBitbucketServerSecretKey = "webhook.bitbucketserver.secret"
// settingsWebhookGogsSecret is the key for Gogs webhook secret
settingsWebhookGogsSecretKey = "webhook.gogs.secret"
+ // settingsWebhookAzureDevOpsUsernameKey is the key for Azure DevOps webhook username
+ settingsWebhookAzureDevOpsUsernameKey = "webhook.azuredevops.username"
+ // settingsWebhookAzureDevOpsPasswordKey is the key for Azure DevOps webhook password
+ settingsWebhookAzureDevOpsPasswordKey = "webhook.azuredevops.password"
// settingsApplicationInstanceLabelKey is the key to configure injected app instance label key
settingsApplicationInstanceLabelKey = "application.instanceLabelKey"
// settingsResourceTrackingMethodKey is the key to configure tracking method for application resources
@@ -1457,6 +1465,12 @@ func (mgr *SettingsManager) updateSettingsFromSecret(settings *ArgoCDSettings, a
if gogsWebhookSecret := argoCDSecret.Data[settingsWebhookGogsSecretKey]; len(gogsWebhookSecret) > 0 {
settings.WebhookGogsSecret = string(gogsWebhookSecret)
}
+ if azureDevOpsUsername := argoCDSecret.Data[settingsWebhookAzureDevOpsUsernameKey]; len(azureDevOpsUsername) > 0 {
+ settings.WebhookAzureDevOpsUsername = string(azureDevOpsUsername)
+ }
+ if azureDevOpsPassword := argoCDSecret.Data[settingsWebhookAzureDevOpsPasswordKey]; len(azureDevOpsPassword) > 0 {
+ settings.WebhookAzureDevOpsPassword = string(azureDevOpsPassword)
+ }
// The TLS certificate may be externally managed. We try to load it from an
// external secret first. If the external secret doesn't exist, we either
@@ -1576,6 +1590,12 @@ func (mgr *SettingsManager) SaveSettings(settings *ArgoCDSettings) error {
if settings.WebhookGogsSecret != "" {
argoCDSecret.Data[settingsWebhookGogsSecretKey] = []byte(settings.WebhookGogsSecret)
}
+ if settings.WebhookAzureDevOpsUsername != "" {
+ argoCDSecret.Data[settingsWebhookAzureDevOpsUsernameKey] = []byte(settings.WebhookAzureDevOpsUsername)
+ }
+ if settings.WebhookAzureDevOpsPassword != "" {
+ argoCDSecret.Data[settingsWebhookAzureDevOpsPasswordKey] = []byte(settings.WebhookAzureDevOpsPassword)
+ }
// we only write the certificate to the secret if it's not externally
// managed.
if settings.Certificate != nil && !settings.CertificateIsExternal {
diff --git a/util/webhook/testdata/azuredevops-git-push-event.json b/util/webhook/testdata/azuredevops-git-push-event.json
new file mode 100644
index 0000000000000..102e7f08aab3d
--- /dev/null
+++ b/util/webhook/testdata/azuredevops-git-push-event.json
@@ -0,0 +1,107 @@
+{
+ "subscriptionId": "8fd412f1-9873-4b45-8854-655b1b8a2eff",
+ "notificationId": 2,
+ "id": "09b0b950-47fa-4f45-8b65-5a22686314f8",
+ "eventType": "git.push",
+ "publisherId": "tfs",
+ "message": {
+ "text": "Alexander Matyushentsev pushed updates to alex-test:master\r\n(https://dev.azure.com/alexander0053/alex-test/_git/alex-test/#version=GBmaster)",
+ "html": "Alexander Matyushentsev pushed updates to
alex-test:
master",
+ "markdown": "Alexander Matyushentsev pushed updates to [alex-test](https://dev.azure.com/alexander0053/alex-test/_git/alex-test/):[master](https://dev.azure.com/alexander0053/alex-test/_git/alex-test/#version=GBmaster)"
+ },
+ "detailedMessage": {
+ "text": "Alexander Matyushentsev pushed a commit to alex-test:master\r\n - draft 298a79aa (https://dev.azure.com/alexander0053/alex-test/_git/alex-test/commit/298a79aa1552799a70718a0ee914d153d5a1a76b)",
+ "html": "Alexander Matyushentsev pushed a commit to
alex-test:
master\r\n
",
+ "markdown": "Alexander Matyushentsev pushed a commit to [alex-test](https://dev.azure.com/alexander0053/alex-test/_git/alex-test/):[master](https://dev.azure.com/alexander0053/alex-test/_git/alex-test/#version=GBmaster)\r\n* draft [298a79aa](https://dev.azure.com/alexander0053/alex-test/_git/alex-test/commit/298a79aa1552799a70718a0ee914d153d5a1a76b)"
+ },
+ "resource": {
+ "commits": [
+ {
+ "commitId": "298a79aa1552799a70718a0ee914d153d5a1a76b",
+ "author": {
+ "name": "Alexander Matyushentsev",
+ "email": "AMatyushentsev@gmail.com",
+ "date": "2023-08-09T00:45:39Z"
+ },
+ "committer": {
+ "name": "Alexander Matyushentsev",
+ "email": "AMatyushentsev@gmail.com",
+ "date": "2023-08-09T00:45:39Z"
+ },
+ "comment": "draft\n\nSigned-off-by: Alexander Matyushentsev
",
+ "url": "https://dev.azure.com/alexander0053/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6/commits/298a79aa1552799a70718a0ee914d153d5a1a76b"
+ }
+ ],
+ "refUpdates": [
+ {
+ "name": "refs/heads/master",
+ "oldObjectId": "fa51eeb1e50b98293ce281e6d5492b9decae613b",
+ "newObjectId": "298a79aa1552799a70718a0ee914d153d5a1a76b"
+ }
+ ],
+ "repository": {
+ "id": "ba2967cc-02c2-414c-8d10-1b99197cbaa6",
+ "name": "alex-test",
+ "url": "https://dev.azure.com/alexander0053/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6",
+ "project": {
+ "id": "ab1c194f-94fa-4d1a-87ff-e9458637d060",
+ "name": "alex-test",
+ "url": "https://dev.azure.com/alexander0053/_apis/projects/ab1c194f-94fa-4d1a-87ff-e9458637d060",
+ "state": "wellFormed",
+ "visibility": "unchanged",
+ "lastUpdateTime": "0001-01-01T00:00:00"
+ },
+ "defaultBranch": "refs/heads/master",
+ "remoteUrl": "https://dev.azure.com/alexander0053/alex-test/_git/alex-test"
+ },
+ "pushedBy": {
+ "displayName": "Alexander Matyushentsev",
+ "url": "https://spsprodcus4.vssps.visualstudio.com/A7a73fd0c-d080-434d-a8b4-0b4c0217e290/_apis/Identities/07220d5e-521c-683d-982c-726e80086d08",
+ "_links": {
+ "avatar": {
+ "href": "https://dev.azure.com/alexander0053/_apis/GraphProfile/MemberAvatars/aad.MDcyMjBkNWUtNTIxYy03ODNkLTk4MmMtNzI2ZTgwMDg2ZDA4"
+ }
+ },
+ "id": "07220d5e-521c-683d-982c-726e80086d08",
+ "uniqueName": "alexander@akuity.onmicrosoft.com",
+ "imageUrl": "https://dev.azure.com/alexander0053/_api/_common/identityImage?id=07220d5e-521c-683d-982c-726e80086d08",
+ "descriptor": "aad.MDcyMjBkNWUtNTIxYy03ODNkLTk4MmMtNzI2ZTgwMDg2ZDA4"
+ },
+ "pushId": 4,
+ "date": "2023-08-09T00:45:42.8315767Z",
+ "url": "https://dev.azure.com/alexander0053/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6/pushes/4",
+ "_links": {
+ "self": {
+ "href": "https://dev.azure.com/alexander0053/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6/pushes/4"
+ },
+ "repository": {
+ "href": "https://dev.azure.com/alexander0053/ab1c194f-94fa-4d1a-87ff-e9458637d060/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6"
+ },
+ "commits": {
+ "href": "https://dev.azure.com/alexander0053/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6/pushes/4/commits"
+ },
+ "pusher": {
+ "href": "https://spsprodcus4.vssps.visualstudio.com/A7a73fd0c-d080-434d-a8b4-0b4c0217e290/_apis/Identities/07220d5e-521c-683d-982c-726e80086d08"
+ },
+ "refs": {
+ "href": "https://dev.azure.com/alexander0053/ab1c194f-94fa-4d1a-87ff-e9458637d060/_apis/git/repositories/ba2967cc-02c2-414c-8d10-1b99197cbaa6/refs/heads/master"
+ }
+ }
+ },
+ "resourceVersion": "1.0",
+ "resourceContainers": {
+ "collection": {
+ "id": "d54a3f95-82a0-47c4-8444-00da7391d976",
+ "baseUrl": "https://dev.azure.com/alexander0053/"
+ },
+ "account": {
+ "id": "7a73fd0c-d080-434d-a8b4-0b4c0217e290",
+ "baseUrl": "https://dev.azure.com/alexander0053/"
+ },
+ "project": {
+ "id": "ab1c194f-94fa-4d1a-87ff-e9458637d060",
+ "baseUrl": "https://dev.azure.com/alexander0053/"
+ }
+ },
+ "createdDate": "2023-08-09T00:45:49.3448928Z"
+}
\ No newline at end of file
diff --git a/util/webhook/webhook.go b/util/webhook/webhook.go
index ca4742e31a1f1..9955540ea04a9 100644
--- a/util/webhook/webhook.go
+++ b/util/webhook/webhook.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
- "github.com/argoproj/argo-cd/v2/util/glob"
"html"
"net/http"
"net/url"
@@ -12,13 +11,14 @@ import (
"regexp"
"strings"
+ "github.com/go-playground/webhooks/v6/azuredevops"
+ "github.com/go-playground/webhooks/v6/bitbucket"
+ bitbucketserver "github.com/go-playground/webhooks/v6/bitbucket-server"
+ "github.com/go-playground/webhooks/v6/github"
+ "github.com/go-playground/webhooks/v6/gitlab"
+ "github.com/go-playground/webhooks/v6/gogs"
gogsclient "github.com/gogits/go-gogs-client"
log "github.com/sirupsen/logrus"
- "gopkg.in/go-playground/webhooks.v5/bitbucket"
- bitbucketserver "gopkg.in/go-playground/webhooks.v5/bitbucket-server"
- "gopkg.in/go-playground/webhooks.v5/github"
- "gopkg.in/go-playground/webhooks.v5/gitlab"
- "gopkg.in/go-playground/webhooks.v5/gogs"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/argoproj/argo-cd/v2/common"
@@ -28,6 +28,7 @@ import (
servercache "github.com/argoproj/argo-cd/v2/server/cache"
"github.com/argoproj/argo-cd/v2/util/argo"
"github.com/argoproj/argo-cd/v2/util/db"
+ "github.com/argoproj/argo-cd/v2/util/glob"
"github.com/argoproj/argo-cd/v2/util/security"
"github.com/argoproj/argo-cd/v2/util/settings"
)
@@ -41,21 +42,26 @@ type settingsSource interface {
// https://github.com/shadow-maint/shadow/blob/master/libmisc/chkname.c#L36
const usernameRegex = `[a-zA-Z0-9_\.][a-zA-Z0-9_\.-]{0,30}[a-zA-Z0-9_\.\$-]?`
-var _ settingsSource = &settings.SettingsManager{}
+var (
+ _ settingsSource = &settings.SettingsManager{}
+ errBasicAuthVerificationFailed = errors.New("basic auth verification failed")
+)
type ArgoCDWebhookHandler struct {
- repoCache *cache.Cache
- serverCache *servercache.Cache
- db db.ArgoDB
- ns string
- appNs []string
- appClientset appclientset.Interface
- github *github.Webhook
- gitlab *gitlab.Webhook
- bitbucket *bitbucket.Webhook
- bitbucketserver *bitbucketserver.Webhook
- gogs *gogs.Webhook
- settingsSrc settingsSource
+ repoCache *cache.Cache
+ serverCache *servercache.Cache
+ db db.ArgoDB
+ ns string
+ appNs []string
+ appClientset appclientset.Interface
+ github *github.Webhook
+ gitlab *gitlab.Webhook
+ bitbucket *bitbucket.Webhook
+ bitbucketserver *bitbucketserver.Webhook
+ azuredevops *azuredevops.Webhook
+ azuredevopsAuthHandler func(r *http.Request) error
+ gogs *gogs.Webhook
+ settingsSrc settingsSource
}
func NewHandler(namespace string, applicationNamespaces []string, appClientset appclientset.Interface, set *settings.ArgoCDSettings, settingsSrc settingsSource, repoCache *cache.Cache, serverCache *servercache.Cache, argoDB db.ArgoDB) *ArgoCDWebhookHandler {
@@ -79,20 +85,35 @@ func NewHandler(namespace string, applicationNamespaces []string, appClientset a
if err != nil {
log.Warnf("Unable to init the Gogs webhook")
}
+ azuredevopsWebhook, err := azuredevops.New()
+ if err != nil {
+ log.Warnf("Unable to init the Azure DevOps webhook")
+ }
+ azuredevopsAuthHandler := func(r *http.Request) error {
+ if set.WebhookAzureDevOpsUsername != "" && set.WebhookAzureDevOpsPassword != "" {
+ username, password, ok := r.BasicAuth()
+ if !ok || username != set.WebhookAzureDevOpsUsername || password != set.WebhookAzureDevOpsPassword {
+ return errBasicAuthVerificationFailed
+ }
+ }
+ return nil
+ }
acdWebhook := ArgoCDWebhookHandler{
- ns: namespace,
- appNs: applicationNamespaces,
- appClientset: appClientset,
- github: githubWebhook,
- gitlab: gitlabWebhook,
- bitbucket: bitbucketWebhook,
- bitbucketserver: bitbucketserverWebhook,
- gogs: gogsWebhook,
- settingsSrc: settingsSrc,
- repoCache: repoCache,
- serverCache: serverCache,
- db: argoDB,
+ ns: namespace,
+ appNs: applicationNamespaces,
+ appClientset: appClientset,
+ github: githubWebhook,
+ gitlab: gitlabWebhook,
+ bitbucket: bitbucketWebhook,
+ bitbucketserver: bitbucketserverWebhook,
+ azuredevops: azuredevopsWebhook,
+ azuredevopsAuthHandler: azuredevopsAuthHandler,
+ gogs: gogsWebhook,
+ settingsSrc: settingsSrc,
+ repoCache: repoCache,
+ serverCache: serverCache,
+ db: argoDB,
}
return &acdWebhook
@@ -107,6 +128,14 @@ func parseRevision(ref string) string {
// the revision, and whether or not this affected origin/HEAD (the default branch of the repository)
func affectedRevisionInfo(payloadIf interface{}) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) {
switch payload := payloadIf.(type) {
+ case azuredevops.GitPushEvent:
+ // See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push
+ webURLs = append(webURLs, payload.Resource.Repository.RemoteURL)
+ revision = parseRevision(payload.Resource.RefUpdates[0].Name)
+ change.shaAfter = parseRevision(payload.Resource.RefUpdates[0].NewObjectID)
+ change.shaBefore = parseRevision(payload.Resource.RefUpdates[0].OldObjectID)
+ touchedHead = payload.Resource.RefUpdates[0].Name == payload.Resource.Repository.DefaultBranch
+ // unfortunately, Azure DevOps doesn't provide a list of changed files
case github.PushPayload:
// See: https://developer.github.com/v3/activity/events/types/#pushevent
webURLs = append(webURLs, payload.Repository.HTMLURL)
@@ -430,6 +459,14 @@ func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) {
var err error
switch {
+ case r.Header.Get("X-Vss-Activityid") != "":
+ if err = a.azuredevopsAuthHandler(r); err != nil {
+ if errors.Is(err, errBasicAuthVerificationFailed) {
+ log.WithField(common.SecurityField, common.SecurityHigh).Infof("Azure DevOps webhook basic auth verification failed")
+ }
+ } else {
+ payload, err = a.azuredevops.Parse(r, azuredevops.GitPushEventType)
+ }
//Gogs needs to be checked before GitHub since it carries both Gogs and (incompatible) GitHub headers
case r.Header.Get("X-Gogs-Event") != "":
payload, err = a.gogs.Parse(r, gogs.PushEvent)
diff --git a/util/webhook/webhook_test.go b/util/webhook/webhook_test.go
index cf11162febc6c..b241d7c671841 100644
--- a/util/webhook/webhook_test.go
+++ b/util/webhook/webhook_test.go
@@ -5,18 +5,19 @@ import (
"encoding/json"
"fmt"
"io"
- "k8s.io/apimachinery/pkg/types"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
+ "k8s.io/apimachinery/pkg/types"
+
+ "github.com/go-playground/webhooks/v6/bitbucket"
+ bitbucketserver "github.com/go-playground/webhooks/v6/bitbucket-server"
+ "github.com/go-playground/webhooks/v6/github"
+ "github.com/go-playground/webhooks/v6/gitlab"
gogsclient "github.com/gogits/go-gogs-client"
- "gopkg.in/go-playground/webhooks.v5/bitbucket"
- bitbucketserver "gopkg.in/go-playground/webhooks.v5/bitbucket-server"
- "gopkg.in/go-playground/webhooks.v5/github"
- "gopkg.in/go-playground/webhooks.v5/gitlab"
"k8s.io/apimachinery/pkg/runtime"
kubetesting "k8s.io/client-go/testing"
@@ -89,6 +90,22 @@ func TestGitHubCommitEvent(t *testing.T) {
hook.Reset()
}
+func TestAzureDevOpsCommitEvent(t *testing.T) {
+ hook := test.NewGlobal()
+ h := NewMockHandler(nil, []string{})
+ req := httptest.NewRequest(http.MethodPost, "/api/webhook", nil)
+ req.Header.Set("X-Vss-Activityid", "abc")
+ eventJSON, err := os.ReadFile("testdata/azuredevops-git-push-event.json")
+ assert.NoError(t, err)
+ req.Body = io.NopCloser(bytes.NewReader(eventJSON))
+ w := httptest.NewRecorder()
+ h.Handler(w, req)
+ assert.Equal(t, w.Code, http.StatusOK)
+ expectedLogResult := "Received push event repo: https://dev.azure.com/alexander0053/alex-test/_git/alex-test, revision: master, touchedHead: true"
+ assert.Equal(t, expectedLogResult, hook.LastEntry().Message)
+ hook.Reset()
+}
+
// TestGitHubCommitEvent_MultiSource_Refresh makes sure that a webhook will refresh a multi-source app when at least
// one source matches.
func TestGitHubCommitEvent_MultiSource_Refresh(t *testing.T) {