Skip to content

Commit 340a940

Browse files
authored
Improve webhook request validation and test coverage (#181)
1 parent 0571903 commit 340a940

4 files changed

Lines changed: 175 additions & 3 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ require (
1111
golang.org/x/crypto v0.23.0
1212
)
1313

14+
require github.com/google/go-cmp v0.7.0 // indirect
15+
1416
require (
1517
github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
1618
github.com/btcsuite/btcd/btcutil v1.1.5

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
5555
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
5656
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
5757
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
58+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
59+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5860
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
5961
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
6062
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=

webhooks/webhooks.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"encoding/hex"
99
"encoding/json"
1010
"errors"
11-
"strings"
1211
"time"
1312

1413
"github.com/lightsparkdev/go-sdk/objects"
@@ -36,9 +35,15 @@ func VerifyAndParse(data []byte, hexdigest string, webhookSecret string) (*Webho
3635
hash := hmac.New(sha256.New, []byte(webhookSecret))
3736
hash.Write(data)
3837
result := hash.Sum(nil)
39-
if strings.ToLower(hex.EncodeToString(result)) != strings.ToLower(hexdigest) {
40-
return nil, errors.New("Webhook message hash does not match signature")
38+
39+
headerBytes, err := hex.DecodeString(hexdigest)
40+
if err != nil {
41+
return nil, errors.New("invalid message signature format")
4142
}
43+
if !hmac.Equal(result, headerBytes) {
44+
return nil, errors.New("webhook message hash does not match signature")
45+
}
46+
4247
return Parse(data)
4348
}
4449

webhooks/webhooks_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package webhooks
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/lightsparkdev/go-sdk/objects"
14+
)
15+
16+
func TestVerifyAndParse(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
data string
20+
webhookSecret string
21+
want *WebhookEvent
22+
}{
23+
{
24+
name: "payment finished",
25+
data: `{
26+
"event_type": "PAYMENT_FINISHED",
27+
"event_id": "test-event-123",
28+
"timestamp": "2025-01-01T12:00:00Z",
29+
"entity_id": "invoice-entity-456",
30+
"wallet_id": "wallet-789",
31+
"data": {
32+
"amount": 1000,
33+
"currency": "USD"
34+
}
35+
}`,
36+
webhookSecret: "test-secret-key",
37+
want: &WebhookEvent{
38+
EventType: objects.WebhookEventTypePaymentFinished,
39+
EventId: "test-event-123",
40+
Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC),
41+
EntityId: "invoice-entity-456",
42+
WalletId: stringPtr("wallet-789"),
43+
Data: &map[string]any{"amount": json.Number("1000"), "currency": "USD"},
44+
},
45+
},
46+
{
47+
name: "no wallet_id",
48+
data: `{
49+
"event_type": "WALLET_OUTGOING_PAYMENT_FINISHED",
50+
"event_id": "payment-event-456",
51+
"timestamp": "2025-01-02T15:30:00Z",
52+
"entity_id": "payment-entity-789",
53+
"data": {
54+
"status": "COMPLETED"
55+
}
56+
}`,
57+
webhookSecret: "test-secret-key",
58+
want: &WebhookEvent{
59+
EventType: objects.WebhookEventTypeWalletOutgoingPaymentFinished,
60+
EventId: "payment-event-456",
61+
Timestamp: time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC),
62+
EntityId: "payment-entity-789",
63+
WalletId: nil,
64+
Data: &map[string]any{"status": "COMPLETED"},
65+
},
66+
},
67+
{
68+
name: "empty data",
69+
data: `{
70+
"event_type": "NODE_STATUS",
71+
"event_id": "node-event-789",
72+
"timestamp": "2025-01-03T09:15:00Z",
73+
"entity_id": "node-entity-123",
74+
"wallet_id": "wallet-456"
75+
}`,
76+
webhookSecret: "test-secret-key",
77+
want: &WebhookEvent{
78+
EventType: objects.WebhookEventTypeNodeStatus,
79+
EventId: "node-event-789",
80+
Timestamp: time.Date(2025, 1, 3, 9, 15, 0, 0, time.UTC),
81+
EntityId: "node-entity-123",
82+
WalletId: stringPtr("wallet-456"),
83+
Data: nil,
84+
},
85+
},
86+
}
87+
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
hexDigest := hexHMAC(tt.webhookSecret, tt.data)
91+
92+
result, err := VerifyAndParse([]byte(tt.data), hexDigest, tt.webhookSecret)
93+
94+
if err != nil {
95+
t.Fatalf("Unexpected error: %v", err)
96+
}
97+
if diff := cmp.Diff(tt.want, result); diff != "" {
98+
t.Fatalf("WebhookEvent mismatch (-want +got):\n%s", diff)
99+
}
100+
})
101+
}
102+
}
103+
104+
func TestVerifyAndParse_InvalidSignature_Errors(t *testing.T) {
105+
tests := []struct {
106+
name string
107+
data string
108+
hexdigest string
109+
webhookSecret string
110+
wantErr string
111+
}{
112+
{
113+
name: "invalid signature",
114+
data: `{
115+
"event_type": "PAYMENT_FINISHED",
116+
"event_id": "test-event-123",
117+
"timestamp": "2025-01-01T12:00:00Z",
118+
"entity_id": "invoice-entity-456"
119+
}`,
120+
hexdigest: "a1b2c3d4e5f6",
121+
webhookSecret: "test-secret-key",
122+
wantErr: "webhook message hash does not match signature",
123+
},
124+
{
125+
name: "malformed hex signature",
126+
data: `{
127+
"event_type": "PAYMENT_FINISHED",
128+
"event_id": "test-event-123",
129+
"timestamp": "2025-01-01T12:00:00Z",
130+
"entity_id": "invoice-entity-456"
131+
}`,
132+
hexdigest: "not-a-valid-hex-string",
133+
webhookSecret: "test-secret-key",
134+
wantErr: "invalid message signature format",
135+
},
136+
}
137+
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
got, err := VerifyAndParse([]byte(tt.data), tt.hexdigest, tt.webhookSecret)
141+
142+
if got != nil {
143+
t.Errorf("VerifyAndParse() got = %v, want nil", got)
144+
}
145+
if err == nil {
146+
t.Fatalf("Expected error but got none")
147+
}
148+
if !strings.Contains(err.Error(), tt.wantErr) {
149+
t.Fatalf("Expected error to contain '%s', but got '%s'", tt.wantErr, err.Error())
150+
}
151+
})
152+
}
153+
}
154+
155+
func hexHMAC(secret, data string) string {
156+
hash := hmac.New(sha256.New, []byte(secret))
157+
hash.Write([]byte(data))
158+
return hex.EncodeToString(hash.Sum(nil))
159+
}
160+
161+
func stringPtr(s string) *string {
162+
return &s
163+
}

0 commit comments

Comments
 (0)