Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions agents/configure-route.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions config/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions config/minikube-gatekeeperd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
25 changes: 25 additions & 0 deletions config/minikube-relay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion docs/PROVIDER_TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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/) |
Expand Down
6 changes: 5 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
40 changes: 40 additions & 0 deletions internal/verifier/gitlab.go
Original file line number Diff line number Diff line change
@@ -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"
}
65 changes: 65 additions & 0 deletions internal/verifier/gitlab_test.go
Original file line number Diff line number Diff line change
@@ -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")
}