diff --git a/CHANGELOG.md b/CHANGELOG.md index bf167d9..24597fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `gitlab` verifier type for GitLab webhook authentication via `X-Gitlab-Token` header +- `gitlab` predefined IP allowlist for GitLab.com webhook source IPs (`34.74.90.64/28`, `34.74.226.0/24`) + ## [0.2.6] - 2026-01-27 ### Added - Microsoft Graph subscription validation handling: automatically responds to `validationToken` query parameter on `json_field` verifier routes, enabling webhook setup without backend involvement (similar to Slack URL verification) diff --git a/agents/configure-route.md b/agents/configure-route.md index 8ac9445..59a38dc 100644 --- a/agents/configure-route.md +++ b/agents/configure-route.md @@ -17,6 +17,7 @@ Ask which webhook provider they want to configure. Offer these options: - **Slack** - Slack Events API, slash commands, interactive components - **GitHub** - Repository webhooks, organization webhooks +- **GitLab** - Repository/project webhooks, group webhooks - **Shopify** - Store webhooks (orders, products, customers) - **Google Calendar** - Calendar push notifications (X-Goog-Channel-Token header) - **Microsoft Graph** - Outlook Calendar, OneDrive change notifications (token in JSON body) @@ -66,6 +67,7 @@ For relay mode: This is the URL the relay client will forward to locally. Suggest provider-specific defaults: - Slack: `http://your-app:8080/webhooks/slack` or `/slack/events` - GitHub: `http://your-app:8080/webhooks/github` or `/github/events` +- GitLab: `http://your-app:8080/webhooks/gitlab` or `/gitlab/events` - Shopify: `http://your-app:8080/webhooks/shopify` - Google Calendar: `http://your-app:8080/webhooks/gcal` or `/calendar/notifications` - Microsoft Graph: `http://your-app:8080/webhooks/graph` or `/graph/notifications` @@ -202,6 +204,35 @@ ip_allowlists: refresh_interval: 24h ``` +#### GitLab + +1. Go to your GitLab project/group settings +2. Navigate to Settings > Webhooks +3. Click "Add new webhook" +4. Set the URL to: `https://{hostname}{path}` +5. Enter a secret token in the "Secret token" field +6. Select the events you want to trigger the webhook +7. Set the environment variable: `export GITLAB_WEBHOOK_TOKEN="your-secret-token"` + +Configuration uses: +```yaml +verifiers: + gitlab: + type: gitlab + token: "${GITLAB_WEBHOOK_TOKEN}" +``` + +Recommended IP allowlist (GitLab.com): +```yaml +ip_allowlists: + gitlab: + cidrs: + - "34.74.90.64/28" + - "34.74.226.0/24" +``` + +Note: Self-hosted GitLab instances will have different IP addresses. Check your instance's outbound IP or skip IP allowlisting. + #### Shopify 1. Go to your Shopify admin panel diff --git a/config/example.yaml b/config/example.yaml index 05fdbec..d4503d0 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -35,6 +35,12 @@ ip_allowlists: fetch_jq: ".prefixes[].ipv4Prefix" refresh_interval: 24h + # GitLab.com IP ranges (static) + gitlab: + cidrs: + - "34.74.90.64/28" + - "34.74.226.0/24" + # Static IP list for internal or known services internal-only: cidrs: @@ -64,6 +70,11 @@ verifiers: type: github secret: "${MARTINDALE_GITHUB_WEBHOOK_SECRET}" + # GitLab webhook verification (simple token in header) + example-gitlab: + type: gitlab + token: "${GITLAB_WEBHOOK_TOKEN}" + # Shared noop verifier for testing/development none: type: noop diff --git a/config/minikube-gatekeeperd.yaml b/config/minikube-gatekeeperd.yaml index e3b633b..dbe945b 100644 --- a/config/minikube-gatekeeperd.yaml +++ b/config/minikube-gatekeeperd.yaml @@ -41,6 +41,22 @@ ipAllowlists: verifiers: noop: type: noop + test-gitlab: + type: gitlab + tokenKey: GITLAB_WEBHOOK_TOKEN + test-github: + type: github + secretKey: GITHUB_WEBHOOK_SECRET + test-slack: + type: slack + signingSecretKey: SLACK_SIGNING_SECRET + maxTimestampAge: 5m + shopify-store-a: + type: shopify + secretKey: SHOPIFY_SECRET_A + shopify-store-b: + type: shopify + secretKey: SHOPIFY_SECRET_B # Routes for testing both direct forwarding and relay delivery routes: @@ -58,6 +74,60 @@ routes: verifier: noop relayTokenKey: RELAY_TOKEN + # Direct delivery + - hostname: test.local + path: /direct/gitlab + ipAllowlist: allow-all + verifier: test-gitlab + destination: https://httpbin.org/post + + - hostname: test.local + path: /direct/github + ipAllowlist: allow-all + verifier: test-github + destination: https://httpbin.org/post + + # Relay delivery + - hostname: test.local + path: /relay/gitlab + ipAllowlist: allow-all + verifier: test-gitlab + relayTokenKey: RELAY_TOKEN_GITLAB + + - hostname: test.local + path: /relay/github + ipAllowlist: allow-all + verifier: test-github + relayTokenKey: RELAY_TOKEN_GITHUB + + - hostname: test.local + path: /relay/slack + ipAllowlist: allow-all + verifier: test-slack + relayTokenKey: RELAY_TOKEN_SLACK + + - hostname: test.local + path: /relay/shopify/store-a + ipAllowlist: allow-all + verifier: shopify-store-a + relayTokenKey: RELAY_TOKEN_SHOPIFY_A + + - hostname: test.local + path: /relay/shopify/store-b + ipAllowlist: allow-all + verifier: shopify-store-b + relayTokenKey: RELAY_TOKEN_SHOPIFY_B + # Secrets for relay token (must match gatekeeper-relay config) secrets: RELAY_TOKEN: "minikube-test-relay-token" + GITLAB_WEBHOOK_TOKEN: "test-gitlab-secret" + GITHUB_WEBHOOK_SECRET: "test-github-secret" + SLACK_SIGNING_SECRET: "test-slack-secret" + SHOPIFY_SECRET_A: "test-shopify-secret-a" + SHOPIFY_SECRET_B: "test-shopify-secret-b" + RELAY_TOKEN_GITLAB: "relay-token-gitlab" + RELAY_TOKEN_GITHUB: "relay-token-github" + RELAY_TOKEN_SLACK: "relay-token-slack" + RELAY_TOKEN_SHOPIFY_A: "relay-token-shopify-a" + RELAY_TOKEN_SHOPIFY_B: "relay-token-shopify-b" diff --git a/config/minikube-relay.yaml b/config/minikube-relay.yaml index f1db8d9..6d8c5a8 100644 --- a/config/minikube-relay.yaml +++ b/config/minikube-relay.yaml @@ -26,6 +26,31 @@ channels: tokenKey: RELAY_TOKEN destination: https://httpbin.org/anything + - name: gitlab + tokenKey: RELAY_TOKEN_GITLAB + destination: https://httpbin.org/anything?source=gitlab + + - name: github + tokenKey: RELAY_TOKEN_GITHUB + destination: https://httpbin.org/anything?source=github + + - name: slack + tokenKey: RELAY_TOKEN_SLACK + destination: https://httpbin.org/anything?source=slack + + - name: shopify-store-a + tokenKey: RELAY_TOKEN_SHOPIFY_A + destination: https://httpbin.org/anything?source=shopify-a + + - name: shopify-store-b + tokenKey: RELAY_TOKEN_SHOPIFY_B + destination: https://httpbin.org/anything?source=shopify-b + # Secrets - must match the relay token in gatekeeperd config secrets: RELAY_TOKEN: "minikube-test-relay-token" + RELAY_TOKEN_GITLAB: "relay-token-gitlab" + RELAY_TOKEN_GITHUB: "relay-token-github" + RELAY_TOKEN_SLACK: "relay-token-slack" + RELAY_TOKEN_SHOPIFY_A: "relay-token-shopify-a" + RELAY_TOKEN_SHOPIFY_B: "relay-token-shopify-b" diff --git a/docs/PROVIDER_TODO.md b/docs/PROVIDER_TODO.md index 6d7024c..aaa7a9e 100644 --- a/docs/PROVIDER_TODO.md +++ b/docs/PROVIDER_TODO.md @@ -8,6 +8,7 @@ Webhook providers we want to support in the future. Contributions welcome. |----------|----------|---------------| | Slack | Communication | `slack` | | GitHub | DevOps | `github` | +| GitLab | DevOps | `gitlab` | | Shopify | E-commerce | `shopify` | | Google Calendar | Productivity | `api_key` | | Generic HMAC | Any | `hmac` | @@ -20,7 +21,6 @@ Well-documented APIs with straightforward signature schemes. | Provider | Category | Signature Method | Docs | |----------|----------|------------------|------| | Stripe | Payments | HMAC-SHA256 with timestamp | [link](https://stripe.com/docs/webhooks/signatures) | -| GitLab | DevOps | Token header or HMAC-SHA256 | [link](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html) | | Twilio | Communication | HMAC-SHA1 of URL + params | [link](https://www.twilio.com/docs/usage/webhooks/webhooks-security) | | SendGrid | Email | ECDSA signature | [link](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | | PagerDuty | Ops | HMAC-SHA256 | [link](https://developer.pagerduty.com/docs/webhooks/v3-overview/) | diff --git a/internal/config/config.go b/internal/config/config.go index 3e8e930..021fe38 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,7 +43,7 @@ type IPAllowlist struct { // VerifierConfig defines a webhook signature verifier type VerifierConfig struct { - Type string `yaml:"type"` // slack, github, shopify, api_key, hmac, json_field, query_param, header_query_param, noop + Type string `yaml:"type"` // slack, github, gitlab, shopify, api_key, hmac, json_field, query_param, header_query_param, noop // For slack verifier SigningSecret string `yaml:"signing_secret,omitempty"` @@ -235,6 +235,10 @@ func validateVerifier(name string, v VerifierConfig) error { if v.Secret == "" { return fmt.Errorf("verifier %q: secret is required for %s verifier", name, v.Type) } + case "gitlab": + if v.Token == "" { + return fmt.Errorf("verifier %q: token is required for gitlab verifier", name) + } case "api_key": return validateAPIKeyVerifier(name, v) case "hmac": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 378ef6c..87e0c1e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -271,6 +271,10 @@ func TestValidate_ValidConfig(t *testing.T) { "noop": { Type: "noop", }, + "gitlab": { + Type: "gitlab", + Token: "token", + }, }, Routes: []RouteConfig{ { @@ -958,3 +962,18 @@ func TestValidate_ValidHeaderQueryParamVerifier(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestValidate_GitLabVerifierRequiresToken(t *testing.T) { + cfg := &Config{ + Verifiers: map[string]VerifierConfig{ + "test": { + Type: "gitlab", + }, + }, + } + + err := cfg.Validate() + if err == nil { + t.Error("expected validation error for gitlab verifier without token") + } +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index 48aaf7a..5211889 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -122,6 +122,8 @@ func buildVerifier(vc config.VerifierConfig) (verifier.Verifier, error) { return verifier.NewSlackVerifier(vc.SigningSecret, vc.MaxTimestampAge), nil case "github": return verifier.NewGitHubVerifier(vc.Secret), nil + case "gitlab": + return verifier.NewGitLabVerifier(vc.Token), nil case "shopify": return verifier.NewShopifyVerifier(vc.Secret), nil case "api_key": diff --git a/internal/proxy/handler_test.go b/internal/proxy/handler_test.go index 1eb9563..148290c 100644 --- a/internal/proxy/handler_test.go +++ b/internal/proxy/handler_test.go @@ -666,6 +666,7 @@ func TestNewHandler_BuildVerifiers(t *testing.T) { "query_param": {Type: "query_param", Name: "token", Token: "secret"}, "header_query_param": {Type: "header_query_param", Header: "X-Goog-Channel-Token", Name: "secret", Token: "mytoken"}, "noop": {Type: "noop"}, + "gitlab": {Type: "gitlab", Token: "secret"}, }, } @@ -678,8 +679,8 @@ func TestNewHandler_BuildVerifiers(t *testing.T) { } // Verify all verifiers were created - if len(handler.verifiers) != 9 { - t.Errorf("expected 9 verifiers, got %d", len(handler.verifiers)) + if len(handler.verifiers) != 10 { + t.Errorf("expected 10 verifiers, got %d", len(handler.verifiers)) } } @@ -2284,6 +2285,7 @@ func TestHandler_VerifierTypesMap(t *testing.T) { "my-github": {Type: "github", Secret: "secret"}, "my-shopify": {Type: "shopify", Secret: "secret"}, "my-noop": {Type: "noop"}, + "my-gitlab": {Type: "gitlab", Token: "secret"}, }, } @@ -2308,6 +2310,9 @@ func TestHandler_VerifierTypesMap(t *testing.T) { if handler.verifierTypes["my-noop"] != "noop" { t.Errorf("expected verifierTypes['my-noop']='noop', got %q", handler.verifierTypes["my-noop"]) } + if handler.verifierTypes["my-gitlab"] != "gitlab" { + t.Errorf("expected verifierTypes['my-gitlab']='gitlab', got %q", handler.verifierTypes["my-gitlab"]) + } } func TestIsPrivateIP(t *testing.T) { diff --git a/internal/verifier/gitlab.go b/internal/verifier/gitlab.go new file mode 100644 index 0000000..17a9780 --- /dev/null +++ b/internal/verifier/gitlab.go @@ -0,0 +1,40 @@ +package verifier + +import ( + "crypto/subtle" + "fmt" + "net/http" +) + +// GitLabVerifier verifies requests using the X-Gitlab-Token header. +// GitLab webhooks use simple token comparison (not HMAC). +type GitLabVerifier struct { + token string +} + +// NewGitLabVerifier creates a new GitLab webhook verifier +func NewGitLabVerifier(token string) *GitLabVerifier { + return &GitLabVerifier{ + token: token, + } +} + +// Verify checks that the X-Gitlab-Token header matches the expected token +func (v *GitLabVerifier) Verify(r *http.Request, _ []byte) error { + value := r.Header.Get("X-Gitlab-Token") + if value == "" { + return fmt.Errorf("%w: X-Gitlab-Token header missing", ErrSignatureEmpty) + } + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(value), []byte(v.token)) != 1 { + return ErrTokenMismatch + } + + return nil +} + +// Type returns the verifier type +func (v *GitLabVerifier) Type() string { + return "gitlab" +} diff --git a/internal/verifier/gitlab_test.go b/internal/verifier/gitlab_test.go new file mode 100644 index 0000000..bd8ba17 --- /dev/null +++ b/internal/verifier/gitlab_test.go @@ -0,0 +1,65 @@ +package verifier + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGitLabVerifier_Verify(t *testing.T) { + token := "my-gitlab-secret-token" + verifier := NewGitLabVerifier(token) + + tests := []verifierTestCase{ + { + name: "valid token", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event_type":"push"}`) + req := httptest.NewRequest(http.MethodPost, "/gitlab/webhook", strings.NewReader(string(body))) + req.Header.Set("X-Gitlab-Token", token) + return req, body + }, + wantErr: false, + }, + { + name: "missing header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event_type":"push"}`) + req := httptest.NewRequest(http.MethodPost, "/gitlab/webhook", strings.NewReader(string(body))) + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "wrong token", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event_type":"push"}`) + req := httptest.NewRequest(http.MethodPost, "/gitlab/webhook", strings.NewReader(string(body))) + req.Header.Set("X-Gitlab-Token", "wrong-token") + return req, body + }, + wantErr: true, + errString: "token does not match", + }, + { + name: "empty token in header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event_type":"push"}`) + req := httptest.NewRequest(http.MethodPost, "/gitlab/webhook", strings.NewReader(string(body))) + req.Header.Set("X-Gitlab-Token", "") + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + } + + runVerifierTests(t, verifier, tests) +} + +func TestGitLabVerifier_Type(t *testing.T) { + v := NewGitLabVerifier("secret") + assertVerifierType(t, v, "gitlab") +}