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
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
- `query_param` verifier type for validating a query parameter in the request URL
- `header_query_param` verifier type for parsing a header value as query string format and validating a named parameter (e.g., Google's `X-Goog-Channel-Token` with `secret=...` format)

## [0.2.3] - 2026-01-23

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions charts/gatekeeperd/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ data:
{{- else if eq $verifier.type "json_field" }}
path: {{ $verifier.path | quote }}
token: ${{ "{" }}{{ $verifier.tokenKey }}{{ "}" }}
{{- else if eq $verifier.type "query_param" }}
name: {{ $verifier.name | quote }}
token: ${{ "{" }}{{ $verifier.tokenKey }}{{ "}" }}
{{- else if eq $verifier.type "header_query_param" }}
header: {{ $verifier.header | quote }}
name: {{ $verifier.name | quote }}
token: ${{ "{" }}{{ $verifier.tokenKey }}{{ "}" }}
{{- end }}
{{- end }}

Expand Down
9 changes: 9 additions & 0 deletions charts/gatekeeperd/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ verifiers: {}
# type: json_field
# path: "value.0.clientState.tpVerificationToken"
# tokenKey: MS_GRAPH_TP_VERIFICATION_TOKEN
# webhook-url-token:
# type: query_param
# name: token
# tokenKey: WEBHOOK_URL_TOKEN
# gcal-channel-token:
# type: header_query_param
# header: "X-Goog-Channel-Token"
# name: secret
# tokenKey: GCAL_CHANNEL_SECRET

# Proxy routes
# Each route must have either 'destination' (direct forwarding) or 'relayTokenKey' (relay delivery)
Expand Down
34 changes: 33 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, noop
Type string `yaml:"type"` // slack, github, shopify, api_key, hmac, json_field, query_param, header_query_param, noop

// For slack verifier
SigningSecret string `yaml:"signing_secret,omitempty"`
Expand All @@ -62,6 +62,9 @@ type VerifierConfig struct {

// For json_field verifier
Path string `yaml:"path,omitempty"` // dot-notation path to field (e.g., "value.0.clientState")

// For query_param and header_query_param verifiers
Name string `yaml:"name,omitempty"` // query parameter name or key name within header
}

// ValidatorConfig defines a payload structure validator
Expand Down Expand Up @@ -238,6 +241,10 @@ func validateVerifier(name string, v VerifierConfig) error {
return validateHMACVerifier(name, v)
case "json_field":
return validateJSONFieldVerifier(name, v)
case "query_param":
return validateQueryParamVerifier(name, v)
case "header_query_param":
return validateHeaderQueryParamVerifier(name, v)
case "noop":
// No validation needed
case "":
Expand Down Expand Up @@ -287,6 +294,31 @@ func validateJSONFieldVerifier(name string, v VerifierConfig) error {
return nil
}

// validateQueryParamVerifier validates query_param verifier config
func validateQueryParamVerifier(name string, v VerifierConfig) error {
if v.Name == "" {
return fmt.Errorf("verifier %q: name is required for query_param verifier", name)
}
if v.Token == "" {
return fmt.Errorf("verifier %q: token is required for query_param verifier", name)
}
return nil
}

// validateHeaderQueryParamVerifier validates header_query_param verifier config
func validateHeaderQueryParamVerifier(name string, v VerifierConfig) error {
if v.Header == "" {
return fmt.Errorf("verifier %q: header is required for header_query_param verifier", name)
}
if v.Name == "" {
return fmt.Errorf("verifier %q: name is required for header_query_param verifier", name)
}
if v.Token == "" {
return fmt.Errorf("verifier %q: token is required for header_query_param verifier", name)
}
return nil
}

// validateIPAllowlists checks that all IP allowlist configs are valid
func (c *Config) validateIPAllowlists() error {
for name, al := range c.IPAllowlists {
Expand Down
123 changes: 123 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -835,3 +835,126 @@ func TestValidate_ValidJSONFieldVerifier(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
}

func TestValidate_QueryParamVerifier_MissingName(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "query_param",
Token: "secret",
// Name is missing
},
},
}

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

func TestValidate_QueryParamVerifier_MissingToken(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "query_param",
Name: "token",
// Token is missing
},
},
}

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

func TestValidate_ValidQueryParamVerifier(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "query_param",
Name: "token",
Token: "my-secret",
},
},
}

err := cfg.Validate()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}

func TestValidate_HeaderQueryParamVerifier_MissingHeader(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "header_query_param",
Name: "secret",
Token: "my-secret",
// Header is missing
},
},
}

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

func TestValidate_HeaderQueryParamVerifier_MissingName(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "header_query_param",
Header: "X-Goog-Channel-Token",
Token: "my-secret",
// Name is missing
},
},
}

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

func TestValidate_HeaderQueryParamVerifier_MissingToken(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "header_query_param",
Header: "X-Goog-Channel-Token",
Name: "secret",
// Token is missing
},
},
}

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

func TestValidate_ValidHeaderQueryParamVerifier(t *testing.T) {
cfg := &Config{
Verifiers: map[string]VerifierConfig{
"test": {
Type: "header_query_param",
Header: "X-Goog-Channel-Token",
Name: "secret",
Token: "my-secret",
},
},
}

err := cfg.Validate()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
4 changes: 4 additions & 0 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ func buildVerifier(vc config.VerifierConfig) (verifier.Verifier, error) {
return verifier.NewHMACVerifier(vc.Header, vc.Secret, vc.Hash, vc.Encoding)
case "json_field":
return verifier.NewJSONFieldVerifier(vc.Path, vc.Token), nil
case "query_param":
return verifier.NewQueryParamVerifier(vc.Name, vc.Token), nil
case "header_query_param":
return verifier.NewHeaderQueryParamVerifier(vc.Header, vc.Name, vc.Token), nil
case "noop":
return verifier.NewNoopVerifier(), nil
default:
Expand Down
20 changes: 11 additions & 9 deletions internal/proxy/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,13 +657,15 @@ func TestNewHandler_BuildVerifiers(t *testing.T) {
{Hostname: "test.com", Path: "/", Destination: "http://backend"},
},
Verifiers: map[string]config.VerifierConfig{
"slack": {Type: "slack", SigningSecret: "secret"},
"github": {Type: "github", Secret: "secret"},
"shopify": {Type: "shopify", Secret: "secret"},
"apikey": {Type: "api_key", Header: "X-API-Key", Token: "token"},
"hmac": {Type: "hmac", Header: "X-Sig", Secret: "secret", Hash: "SHA256", Encoding: "hex"},
"json_field": {Type: "json_field", Path: "clientState", Token: "secret"},
"noop": {Type: "noop"},
"slack": {Type: "slack", SigningSecret: "secret"},
"github": {Type: "github", Secret: "secret"},
"shopify": {Type: "shopify", Secret: "secret"},
"apikey": {Type: "api_key", Header: "X-API-Key", Token: "token"},
"hmac": {Type: "hmac", Header: "X-Sig", Secret: "secret", Hash: "SHA256", Encoding: "hex"},
"json_field": {Type: "json_field", Path: "clientState", Token: "secret"},
"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"},
},
}

Expand All @@ -676,8 +678,8 @@ func TestNewHandler_BuildVerifiers(t *testing.T) {
}

// Verify all verifiers were created
if len(handler.verifiers) != 7 {
t.Errorf("expected 7 verifiers, got %d", len(handler.verifiers))
if len(handler.verifiers) != 9 {
t.Errorf("expected 9 verifiers, got %d", len(handler.verifiers))
}
}

Expand Down
57 changes: 57 additions & 0 deletions internal/verifier/headerqueryparam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package verifier

import (
"crypto/subtle"
"fmt"
"net/http"
"net/url"
)

// HeaderQueryParamVerifier verifies requests by parsing a header value as a query string
// and comparing a named parameter to a known token. This is useful for headers like
// Google's X-Goog-Channel-Token which can contain query string formatted data.
type HeaderQueryParamVerifier struct {
header string
name string
token string
}

// NewHeaderQueryParamVerifier creates a new header query parameter verifier
func NewHeaderQueryParamVerifier(header, name, token string) *HeaderQueryParamVerifier {
return &HeaderQueryParamVerifier{
header: header,
name: name,
token: token,
}
}

// Verify checks that the named parameter in the header's query string value matches the expected token
func (v *HeaderQueryParamVerifier) Verify(r *http.Request, _ []byte) error {
headerValue := r.Header.Get(v.header)
if headerValue == "" {
return fmt.Errorf("%w: %s header missing", ErrSignatureEmpty, v.header)
}

// Parse the header value as a query string (key=value&key2=value2)
values, err := url.ParseQuery(headerValue)
if err != nil {
return fmt.Errorf("%w: failed to parse %s header as query string", ErrSignatureMismatch, v.header)
}

value := values.Get(v.name)
if value == "" {
return fmt.Errorf("%w: %s parameter missing in %s header", ErrSignatureEmpty, v.name, v.header)
}

// 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 *HeaderQueryParamVerifier) Type() string {
return "header_query_param"
}
Loading
Loading