diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index a359c7f6..3aef6f1b 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -598,7 +598,7 @@ func KnownStepTypes() map[string]StepTypeInfo { "step.webhook_verify": { Type: "step.webhook_verify", Plugin: "pipelinesteps", - ConfigKeys: []string{"secret", "header", "algorithm"}, + ConfigKeys: []string{"provider", "scheme", "secret", "secret_from", "header", "signature_header", "url_reconstruction", "include_form_params", "error_status"}, }, "step.cache_get": { Type: "step.cache_get", diff --git a/module/pipeline_step_webhook_verify.go b/module/pipeline_step_webhook_verify.go index 9e658357..8f735ec3 100644 --- a/module/pipeline_step_webhook_verify.go +++ b/module/pipeline_step_webhook_verify.go @@ -3,13 +3,17 @@ package module import ( "context" "crypto/hmac" + "crypto/sha1" //nolint:gosec // Required for Twilio HMAC-SHA1 webhook signature verification "crypto/sha256" "crypto/subtle" + "encoding/base64" "encoding/hex" "fmt" "io" "net/http" + "net/url" "os" + "sort" "strconv" "strings" "time" @@ -22,6 +26,11 @@ const ( webhookVerifyProviderStripe = "stripe" webhookVerifyProviderGeneric = "generic" + // Scheme constants for scheme-based verification. + webhookSchemeHMACSHA1 = "hmac-sha1" + webhookSchemeHMACSHA256 = "hmac-sha256" + webhookSchemeHMACSHA256Hex = "hmac-sha256-hex" + // stripeTimestampTolerance is the maximum allowed age of a Stripe timestamp. stripeTimestampTolerance = 5 * time.Minute ) @@ -32,40 +41,108 @@ type WebhookVerifyStep struct { provider string secret string header string + + // scheme-based fields (new config model) + scheme string + secretFrom string + signatureHeader string + urlReconstruction bool + includeFormParams bool + errorStatus int } // NewWebhookVerifyStepFactory returns a StepFactory that creates WebhookVerifyStep instances. func NewWebhookVerifyStepFactory() StepFactory { return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + scheme, _ := config["scheme"].(string) provider, _ := config["provider"].(string) - if provider == "" { - return nil, fmt.Errorf("webhook_verify step %q: 'provider' is required (github, stripe, or generic)", name) - } - switch provider { - case webhookVerifyProviderGitHub, webhookVerifyProviderStripe, webhookVerifyProviderGeneric: - // valid - default: - return nil, fmt.Errorf("webhook_verify step %q: unknown provider %q (must be github, stripe, or generic)", name, provider) + // Determine which mode to use: scheme-based or provider-based + if scheme != "" { + return newSchemeBasedStep(name, scheme, config) } - secret, _ := config["secret"].(string) - if secret == "" { - return nil, fmt.Errorf("webhook_verify step %q: 'secret' is required", name) + if provider == "" { + return nil, fmt.Errorf("webhook_verify step %q: 'scheme' or 'provider' is required", name) } - // Expand environment variable references (e.g., "$MY_SECRET" or "${MY_SECRET}") + return newProviderBasedStep(name, provider, config) + } +} + +// newSchemeBasedStep creates a WebhookVerifyStep using the scheme-based config model. +func newSchemeBasedStep(name, scheme string, config map[string]any) (PipelineStep, error) { + switch scheme { + case webhookSchemeHMACSHA1, webhookSchemeHMACSHA256, webhookSchemeHMACSHA256Hex: + // valid + default: + return nil, fmt.Errorf("webhook_verify step %q: unknown scheme %q (must be hmac-sha1, hmac-sha256, or hmac-sha256-hex)", name, scheme) + } + + secret, _ := config["secret"].(string) + secretFrom, _ := config["secret_from"].(string) + if secret == "" && secretFrom == "" { + return nil, fmt.Errorf("webhook_verify step %q: 'secret' or 'secret_from' is required", name) + } + + if secret != "" { secret = expandEnvSecret(secret) + } - header, _ := config["header"].(string) + signatureHeader, _ := config["signature_header"].(string) + if signatureHeader == "" { + return nil, fmt.Errorf("webhook_verify step %q: 'signature_header' is required when using scheme", name) + } - return &WebhookVerifyStep{ - name: name, - provider: provider, - secret: secret, - header: header, - }, nil + urlReconstruction, _ := config["url_reconstruction"].(bool) + includeFormParams, _ := config["include_form_params"].(bool) + + errorStatus := http.StatusUnauthorized + if es, ok := config["error_status"]; ok { + switch v := es.(type) { + case int: + errorStatus = v + case float64: + errorStatus = int(v) + } + } + + return &WebhookVerifyStep{ + name: name, + scheme: scheme, + secret: secret, + secretFrom: secretFrom, + signatureHeader: signatureHeader, + urlReconstruction: urlReconstruction, + includeFormParams: includeFormParams, + errorStatus: errorStatus, + }, nil +} + +// newProviderBasedStep creates a WebhookVerifyStep using the legacy provider-based config model. +func newProviderBasedStep(name, provider string, config map[string]any) (PipelineStep, error) { + switch provider { + case webhookVerifyProviderGitHub, webhookVerifyProviderStripe, webhookVerifyProviderGeneric: + // valid + default: + return nil, fmt.Errorf("webhook_verify step %q: unknown provider %q (must be github, stripe, or generic)", name, provider) + } + + secret, _ := config["secret"].(string) + if secret == "" { + return nil, fmt.Errorf("webhook_verify step %q: 'secret' is required", name) } + + secret = expandEnvSecret(secret) + header, _ := config["header"].(string) + + return &WebhookVerifyStep{ + name: name, + provider: provider, + secret: secret, + header: header, + errorStatus: http.StatusUnauthorized, + }, nil } // Name returns the step name. @@ -84,6 +161,11 @@ func (s *WebhookVerifyStep) Execute(_ context.Context, pc *PipelineContext) (*St return s.unauthorized(pc, fmt.Sprintf("failed to read request body: %v", err)) } + // Scheme-based verification takes priority + if s.scheme != "" { + return s.verifyByScheme(req, body, pc) + } + switch s.provider { case webhookVerifyProviderGitHub: return s.verifyGitHub(req, body, pc) @@ -96,6 +178,196 @@ func (s *WebhookVerifyStep) Execute(_ context.Context, pc *PipelineContext) (*St } } +// resolveSecret returns the signing secret, resolving from pipeline context if secret_from is set. +func (s *WebhookVerifyStep) resolveSecret(pc *PipelineContext) (string, error) { + if s.secret != "" { + return s.secret, nil + } + + if s.secretFrom == "" { + return "", fmt.Errorf("no secret configured") + } + + // Build a data map for dot-path resolution. + // Convert StepOutputs (map[string]map[string]any) to map[string]any for traversal. + stepsMap := make(map[string]any, len(pc.StepOutputs)) + for k, v := range pc.StepOutputs { + stepsMap[k] = v + } + + // Start with the current context, then overlay reserved keys so they cannot be overridden + // by user-controlled trigger data containing keys like "steps", "trigger", or "meta". + data := make(map[string]any, len(pc.Current)+3) + for k, v := range pc.Current { + data[k] = v + } + data["steps"] = stepsMap + data["trigger"] = pc.TriggerData + data["meta"] = pc.Metadata + + val, err := resolveDottedPath(data, s.secretFrom) + if err != nil { + return "", fmt.Errorf("could not resolve secret_from %q: %w", s.secretFrom, err) + } + + secretStr, ok := val.(string) + if !ok { + return "", fmt.Errorf("secret_from %q resolved to non-string type %T", s.secretFrom, val) + } + return secretStr, nil +} + +// verifyByScheme performs signature verification using the scheme-based config model. +func (s *WebhookVerifyStep) verifyByScheme(req *http.Request, body []byte, pc *PipelineContext) (*StepResult, error) { + sig := req.Header.Get(s.signatureHeader) + if sig == "" { + return s.unauthorized(pc, fmt.Sprintf("missing %s header", s.signatureHeader)) + } + + secret, err := s.resolveSecret(pc) + if err != nil { + return s.unauthorized(pc, err.Error()) + } + + // Build signing input + var signingInput []byte + if s.includeFormParams { + signingInput = s.buildTwilioSigningInput(req, body) + } else { + signingInput = body + } + + switch s.scheme { + case webhookSchemeHMACSHA1: + return s.verifyHMACSHA1(sig, secret, signingInput, pc) + case webhookSchemeHMACSHA256: + return s.verifyHMACSHA256Hex(sig, secret, signingInput, pc) + case webhookSchemeHMACSHA256Hex: + // Expects sha256= prefix + if !strings.HasPrefix(sig, "sha256=") { + return s.unauthorized(pc, fmt.Sprintf("%s must have format sha256=", s.signatureHeader)) + } + return s.verifyHMACSHA256Hex(strings.TrimPrefix(sig, "sha256="), secret, signingInput, pc) + default: + return s.unauthorized(pc, fmt.Sprintf("unknown scheme: %s", s.scheme)) + } +} + +// verifyHMACSHA1 verifies a base64-encoded HMAC-SHA1 signature. +func (s *WebhookVerifyStep) verifyHMACSHA1(sig, secret string, data []byte, pc *PipelineContext) (*StepResult, error) { + sigBytes, err := base64.StdEncoding.DecodeString(sig) + if err != nil { + return s.unauthorized(pc, fmt.Sprintf("invalid base64 in %s", s.signatureHeader)) + } + + expected := computeHMACSHA1([]byte(secret), data) + if subtle.ConstantTimeCompare(expected, sigBytes) != 1 { + return s.unauthorized(pc, "signature mismatch") + } + + return &StepResult{ + Output: map[string]any{"verified": true}, + }, nil +} + +// verifyHMACSHA256Hex verifies a hex-encoded HMAC-SHA256 signature. +func (s *WebhookVerifyStep) verifyHMACSHA256Hex(sigHex, secret string, data []byte, pc *PipelineContext) (*StepResult, error) { + sigBytes, err := hex.DecodeString(sigHex) + if err != nil { + return s.unauthorized(pc, fmt.Sprintf("invalid hex in %s", s.signatureHeader)) + } + + expected := computeHMACSHA256([]byte(secret), data) + if subtle.ConstantTimeCompare(expected, sigBytes) != 1 { + return s.unauthorized(pc, "signature mismatch") + } + + return &StepResult{ + Output: map[string]any{"verified": true}, + }, nil +} + +// buildTwilioSigningInput constructs the signing input for Twilio-style webhooks: +// the URL followed by POST form parameter values sorted alphabetically by key. +func (s *WebhookVerifyStep) buildTwilioSigningInput(req *http.Request, body []byte) []byte { + requestURL := s.reconstructURL(req) + + // Parse form parameters from the body + params, err := url.ParseQuery(string(body)) + if err != nil { + return []byte(requestURL) + } + + // Sort parameter keys and append key+value pairs + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf strings.Builder + buf.WriteString(requestURL) + for _, k := range keys { + for _, v := range params[k] { + buf.WriteString(k) + buf.WriteString(v) + } + } + + return []byte(buf.String()) +} + +// reconstructURL returns the full URL used for signature verification. +// When url_reconstruction is enabled, it rebuilds from X-Forwarded-Proto and X-Forwarded-Host headers. +func (s *WebhookVerifyStep) reconstructURL(req *http.Request) string { + if !s.urlReconstruction { + return requestURL(req) + } + + // Take the first value from comma-separated X-Forwarded-Proto header, + // falling back to the scheme inferred from the request itself. + scheme := firstHeaderValue(req.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + scheme = requestScheme(req) + } + + // Take the first value from comma-separated X-Forwarded-Host header, + // falling back to the Host from the request. + host := firstHeaderValue(req.Header.Get("X-Forwarded-Host")) + if host == "" { + host = req.Host + } + + return scheme + "://" + host + req.URL.RequestURI() +} + +// firstHeaderValue returns the first comma-separated value from a header string. +func firstHeaderValue(h string) string { + if h == "" { + return "" + } + if idx := strings.IndexByte(h, ','); idx != -1 { + return strings.TrimSpace(h[:idx]) + } + return strings.TrimSpace(h) +} + +// requestScheme returns the scheme of the request based on TLS state and URL. +func requestScheme(req *http.Request) string { + if req.TLS != nil { + return "https" + } + if s := req.URL.Scheme; s != "" { + return s + } + return "http" +} + +// requestURL reconstructs the URL from the request as-is. +func requestURL(req *http.Request) string { + return requestScheme(req) + "://" + req.Host + req.URL.RequestURI() +} + // verifyGitHub checks the X-Hub-Signature-256 header (format: sha256=). func (s *WebhookVerifyStep) verifyGitHub(req *http.Request, body []byte, pc *PipelineContext) (*StepResult, error) { sig := req.Header.Get("X-Hub-Signature-256") @@ -189,11 +461,15 @@ func (s *WebhookVerifyStep) verifyGeneric(req *http.Request, body []byte, pc *Pi }, nil } -// unauthorized writes a 401 response if a response writer is available, and returns Stop: true. +// unauthorized writes an error response if a response writer is available, and returns Stop: true. func (s *WebhookVerifyStep) unauthorized(pc *PipelineContext, reason string) (*StepResult, error) { + status := s.errorStatus + if status == 0 { + status = http.StatusUnauthorized + } if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(status) _, _ = w.Write([]byte(`{"error":"unauthorized","reason":"webhook signature verification failed"}`)) } return &StepResult{ @@ -231,6 +507,13 @@ func computeHMACSHA256(key, data []byte) []byte { return mac.Sum(nil) } +// computeHMACSHA1 returns the HMAC-SHA1 of data using key. +func computeHMACSHA1(key, data []byte) []byte { + mac := hmac.New(sha1.New, key) + mac.Write(data) + return mac.Sum(nil) +} + // parseStripeSignature parses the Stripe-Signature header. // Format: t=,v1=[,v1=]... func parseStripeSignature(sig string) (int64, []string, error) { diff --git a/module/pipeline_step_webhook_verify_test.go b/module/pipeline_step_webhook_verify_test.go index e2896a15..d5eda309 100644 --- a/module/pipeline_step_webhook_verify_test.go +++ b/module/pipeline_step_webhook_verify_test.go @@ -3,7 +3,9 @@ package module import ( "bytes" "crypto/hmac" + "crypto/sha1" "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "net/http" @@ -401,3 +403,558 @@ func TestWebhookVerifyStep_RawBodyCachedInMetadata(t *testing.T) { t.Errorf("expected verified=true, got %v", result.Output["verified"]) } } + +// --- Scheme-based tests --- + +func computeTestHMACSHA1Base64(secret, data string) string { + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write([]byte(data)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func TestWebhookVerifyStep_SchemeHMACSHA1_Valid(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-twilio", map[string]any{ + "scheme": "hmac-sha1", + "secret": "twilio-secret", + "signature_header": "X-Twilio-Signature", + "url_reconstruction": true, + "include_form_params": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + // Build form-encoded body + formBody := "Body=Hello&From=%2B1234567890&To=%2B0987654321" + // Twilio signing input: URL + sorted form params (key+value concatenated) + // With url_reconstruction and X-Forwarded-Proto/Host: + signingInput := "https://example.com/webhook" + "Body" + "Hello" + "From" + "+1234567890" + "To" + "+0987654321" + sig := computeTestHMACSHA1Base64("twilio-secret", signingInput) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(formBody))) + req.Header.Set("X-Twilio-Signature", sig) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "example.com") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false on valid Twilio signature, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SchemeHMACSHA1_Invalid(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-twilio-bad", map[string]any{ + "scheme": "hmac-sha1", + "secret": "twilio-secret", + "signature_header": "X-Twilio-Signature", + "include_form_params": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + formBody := "Body=Hello" + sig := computeTestHMACSHA1Base64("wrong-secret", "http://example.com/webhook"+"Body"+"Hello") + + req := httptest.NewRequest(http.MethodPost, "http://example.com/webhook", bytes.NewReader([]byte(formBody))) + req.Header.Set("X-Twilio-Signature", sig) + + w := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + "_http_response_writer": w, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Stop { + t.Error("expected Stop=true on invalid Twilio signature") + } + if w.Code != http.StatusUnauthorized { + t.Errorf("expected HTTP 401, got %d", w.Code) + } +} + +func TestWebhookVerifyStep_SchemeHMACSHA256_Valid(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-sha256", map[string]any{ + "scheme": "hmac-sha256", + "secret": "sha256-secret", + "signature_header": "X-Signature", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"event":"test"}`) + sig := computeTestHMAC("sha256-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Signature", sig) + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SchemeHMACSHA256Hex_Valid(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-gh-scheme", map[string]any{ + "scheme": "hmac-sha256-hex", + "secret": "gh-secret", + "signature_header": "X-Hub-Signature-256", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"action":"opened"}`) + sig := "sha256=" + computeTestHMAC("gh-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Hub-Signature-256", sig) + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SchemeHMACSHA256Hex_MissingPrefix(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-gh-no-prefix", map[string]any{ + "scheme": "hmac-sha256-hex", + "secret": "gh-secret", + "signature_header": "X-Hub-Signature-256", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"action":"opened"}`) + // Missing "sha256=" prefix + sig := computeTestHMAC("gh-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Hub-Signature-256", sig) + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Stop { + t.Error("expected Stop=true when sha256= prefix is missing") + } +} + +func TestWebhookVerifyStep_SecretFrom_Valid(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-secret-from", map[string]any{ + "scheme": "hmac-sha256", + "secret_from": "steps.load-config.auth_token", + "signature_header": "X-Signature", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"event":"test"}`) + sig := computeTestHMAC("dynamic-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Signature", sig) + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + // Simulate a previous step having produced the secret + pc.StepOutputs["load-config"] = map[string]any{"auth_token": "dynamic-secret"} + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SecretFrom_NotFound(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-secret-from-missing", map[string]any{ + "scheme": "hmac-sha256", + "secret_from": "steps.missing-step.token", + "signature_header": "X-Signature", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"event":"test"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Signature", "deadbeef") + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Stop { + t.Error("expected Stop=true when secret_from cannot be resolved") + } + reason, _ := result.Output["reason"].(string) + if !strings.Contains(reason, "secret_from") { + t.Errorf("expected reason to mention secret_from, got: %q", reason) + } +} + +func TestWebhookVerifyStep_ErrorStatus_Custom(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-custom-status", map[string]any{ + "scheme": "hmac-sha256", + "secret": "my-secret", + "signature_header": "X-Signature", + "error_status": 403, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{}`)) + // No X-Signature header → should fail + + w := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + "_http_response_writer": w, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Stop { + t.Error("expected Stop=true on missing signature") + } + if w.Code != http.StatusForbidden { + t.Errorf("expected HTTP 403, got %d", w.Code) + } +} + +func TestWebhookVerifyStep_URLReconstruction(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-url-recon", map[string]any{ + "scheme": "hmac-sha1", + "secret": "test-secret", + "signature_header": "X-Twilio-Signature", + "url_reconstruction": true, + "include_form_params": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + formBody := "Param=Value" + // URL reconstruction: X-Forwarded-Proto=https, X-Forwarded-Host=myapp.example.com + expectedURL := "https://myapp.example.com/hook" + signingInput := expectedURL + "Param" + "Value" + sig := computeTestHMACSHA1Base64("test-secret", signingInput) + + req := httptest.NewRequest(http.MethodPost, "/hook", bytes.NewReader([]byte(formBody))) + req.Header.Set("X-Twilio-Signature", sig) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "myapp.example.com") + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SchemeFactoryRejectsUnknownScheme(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + _, err := factory("bad-scheme", map[string]any{ + "scheme": "hmac-sha512", + "secret": "my-secret", + "signature_header": "X-Sig", + }, nil) + if err == nil { + t.Fatal("expected error for unknown scheme") + } + if !strings.Contains(err.Error(), "unknown scheme") { + t.Errorf("expected 'unknown scheme' error, got: %v", err) + } +} + +func TestWebhookVerifyStep_SchemeFactoryRejectsMissingSignatureHeader(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + _, err := factory("no-header", map[string]any{ + "scheme": "hmac-sha256", + "secret": "my-secret", + }, nil) + if err == nil { + t.Fatal("expected error for missing signature_header") + } + if !strings.Contains(err.Error(), "signature_header") { + t.Errorf("expected 'signature_header' error, got: %v", err) + } +} + +func TestWebhookVerifyStep_SchemeFactoryRejectsMissingSecret(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + _, err := factory("no-secret", map[string]any{ + "scheme": "hmac-sha256", + "signature_header": "X-Sig", + }, nil) + if err == nil { + t.Fatal("expected error for missing secret and secret_from") + } +} + +func TestWebhookVerifyStep_SchemeMissingHeader(t *testing.T) { + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-missing-sig", map[string]any{ + "scheme": "hmac-sha256", + "secret": "my-secret", + "signature_header": "X-Custom-Sig", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{}`)) + // No X-Custom-Sig header + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Stop { + t.Error("expected Stop=true when signature header is missing") + } + reason, _ := result.Output["reason"].(string) + if !strings.Contains(reason, "X-Custom-Sig") { + t.Errorf("expected reason to mention X-Custom-Sig, got: %q", reason) + } +} + +func TestWebhookVerifyStep_SchemeNoFormParams(t *testing.T) { + // When include_form_params is false, should use raw body + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-no-form", map[string]any{ + "scheme": "hmac-sha256", + "secret": "raw-body-secret", + "signature_header": "X-Signature", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`raw body content`) + sig := computeTestHMAC("raw-body-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Signature", sig) + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_SecretFrom_CannotBeOverriddenByCurrentData(t *testing.T) { + // Verify that reserved keys (steps/trigger/meta) in resolveSecret cannot be overridden + // by user-controlled data in pc.Current. + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-secret-override", map[string]any{ + "scheme": "hmac-sha256", + "secret_from": "steps.load-config.auth_token", + "signature_header": "X-Signature", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + body := []byte(`{"event":"test"}`) + sig := computeTestHMAC("real-secret", string(body)) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("X-Signature", sig) + + // Set the actual secret via StepOutputs + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + pc.StepOutputs["load-config"] = map[string]any{"auth_token": "real-secret"} + // Attempt to override the "steps" key via Current — this should NOT take effect + pc.Current["steps"] = map[string]any{ + "load-config": map[string]any{"auth_token": "attacker-controlled-secret"}, + } + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + // The real secret should be used (from StepOutputs), so signature verification should succeed. + if result.Stop { + t.Errorf("expected Stop=false; reserved keys must not be overrideable via Current, reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_URLReconstruction_CommaSeparatedHeaders(t *testing.T) { + // Verify that comma-separated X-Forwarded-Proto and X-Forwarded-Host values + // are handled correctly (first value is used). + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-url-comma", map[string]any{ + "scheme": "hmac-sha1", + "secret": "test-secret", + "signature_header": "X-Twilio-Signature", + "url_reconstruction": true, + "include_form_params": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + formBody := "Param=Value" + // First value from comma-separated headers should be used + expectedURL := "https://myapp.example.com/hook" + signingInput := expectedURL + "Param" + "Value" + sig := computeTestHMACSHA1Base64("test-secret", signingInput) + + req := httptest.NewRequest(http.MethodPost, "/hook", bytes.NewReader([]byte(formBody))) + req.Header.Set("X-Twilio-Signature", sig) + // Comma-separated values — first should win + req.Header.Set("X-Forwarded-Proto", "https, http") + req.Header.Set("X-Forwarded-Host", "myapp.example.com, internal.host") + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false (first value from comma-separated headers), reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +} + +func TestWebhookVerifyStep_URLReconstruction_FallsBackToRequestScheme(t *testing.T) { + // When X-Forwarded-Proto is absent, scheme should be inferred from the request (http for non-TLS). + factory := NewWebhookVerifyStepFactory() + step, err := factory("verify-url-fallback", map[string]any{ + "scheme": "hmac-sha1", + "secret": "test-secret", + "signature_header": "X-Twilio-Signature", + "url_reconstruction": true, + "include_form_params": true, + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + + formBody := "Key=Val" + // No X-Forwarded-Proto — req.TLS is nil so scheme should be "http" + // httptest.NewRequest creates a request with no TLS so requestScheme returns "http" + expectedURL := "http://example.com/hook" + signingInput := expectedURL + "Key" + "Val" + sig := computeTestHMACSHA1Base64("test-secret", signingInput) + + req := httptest.NewRequest(http.MethodPost, "/hook", bytes.NewReader([]byte(formBody))) + req.Host = "example.com" + req.Header.Set("X-Twilio-Signature", sig) + // No X-Forwarded-Proto set + + pc := NewPipelineContext(nil, map[string]any{ + "_http_request": req, + }) + + result, err := step.Execute(t.Context(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + if result.Stop { + t.Errorf("expected Stop=false (fallback to request scheme), reason: %v", result.Output["reason"]) + } + if result.Output["verified"] != true { + t.Errorf("expected verified=true, got %v", result.Output["verified"]) + } +}