Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/pr-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
build-args: |
VERSION=${{ steps.meta.outputs.TAG }}
tags: ${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:${{ steps.meta.outputs.TAG }}
platforms: linux/amd64
cache-from: type=gha
Expand All @@ -55,6 +57,8 @@ jobs:
context: .
file: ./Dockerfile.relay
push: true
build-args: |
VERSION=${{ steps.meta.outputs.TAG }}
tags: ${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:${{ steps.meta.outputs.TAG }}
platforms: linux/amd64
cache-from: type=gha
Expand All @@ -67,6 +71,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
build-args: |
VERSION=${{ steps.meta.outputs.TAG }}
tags: ${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:${{ steps.meta.outputs.TAG }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
Expand All @@ -78,6 +84,8 @@ jobs:
context: .
file: ./Dockerfile.relay
push: true
build-args: |
VERSION=${{ steps.meta.outputs.TAG }}
tags: ${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:${{ steps.meta.outputs.TAG }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
build-args: |
VERSION=${{ steps.meta.outputs.VERSION }}
tags: |
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:${{ steps.meta.outputs.VERSION }}
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:latest
Expand All @@ -52,6 +54,8 @@ jobs:
context: .
file: ./Dockerfile.relay
push: true
build-args: |
VERSION=${{ steps.meta.outputs.VERSION }}
tags: |
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:${{ steps.meta.outputs.VERSION }}
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:latest
Expand All @@ -64,6 +68,8 @@ jobs:
context: .
file: ./Dockerfile
push: true
build-args: |
VERSION=${{ steps.meta.outputs.VERSION }}
tags: |
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:${{ steps.meta.outputs.VERSION }}
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeperd:latest
Expand All @@ -75,6 +81,8 @@ jobs:
context: .
file: ./Dockerfile.relay
push: true
build-args: |
VERSION=${{ steps.meta.outputs.VERSION }}
tags: |
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:${{ steps.meta.outputs.VERSION }}
${{ steps.meta.outputs.IMAGE_PREFIX }}/gatekeeper-relay:latest
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `sendgrid` verifier type for SendGrid Event Webhook authentication. Verifies the ECDSA P-256 signature in `X-Twilio-Email-Event-Webhook-Signature` over `timestamp + payload`, with optional `max_timestamp_age` replay protection. The public key may be supplied as PEM or as the base64-encoded DER shown in the SendGrid UI.

## [0.2.11] - 2026-04-30

### Added
Expand Down
10 changes: 8 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ RUN go mod download
COPY cmd/ cmd/
COPY internal/ internal/

# Build binary
RUN CGO_ENABLED=0 GOOS=linux go build -o gatekeeperd ./cmd/gatekeeperd
# Build binary. VERSION is overridden by the release / pr-images workflows
# with the git tag (or pr-N-<sha> identifier) so the running binary reports
# its own version at startup.
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o gatekeeperd ./cmd/gatekeeperd

# Runtime stage
FROM alpine:3.23.3
Expand Down
10 changes: 8 additions & 2 deletions Dockerfile.relay
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ RUN go mod download
COPY cmd/ cmd/
COPY internal/ internal/

# Build binary
RUN CGO_ENABLED=0 GOOS=linux go build -o gatekeeper-relay ./cmd/gatekeeper-relay
# Build binary. VERSION is overridden by the release / pr-images workflows
# with the git tag (or pr-N-<sha> identifier) so the running binary reports
# its own version at startup.
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o gatekeeper-relay ./cmd/gatekeeper-relay

# Runtime stage
FROM alpine:3.23.3
Expand Down
20 changes: 20 additions & 0 deletions agents/configure-route.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,26 @@ verifiers:
secret: "${SHOPIFY_WEBHOOK_SECRET}"
```

#### SendGrid (Event Webhook / Inbound Parse)

SendGrid signs Event Webhook deliveries with ECDSA P-256. The signed content is `timestamp + raw_body`, the signature header carries the base64-encoded ASN.1 DER signature, and the timestamp travels in a sibling header.

1. In the SendGrid app, enable signature verification under **Settings -> Mail Settings -> Event Webhook -> Signed Event Webhook Requests**
2. Copy the displayed public verification key (base64-encoded DER) or convert it to PEM
3. Set the environment variable: `export SENDGRID_WEBHOOK_PUBLIC_KEY="MFkwEwYHKoZIzj0CAQYI..."`
4. Point the Event Webhook URL at: `https://{hostname}{path}`

Configuration uses:
```yaml
verifiers:
sendgrid:
type: sendgrid
public_key: "${SENDGRID_WEBHOOK_PUBLIC_KEY}"
max_timestamp_age: 5m # optional replay-protection window; omit/0 disables
```

IP allowlist: SendGrid does **not** publish stable IP ranges for Event Webhook or Inbound Parse traffic, so omit `ip_allowlist` and rely on signature verification.

#### Google Calendar

1. Use the Google Calendar API to create a watch request
Expand Down
5 changes: 5 additions & 0 deletions charts/gatekeeperd/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ data:
secret: ${{ "{" }}{{ $verifier.secretKey }}{{ "}" }}
{{- else if eq $verifier.type "shopify" }}
secret: ${{ "{" }}{{ $verifier.secretKey }}{{ "}" }}
{{- else if eq $verifier.type "sendgrid" }}
public_key: ${{ "{" }}{{ $verifier.publicKeyKey }}{{ "}" }}
{{- if $verifier.maxTimestampAge }}
max_timestamp_age: {{ $verifier.maxTimestampAge }}
{{- end }}
{{- else if eq $verifier.type "api_key" }}
header: {{ $verifier.header | quote }}
token: ${{ "{" }}{{ $verifier.tokenKey }}{{ "}" }}
Expand Down
4 changes: 3 additions & 1 deletion cmd/gatekeeper-relay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import (
"github.com/tight-line/gatekeeper/internal/relayclient"
)

var version = "0.1.0"
// version is set at link time via -ldflags "-X main.version=...".
// See Dockerfile.relay (ARG VERSION) and the release / pr-images workflows.
var version = "dev"

func main() {
if err := run(); err != nil {
Expand Down
4 changes: 3 additions & 1 deletion cmd/gatekeeperd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import (
"github.com/tight-line/gatekeeper/internal/server"
)

var version = "0.1.0"
// version is set at link time via -ldflags "-X main.version=...".
// See Dockerfile (ARG VERSION) and the release / pr-images workflows.
var version = "dev"

func main() {
if err := run(); err != nil {
Expand Down
10 changes: 10 additions & 0 deletions config/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ verifiers:
type: gitlab
token: "${GITLAB_WEBHOOK_TOKEN}"

# SendGrid Event Webhook verification (ECDSA P-256 signature).
# public_key may be PEM or the base64-encoded DER shown in the SendGrid UI.
# max_timestamp_age enables replay protection; omit or set to 0 to disable.
# SendGrid does not publish stable IP ranges, so routes typically have no
# ip_allowlist and rely on signature verification.
example-sendgrid:
type: sendgrid
public_key: "${SENDGRID_WEBHOOK_PUBLIC_KEY}"
max_timestamp_age: 5m

# OIDC JWT bearer token verification
# Supports any OIDC-compliant provider (Google, Azure AD, etc.)
# The jwks_uri is optional: if omitted, it is auto-discovered from the issuer's
Expand Down
2 changes: 1 addition & 1 deletion docs/PROVIDER_TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Webhook providers we want to support in the future. Contributions welcome.
| Shopify | E-commerce | `shopify` |
| Google Calendar | Productivity | `api_key` |
| Google Chat | Communication | `oidc` |
| SendGrid | Email | `sendgrid` |
| Generic HMAC | Any | `hmac` |
| Generic API Key | Any | `api_key` |

Expand All @@ -23,7 +24,6 @@ Well-documented APIs with straightforward signature schemes.
|----------|----------|------------------|------|
| Stripe | Payments | HMAC-SHA256 with timestamp | [link](https://stripe.com/docs/webhooks/signatures) |
| 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/) |
| Linear | Project Mgmt | HMAC-SHA256 | [link](https://developers.linear.app/docs/graphql/webhooks) |
| Discord | Communication | Ed25519 signature | [link](https://discord.com/developers/docs/interactions/receiving-and-responding) |
Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.2
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.53.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
Expand All @@ -26,8 +26,8 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
Expand Down
11 changes: 9 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ type RateLimiterConfig struct {

// VerifierConfig defines a webhook signature verifier
type VerifierConfig struct {
Type string `yaml:"type"` // slack, github, gitlab, shopify, api_key, hmac, json_field, query_param, header_query_param, oidc, noop
Type string `yaml:"type"` // slack, github, gitlab, shopify, sendgrid, api_key, hmac, json_field, query_param, header_query_param, oidc, noop

// For slack verifier
// For slack and sendgrid verifiers
SigningSecret string `yaml:"signing_secret,omitempty"`
MaxTimestampAge time.Duration `yaml:"max_timestamp_age,omitempty"`

// For sendgrid verifier (PEM-encoded or base64-encoded DER ECDSA P-256 public key)
PublicKey string `yaml:"public_key,omitempty"`

// For api_key verifier
Header string `yaml:"header,omitempty"`
Token string `yaml:"token,omitempty"`
Expand Down Expand Up @@ -265,6 +268,10 @@ func validateVerifier(name string, v VerifierConfig) error {
if v.Token == "" {
return fmt.Errorf("verifier %q: token is required for gitlab verifier", name)
}
case "sendgrid":
if v.PublicKey == "" {
return fmt.Errorf("verifier %q: public_key is required for sendgrid verifier", name)
}
case "api_key":
return validateAPIKeyVerifier(name, v)
case "hmac":
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,21 @@ func TestValidate_GitLabVerifierRequiresToken(t *testing.T) {
}
}

func TestValidate_SendGridVerifierRequiresPublicKey(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "sendgrid",
},
},
}

err := cfg.Validate()
if err == nil {
t.Error("expected validation error for sendgrid verifier without public_key")
}
}

func TestValidate_RateLimiter_MissingTotalRPS(t *testing.T) {
cfg := &Config{
RateLimiters: map[string]RateLimiterConfig{
Expand Down
2 changes: 2 additions & 0 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ func buildVerifier(vc config.VerifierConfig) (verifier.Verifier, error) {
return verifier.NewGitLabVerifier(vc.Token), nil
case "shopify":
return verifier.NewShopifyVerifier(vc.Secret), nil
case "sendgrid":
return verifier.NewSendGridVerifier(vc.PublicKey, vc.MaxTimestampAge)
case "api_key":
return verifier.NewAPIKeyVerifier(vc.Header, vc.Token), nil
case "hmac":
Expand Down
53 changes: 53 additions & 0 deletions internal/proxy/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package proxy
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"fmt"
Expand Down Expand Up @@ -2367,6 +2371,55 @@ func TestHandler_SlackURLVerification_Relay(t *testing.T) {
}
}

func generateSendGridTestPublicKey(t *testing.T) string {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("marshal pkix: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}

func TestHandler_SendGridVerifierBuilds(t *testing.T) {
cfg := &config.Config{
Routes: []config.RouteConfig{
{Hostname: testHost, Path: "/", Destination: testBackendURL},
},
Verifiers: map[string]config.VerifierConfig{
"sendgrid": {Type: "sendgrid", PublicKey: generateSendGridTestPublicKey(t)},
},
}
filters := ipfilter.NewFilterSet()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler, err := NewHandler(cfg, filters, logger, HandlerOptions{})
if err != nil {
t.Fatalf(errFmtHandler, err)
}
if handler.verifierTypes["sendgrid"] != "sendgrid" {
t.Errorf("expected verifierTypes['sendgrid']='sendgrid', got %q", handler.verifierTypes["sendgrid"])
}
}

func TestHandler_SendGridVerifierBuildError(t *testing.T) {
cfg := &config.Config{
Routes: []config.RouteConfig{
{Hostname: testHost, Path: "/", Destination: testBackendURL},
},
Verifiers: map[string]config.VerifierConfig{
"sendgrid": {Type: "sendgrid", PublicKey: "not-a-valid-key"},
},
}
filters := ipfilter.NewFilterSet()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewHandler(cfg, filters, logger, HandlerOptions{}); err == nil {
t.Fatal("expected error from invalid sendgrid public key, got nil")
}
}

func TestHandler_VerifierTypesMap(t *testing.T) {
cfg := &config.Config{
Routes: []config.RouteConfig{
Expand Down
Loading
Loading