package webhook import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/shankar0123/certctl/internal/connector/notifier" ) func TestWebhook_ValidateConfig_ValidURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, } rawConfig, _ := json.Marshal(cfg) // Create a new logger (or use test logger) logger := newTestLogger() conn := newForTest(cfg, logger) err := conn.ValidateConfig(context.Background(), rawConfig) if err != nil { t.Errorf("expected no error, got %v", err) } } func TestWebhook_ValidateConfig_MissingURL(t *testing.T) { cfg := &Config{ URL: "", } rawConfig, _ := json.Marshal(cfg) logger := newTestLogger() conn := newForTest(cfg, logger) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "webhook url is required") { t.Errorf("expected 'webhook url is required', got %v", err) } } func TestWebhook_ValidateConfig_InvalidJSON(t *testing.T) { rawConfig := []byte("{invalid json") logger := newTestLogger() conn := New(&Config{}, logger) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "invalid webhook config") { t.Errorf("expected 'invalid webhook config', got %v", err) } } func TestWebhook_SendAlert_Success(t *testing.T) { var receivedPayload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if ct := r.Header.Get("Content-Type"); ct != "application/json" { t.Errorf("expected application/json, got %s", ct) } if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil { t.Fatalf("failed to decode payload: %v", err) } w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, } logger := newTestLogger() conn := newForTest(cfg, logger) alert := notifier.Alert{ ID: "alert-123", Type: "expiration", Severity: "warning", Subject: "Certificate Expiring", Message: "Certificate mc-api-prod expires in 7 days", Recipient: "ops@example.com", Metadata: map[string]string{"cert_id": "mc-api-prod"}, CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err != nil { t.Fatalf("unexpected error: %v", err) } if receivedPayload["type"] != "alert" { t.Errorf("expected type 'alert', got %v", receivedPayload["type"]) } if receivedPayload["alert_id"] != "alert-123" { t.Errorf("expected alert_id 'alert-123', got %v", receivedPayload["alert_id"]) } if receivedPayload["severity"] != "warning" { t.Errorf("expected severity 'warning', got %v", receivedPayload["severity"]) } if receivedPayload["subject"] != "Certificate Expiring" { t.Errorf("expected subject 'Certificate Expiring', got %v", receivedPayload["subject"]) } if receivedPayload["message"] != "Certificate mc-api-prod expires in 7 days" { t.Errorf("expected correct message, got %v", receivedPayload["message"]) } } func TestWebhook_SendAlert_HMACSignature(t *testing.T) { var receivedSignature string var receivedBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedSignature = r.Header.Get("X-Signature") sigAlgo := r.Header.Get("X-Signature-Algorithm") if sigAlgo != "sha256" { t.Errorf("expected algorithm sha256, got %s", sigAlgo) } var err error receivedBody, err = io.ReadAll(r.Body) if err != nil { t.Fatalf("failed to read body: %v", err) } w.WriteHeader(http.StatusOK) })) defer server.Close() secret := "my-secret-key" cfg := &Config{ URL: server.URL, Secret: secret, } logger := newTestLogger() conn := newForTest(cfg, logger) alert := notifier.Alert{ ID: "alert-456", Type: "expiration", Severity: "critical", Subject: "Critical: Certificate Expired", Message: "Certificate is already expired", Recipient: "admin@example.com", CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err != nil { t.Fatalf("unexpected error: %v", err) } // Verify signature expectedSignature := computeHMACSHA256(receivedBody, secret) if receivedSignature != expectedSignature { t.Errorf("expected signature %s, got %s", expectedSignature, receivedSignature) } } func TestWebhook_SendAlert_NoSignatureWithoutSecret(t *testing.T) { var hasSignatureHeader bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, hasSignatureHeader = r.Header["X-Signature"] w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, Secret: "", } logger := newTestLogger() conn := newForTest(cfg, logger) alert := notifier.Alert{ ID: "alert-789", Type: "expiration", Severity: "info", Subject: "Renewal Complete", Message: "Certificate renewed successfully", Recipient: "ops@example.com", CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err != nil { t.Fatalf("unexpected error: %v", err) } if hasSignatureHeader { t.Error("expected no X-Signature header when secret is empty") } } func TestWebhook_SendAlert_CustomHeaders(t *testing.T) { var receivedHeaders http.Header server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedHeaders = r.Header w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, Headers: map[string]string{ "Authorization": "Bearer token123", "X-Custom": "custom-value", }, } logger := newTestLogger() conn := newForTest(cfg, logger) alert := notifier.Alert{ ID: "alert-custom", Type: "test", Severity: "info", Subject: "Test", Message: "Test message", Recipient: "test@example.com", CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err != nil { t.Fatalf("unexpected error: %v", err) } if auth := receivedHeaders.Get("Authorization"); auth != "Bearer token123" { t.Errorf("expected Authorization header 'Bearer token123', got %s", auth) } if custom := receivedHeaders.Get("X-Custom"); custom != "custom-value" { t.Errorf("expected X-Custom header 'custom-value', got %s", custom) } } func TestWebhook_SendAlert_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("server error")) })) defer server.Close() cfg := &Config{ URL: server.URL, } logger := newTestLogger() conn := newForTest(cfg, logger) alert := notifier.Alert{ ID: "alert-error", Type: "test", Severity: "error", Subject: "Test Error", Message: "Testing error handling", Recipient: "admin@example.com", CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "500") { t.Errorf("expected error to contain '500', got %v", err) } } func TestWebhook_SendEvent_Success(t *testing.T) { var receivedPayload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil { t.Fatalf("failed to decode payload: %v", err) } w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, } logger := newTestLogger() conn := newForTest(cfg, logger) certID := "mc-api-prod" event := notifier.Event{ ID: "event-123", Type: "issued", CertificateID: &certID, Subject: "Certificate Issued", Body: "New certificate issued for mc-api-prod", Recipient: "ops@example.com", Metadata: map[string]string{"issuer": "letsencrypt"}, CreatedAt: time.Now(), } err := conn.SendEvent(context.Background(), event) if err != nil { t.Fatalf("unexpected error: %v", err) } if receivedPayload["type"] != "event" { t.Errorf("expected type 'event', got %v", receivedPayload["type"]) } if receivedPayload["event_id"] != "event-123" { t.Errorf("expected event_id 'event-123', got %v", receivedPayload["event_id"]) } if receivedPayload["event_type"] != "issued" { t.Errorf("expected event_type 'issued', got %v", receivedPayload["event_type"]) } if receivedPayload["certificate_id"] != "mc-api-prod" { t.Errorf("expected certificate_id 'mc-api-prod', got %v", receivedPayload["certificate_id"]) } } func TestWebhook_SendEvent_WithoutCertificateID(t *testing.T) { var receivedPayload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil { t.Fatalf("failed to decode payload: %v", err) } w.WriteHeader(http.StatusOK) })) defer server.Close() cfg := &Config{ URL: server.URL, } logger := newTestLogger() conn := newForTest(cfg, logger) event := notifier.Event{ ID: "event-456", Type: "test", Subject: "Test Event", Body: "Test body", Recipient: "test@example.com", CreatedAt: time.Now(), } err := conn.SendEvent(context.Background(), event) if err != nil { t.Fatalf("unexpected error: %v", err) } // Ensure certificate_id is not in payload when nil if _, hasKey := receivedPayload["certificate_id"]; hasKey && receivedPayload["certificate_id"] != nil { t.Errorf("expected no certificate_id in payload, got %v", receivedPayload["certificate_id"]) } } // The SSRF tests below exercise the CWE-918 guard added alongside H-4. Each // case pairs a reserved-address URL with the call surface that should reject // it. ValidateConfig is the early-fail path; SendAlert/SendEvent reach the // same guard via postWebhook and are the defence-in-depth that still rejects // even when ValidateConfig was bypassed (e.g. dynamic config reload mutating // c.config.URL in place). func TestWebhook_ValidateConfig_RejectsReservedURLs(t *testing.T) { // These must all fail at config-ingestion time without ever opening a // socket — the reserved-address filter is the whole point of H-4. cases := []struct { name string url string }{ {"loopback v4", "http://127.0.0.1/hook"}, {"loopback v4 with port", "http://127.0.0.1:8080/"}, {"loopback v6 bracketed", "http://[::1]/hook"}, {"AWS metadata", "http://169.254.169.254/latest/meta-data/"}, {"generic link-local", "http://169.254.1.2/"}, {"unspecified v4", "http://0.0.0.0/"}, {"unspecified v6", "http://[::]/"}, {"IPv6 link-local", "http://[fe80::1]/"}, {"multicast", "https://224.0.0.5/"}, {"broadcast", "http://255.255.255.255/"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := &Config{URL: tc.url} rawConfig, _ := json.Marshal(cfg) conn := New(cfg, newTestLogger()) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil { t.Fatalf("ValidateConfig(%q) returned nil, want SSRF rejection", tc.url) } if !strings.Contains(err.Error(), "reserved") && !strings.Contains(err.Error(), "rejected") { t.Errorf("expected reserved/rejected error, got %q", err.Error()) } }) } } func TestWebhook_ValidateConfig_RejectsDangerousSchemes(t *testing.T) { // Only http(s) is a legitimate webhook transport. Every other scheme is // an SSRF amplifier (file, gopher, ftp, javascript, data, ldap, dict, // jar) and must be refused at config time. cases := []struct { name string url string }{ {"file", "file:///etc/passwd"}, {"gopher", "gopher://example.com/_x"}, {"ftp", "ftp://example.com/"}, {"javascript", "javascript:alert(1)"}, {"data", "data:text/plain;base64,SGVsbG8="}, {"ldap", "ldap://example.com/"}, {"dict", "dict://example.com:2628/d:foo"}, {"jar", "jar:http://example.com/foo.jar!/"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := &Config{URL: tc.url} rawConfig, _ := json.Marshal(cfg) conn := New(cfg, newTestLogger()) err := conn.ValidateConfig(context.Background(), rawConfig) if err == nil { t.Fatalf("ValidateConfig(%q) returned nil, want scheme rejection", tc.url) } if !strings.Contains(err.Error(), "rejected") && !strings.Contains(err.Error(), "scheme") { t.Errorf("expected scheme/rejected error, got %q", err.Error()) } }) } } func TestWebhook_SendAlert_RejectsReservedURLInPostWebhook(t *testing.T) { // Simulate config drift: URL was legitimate at ValidateConfig time but // has since been rewritten to an SSRF target. postWebhook must catch // this on every call without ever hitting the wire. cfg := &Config{URL: "http://169.254.169.254/latest/meta-data/"} conn := New(cfg, newTestLogger()) alert := notifier.Alert{ ID: "alert-ssrf", Type: "test", Severity: "info", Subject: "Test", Message: "Test", Recipient: "ops@example.com", CreatedAt: time.Now(), } err := conn.SendAlert(context.Background(), alert) if err == nil { t.Fatal("SendAlert returned nil, want SSRF rejection from postWebhook") } if !strings.Contains(err.Error(), "reserved") && !strings.Contains(err.Error(), "rejected") { t.Errorf("expected reserved/rejected error, got %q", err.Error()) } } func TestWebhook_SendEvent_RejectsReservedURLInPostWebhook(t *testing.T) { cfg := &Config{URL: "http://[::1]:9/webhook"} conn := New(cfg, newTestLogger()) event := notifier.Event{ ID: "event-ssrf", Type: "test", Subject: "Test", Body: "Test", Recipient: "ops@example.com", CreatedAt: time.Now(), } err := conn.SendEvent(context.Background(), event) if err == nil { t.Fatal("SendEvent returned nil, want SSRF rejection from postWebhook") } if !strings.Contains(err.Error(), "reserved") && !strings.Contains(err.Error(), "rejected") { t.Errorf("expected reserved/rejected error, got %q", err.Error()) } } // Helper function to compute HMAC-SHA256 signature func computeHMACSHA256(data []byte, secret string) string { h := hmac.New(sha256.New, []byte(secret)) h.Write(data) signature := hex.EncodeToString(h.Sum(nil)) return fmt.Sprintf("sha256=%s", signature) } // Helper function to create a test logger func newTestLogger() *slog.Logger { // Return a discard logger for tests return slog.New(slog.NewTextHandler(io.Discard, nil)) }