From fe8f215c2815e44f754535876fb0344373db6f4a Mon Sep 17 00:00:00 2001 From: Nick Marden Date: Fri, 23 Jan 2026 13:58:08 +0700 Subject: [PATCH] feat: add query_param and header_query_param verifier types - query_param: validates a query parameter in the request URL - header_query_param: parses a header as query string format and validates a named parameter (e.g., Google's X-Goog-Channel-Token with secret=...) Includes Helm chart template support, config validation, and 100% test coverage. --- CHANGELOG.md | 4 + charts/gatekeeperd/templates/configmap.yaml | 7 ++ charts/gatekeeperd/values.yaml | 9 ++ internal/config/config.go | 34 +++++- internal/config/config_test.go | 123 ++++++++++++++++++++ internal/proxy/handler.go | 4 + internal/proxy/handler_test.go | 20 ++-- internal/verifier/headerqueryparam.go | 57 +++++++++ internal/verifier/headerqueryparam_test.go | 122 +++++++++++++++++++ internal/verifier/queryparam.go | 41 +++++++ internal/verifier/queryparam_test.go | 82 +++++++++++++ 11 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 internal/verifier/headerqueryparam.go create mode 100644 internal/verifier/headerqueryparam_test.go create mode 100644 internal/verifier/queryparam.go create mode 100644 internal/verifier/queryparam_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5a037..d3962e8 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 +- `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 diff --git a/charts/gatekeeperd/templates/configmap.yaml b/charts/gatekeeperd/templates/configmap.yaml index 73dca16..c64ec95 100644 --- a/charts/gatekeeperd/templates/configmap.yaml +++ b/charts/gatekeeperd/templates/configmap.yaml @@ -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 }} diff --git a/charts/gatekeeperd/values.yaml b/charts/gatekeeperd/values.yaml index dfd2a49..6797704 100644 --- a/charts/gatekeeperd/values.yaml +++ b/charts/gatekeeperd/values.yaml @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 03a2ba9..3e8e930 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, 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"` @@ -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 @@ -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 "": @@ -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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 49b8ff6..378ef6c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index c904d25..c3571d6 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -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: diff --git a/internal/proxy/handler_test.go b/internal/proxy/handler_test.go index 1181cf5..2eb76dc 100644 --- a/internal/proxy/handler_test.go +++ b/internal/proxy/handler_test.go @@ -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"}, }, } @@ -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)) } } diff --git a/internal/verifier/headerqueryparam.go b/internal/verifier/headerqueryparam.go new file mode 100644 index 0000000..fe40a91 --- /dev/null +++ b/internal/verifier/headerqueryparam.go @@ -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" +} diff --git a/internal/verifier/headerqueryparam_test.go b/internal/verifier/headerqueryparam_test.go new file mode 100644 index 0000000..a768551 --- /dev/null +++ b/internal/verifier/headerqueryparam_test.go @@ -0,0 +1,122 @@ +package verifier + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHeaderQueryParamVerifier_Verify(t *testing.T) { + header := "X-Goog-Channel-Token" + name := "secret" + token := "my-secret-token" + verifier := NewHeaderQueryParamVerifier(header, name, token) + + tests := []verifierTestCase{ + { + name: "valid token in query string header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "secret=my-secret-token") + return req, body + }, + wantErr: false, + }, + { + name: "valid token with multiple params", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "foo=bar&secret=my-secret-token&baz=qux") + return req, body + }, + wantErr: false, + }, + { + name: "missing header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "empty header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "") + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "parameter missing from header", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "foo=bar&other=value") + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "wrong token", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "secret=wrong-token") + return req, body + }, + wantErr: true, + errString: "token does not match", + }, + { + name: "empty parameter value", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "secret=") + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "invalid query string format", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + req.Header.Set("X-Goog-Channel-Token", "not-a-query-string%") + return req, body + }, + wantErr: true, + errString: "signature does not match", + }, + { + name: "plain value without equals", + setup: func() (*http.Request, []byte) { + body := []byte(`{"resourceState":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/calendar/notify", strings.NewReader(string(body))) + // Plain value parses as key with empty value, so "secret" param would not match + req.Header.Set("X-Goog-Channel-Token", "my-secret-token") + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + } + + runVerifierTests(t, verifier, tests) +} + +func TestHeaderQueryParamVerifier_Type(t *testing.T) { + v := NewHeaderQueryParamVerifier("X-Goog-Channel-Token", "secret", "token123") + assertVerifierType(t, v, "header_query_param") +} diff --git a/internal/verifier/queryparam.go b/internal/verifier/queryparam.go new file mode 100644 index 0000000..133a318 --- /dev/null +++ b/internal/verifier/queryparam.go @@ -0,0 +1,41 @@ +package verifier + +import ( + "crypto/subtle" + "fmt" + "net/http" +) + +// QueryParamVerifier verifies requests by comparing a URL query parameter to a known token +type QueryParamVerifier struct { + name string + token string +} + +// NewQueryParamVerifier creates a new query parameter verifier +func NewQueryParamVerifier(name, token string) *QueryParamVerifier { + return &QueryParamVerifier{ + name: name, + token: token, + } +} + +// Verify checks that the query parameter value matches the expected token +func (v *QueryParamVerifier) Verify(r *http.Request, _ []byte) error { + value := r.URL.Query().Get(v.name) + if value == "" { + return fmt.Errorf("%w: %s query parameter missing", ErrSignatureEmpty, v.name) + } + + // 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 *QueryParamVerifier) Type() string { + return "query_param" +} diff --git a/internal/verifier/queryparam_test.go b/internal/verifier/queryparam_test.go new file mode 100644 index 0000000..1e4afaf --- /dev/null +++ b/internal/verifier/queryparam_test.go @@ -0,0 +1,82 @@ +package verifier + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestQueryParamVerifier_Verify(t *testing.T) { + name := "token" + token := "my-secret-token" + verifier := NewQueryParamVerifier(name, token) + + tests := []verifierTestCase{ + { + name: "valid token", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook?token=my-secret-token", strings.NewReader(string(body))) + return req, body + }, + wantErr: false, + }, + { + name: "valid token with other params", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook?foo=bar&token=my-secret-token&baz=qux", strings.NewReader(string(body))) + return req, body + }, + wantErr: false, + }, + { + name: "missing query parameter", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/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":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook?token=wrong-token", strings.NewReader(string(body))) + return req, body + }, + wantErr: true, + errString: "token does not match", + }, + { + name: "empty token in query", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook?token=", strings.NewReader(string(body))) + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + { + name: "different parameter name present", + setup: func() (*http.Request, []byte) { + body := []byte(`{"event":"sync"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook?secret=my-secret-token", strings.NewReader(string(body))) + return req, body + }, + wantErr: true, + errString: "signature header is empty", + }, + } + + runVerifierTests(t, verifier, tests) +} + +func TestQueryParamVerifier_Type(t *testing.T) { + v := NewQueryParamVerifier("token", "secret") + assertVerifierType(t, v, "query_param") +}