From 3d1a56245a026130ea76d260f5a9221a91fa4a82 Mon Sep 17 00:00:00 2001 From: Alex Weil Date: Fri, 11 Jul 2025 10:29:18 -0700 Subject: [PATCH] Improve webhook request validation and test coverage --- go.mod | 2 + go.sum | 2 + webhooks/webhooks.go | 11 ++- webhooks/webhooks_test.go | 163 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 webhooks/webhooks_test.go diff --git a/go.mod b/go.mod index b507f57..3ceab94 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( golang.org/x/crypto v0.23.0 ) +require github.com/google/go-cmp v0.7.0 // indirect + require ( github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect github.com/btcsuite/btcd/btcutil v1.1.5 diff --git a/go.sum b/go.sum index e50ef3b..db20191 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= diff --git a/webhooks/webhooks.go b/webhooks/webhooks.go index 74250c3..a4dfc85 100644 --- a/webhooks/webhooks.go +++ b/webhooks/webhooks.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "strings" "time" "github.com/lightsparkdev/go-sdk/objects" @@ -36,9 +35,15 @@ func VerifyAndParse(data []byte, hexdigest string, webhookSecret string) (*Webho hash := hmac.New(sha256.New, []byte(webhookSecret)) hash.Write(data) result := hash.Sum(nil) - if strings.ToLower(hex.EncodeToString(result)) != strings.ToLower(hexdigest) { - return nil, errors.New("Webhook message hash does not match signature") + + headerBytes, err := hex.DecodeString(hexdigest) + if err != nil { + return nil, errors.New("invalid message signature format") } + if !hmac.Equal(result, headerBytes) { + return nil, errors.New("webhook message hash does not match signature") + } + return Parse(data) } diff --git a/webhooks/webhooks_test.go b/webhooks/webhooks_test.go new file mode 100644 index 0000000..bce77f8 --- /dev/null +++ b/webhooks/webhooks_test.go @@ -0,0 +1,163 @@ +package webhooks + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/lightsparkdev/go-sdk/objects" +) + +func TestVerifyAndParse(t *testing.T) { + tests := []struct { + name string + data string + webhookSecret string + want *WebhookEvent + }{ + { + name: "payment finished", + data: `{ + "event_type": "PAYMENT_FINISHED", + "event_id": "test-event-123", + "timestamp": "2025-01-01T12:00:00Z", + "entity_id": "invoice-entity-456", + "wallet_id": "wallet-789", + "data": { + "amount": 1000, + "currency": "USD" + } + }`, + webhookSecret: "test-secret-key", + want: &WebhookEvent{ + EventType: objects.WebhookEventTypePaymentFinished, + EventId: "test-event-123", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + EntityId: "invoice-entity-456", + WalletId: stringPtr("wallet-789"), + Data: &map[string]any{"amount": json.Number("1000"), "currency": "USD"}, + }, + }, + { + name: "no wallet_id", + data: `{ + "event_type": "WALLET_OUTGOING_PAYMENT_FINISHED", + "event_id": "payment-event-456", + "timestamp": "2025-01-02T15:30:00Z", + "entity_id": "payment-entity-789", + "data": { + "status": "COMPLETED" + } + }`, + webhookSecret: "test-secret-key", + want: &WebhookEvent{ + EventType: objects.WebhookEventTypeWalletOutgoingPaymentFinished, + EventId: "payment-event-456", + Timestamp: time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC), + EntityId: "payment-entity-789", + WalletId: nil, + Data: &map[string]any{"status": "COMPLETED"}, + }, + }, + { + name: "empty data", + data: `{ + "event_type": "NODE_STATUS", + "event_id": "node-event-789", + "timestamp": "2025-01-03T09:15:00Z", + "entity_id": "node-entity-123", + "wallet_id": "wallet-456" + }`, + webhookSecret: "test-secret-key", + want: &WebhookEvent{ + EventType: objects.WebhookEventTypeNodeStatus, + EventId: "node-event-789", + Timestamp: time.Date(2025, 1, 3, 9, 15, 0, 0, time.UTC), + EntityId: "node-entity-123", + WalletId: stringPtr("wallet-456"), + Data: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hexDigest := hexHMAC(tt.webhookSecret, tt.data) + + result, err := VerifyAndParse([]byte(tt.data), hexDigest, tt.webhookSecret) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if diff := cmp.Diff(tt.want, result); diff != "" { + t.Fatalf("WebhookEvent mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestVerifyAndParse_InvalidSignature_Errors(t *testing.T) { + tests := []struct { + name string + data string + hexdigest string + webhookSecret string + wantErr string + }{ + { + name: "invalid signature", + data: `{ + "event_type": "PAYMENT_FINISHED", + "event_id": "test-event-123", + "timestamp": "2025-01-01T12:00:00Z", + "entity_id": "invoice-entity-456" + }`, + hexdigest: "a1b2c3d4e5f6", + webhookSecret: "test-secret-key", + wantErr: "webhook message hash does not match signature", + }, + { + name: "malformed hex signature", + data: `{ + "event_type": "PAYMENT_FINISHED", + "event_id": "test-event-123", + "timestamp": "2025-01-01T12:00:00Z", + "entity_id": "invoice-entity-456" + }`, + hexdigest: "not-a-valid-hex-string", + webhookSecret: "test-secret-key", + wantErr: "invalid message signature format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := VerifyAndParse([]byte(tt.data), tt.hexdigest, tt.webhookSecret) + + if got != nil { + t.Errorf("VerifyAndParse() got = %v, want nil", got) + } + if err == nil { + t.Fatalf("Expected error but got none") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Expected error to contain '%s', but got '%s'", tt.wantErr, err.Error()) + } + }) + } +} + +func hexHMAC(secret, data string) string { + hash := hmac.New(sha256.New, []byte(secret)) + hash.Write([]byte(data)) + return hex.EncodeToString(hash.Sum(nil)) +} + +func stringPtr(s string) *string { + return &s +}