diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5d425..c6c46b9 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.4] - 2026-01-23 ### Added diff --git a/agents/configure-route.md b/agents/configure-route.md index b7007c2..e80d469 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/proxy/handler.go b/internal/proxy/handler.go index c3571d6..a668dc4 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -116,6 +116,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/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") +}