Skip to content

Commit bbe5dfc

Browse files
committed
Add webhook verification functionality for secure payload validation
1 parent 99901dc commit bbe5dfc

File tree

4 files changed

+642
-0
lines changed

4 files changed

+642
-0
lines changed

.changeset/webhook-verification.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Added webhook verification functionality to securely verify incoming webhooks from thirdweb. This includes:
6+
7+
- New `Webhook.parse` function to verify webhook signatures and timestamps
8+
- Support for both `x-payload-signature` and `x-pay-signature` header formats
9+
- Timestamp verification with configurable tolerance
10+
- Version 2 webhook payload type support
11+
12+
Example usage:
13+
```typescript
14+
import { Webhook } from "thirdweb/bridge";
15+
16+
const webhook = await Webhook.parse(
17+
payload,
18+
headers,
19+
secret,
20+
300 // optional tolerance in seconds
21+
);
22+
```
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import { describe, expect, it } from "vitest";
2+
import { type WebhookPayload, parse } from "./Webhook.js";
3+
4+
describe("parseIncomingWebhook", () => {
5+
const secret = "test-secret";
6+
const timestamp = Math.floor(Date.now() / 1000).toString();
7+
const validPayload: WebhookPayload = {
8+
version: 2,
9+
data: {
10+
transactionId: "tx123",
11+
paymentId: "pay123",
12+
clientId: "client123",
13+
action: "TRANSFER",
14+
status: "COMPLETED",
15+
originToken: "ETH",
16+
originAmount: "1.0",
17+
destinationToken: "0x1234567890123456789012345678901234567890",
18+
destinationAmount: "1.0",
19+
sender: "0x1234567890123456789012345678901234567890",
20+
receiver: "0x1234567890123456789012345678901234567890",
21+
type: "transfer",
22+
transactions: ["tx1", "tx2"],
23+
developerFeeBps: 100,
24+
developerFeeRecipient: "0x1234567890123456789012345678901234567890",
25+
purchaseData: {},
26+
},
27+
};
28+
29+
const payloadString = JSON.stringify(validPayload);
30+
31+
// Helper function to generate signature
32+
const generateSignature = async (timestamp: string, payload: string) => {
33+
const encoder = new TextEncoder();
34+
const key = await crypto.subtle.importKey(
35+
"raw",
36+
encoder.encode(secret),
37+
{ name: "HMAC", hash: "SHA-256" },
38+
false,
39+
["sign"],
40+
);
41+
42+
const signature = await crypto.subtle.sign(
43+
"HMAC",
44+
key,
45+
encoder.encode(`${timestamp}.${payload}`),
46+
);
47+
48+
return Array.from(new Uint8Array(signature))
49+
.map((b) => b.toString(16).padStart(2, "0"))
50+
.join("");
51+
};
52+
53+
it("should successfully verify a valid webhook", async () => {
54+
const signature = await generateSignature(timestamp, payloadString);
55+
const headers = {
56+
"x-payload-signature": signature,
57+
"x-timestamp": timestamp,
58+
};
59+
60+
const result = await parse(payloadString, headers, secret);
61+
expect(result).toEqual(validPayload);
62+
});
63+
64+
it("should accept alternative header names", async () => {
65+
const signature = await generateSignature(timestamp, payloadString);
66+
const headers = {
67+
"x-pay-signature": signature,
68+
"x-pay-timestamp": timestamp,
69+
};
70+
71+
const result = await parse(payloadString, headers, secret);
72+
expect(result).toEqual(validPayload);
73+
});
74+
75+
it("should throw error for missing headers", async () => {
76+
const headers = {};
77+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
78+
"Missing required webhook headers: signature or timestamp",
79+
);
80+
});
81+
82+
it("should throw error for invalid signature", async () => {
83+
const headers = {
84+
"x-payload-signature": "invalid-signature",
85+
"x-timestamp": timestamp,
86+
};
87+
88+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
89+
"Invalid webhook signature",
90+
);
91+
});
92+
93+
it("should throw error for expired timestamp", async () => {
94+
const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString(); // 400 seconds old
95+
const signature = await generateSignature(oldTimestamp, payloadString);
96+
const headers = {
97+
"x-payload-signature": signature,
98+
"x-timestamp": oldTimestamp,
99+
};
100+
101+
await expect(parse(payloadString, headers, secret, 300)).rejects.toThrow(
102+
"Webhook timestamp is too old",
103+
);
104+
});
105+
106+
it("should throw error for invalid JSON payload", async () => {
107+
const invalidPayload = "invalid-json";
108+
const signature = await generateSignature(timestamp, invalidPayload);
109+
const headers = {
110+
"x-payload-signature": signature,
111+
"x-timestamp": timestamp,
112+
};
113+
114+
await expect(parse(invalidPayload, headers, secret)).rejects.toThrow(
115+
"Invalid webhook payload: not valid JSON",
116+
);
117+
});
118+
119+
it("should throw error for version 1 payload", async () => {
120+
const v1Payload = {
121+
version: 1,
122+
data: {
123+
someField: "value",
124+
},
125+
};
126+
const v1PayloadString = JSON.stringify(v1Payload);
127+
const signature = await generateSignature(timestamp, v1PayloadString);
128+
const headers = {
129+
"x-payload-signature": signature,
130+
"x-timestamp": timestamp,
131+
};
132+
133+
await expect(parse(v1PayloadString, headers, secret)).rejects.toThrow(
134+
"Invalid webhook payload: version 1 is no longer supported, please upgrade to webhook version 2.",
135+
);
136+
});
137+
138+
it("should accept payload within tolerance window", async () => {
139+
const recentTimestamp = (Math.floor(Date.now() / 1000) - 200).toString(); // 200 seconds old
140+
const signature = await generateSignature(recentTimestamp, payloadString);
141+
const headers = {
142+
"x-payload-signature": signature,
143+
"x-timestamp": recentTimestamp,
144+
};
145+
146+
const result = await parse(payloadString, headers, secret, 300);
147+
expect(result).toEqual(validPayload);
148+
});
149+
150+
describe("payload validation", () => {
151+
it("should throw error for non-object payload", async () => {
152+
const invalidPayload = JSON.stringify("not-an-object");
153+
const signature = await generateSignature(timestamp, invalidPayload);
154+
const headers = {
155+
"x-payload-signature": signature,
156+
"x-timestamp": timestamp,
157+
};
158+
159+
await expect(parse(invalidPayload, headers, secret)).rejects.toThrow(
160+
"Invalid webhook payload: must be an object",
161+
);
162+
});
163+
164+
it("should throw error for missing version", async () => {
165+
const invalidPayload = {
166+
data: {
167+
transactionId: "tx123",
168+
},
169+
};
170+
const payloadString = JSON.stringify(invalidPayload);
171+
const signature = await generateSignature(timestamp, payloadString);
172+
const headers = {
173+
"x-payload-signature": signature,
174+
"x-timestamp": timestamp,
175+
};
176+
177+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
178+
"Invalid webhook payload: version must be a number",
179+
);
180+
});
181+
182+
it("should throw error for missing data object", async () => {
183+
const invalidPayload = {
184+
version: 2,
185+
};
186+
const payloadString = JSON.stringify(invalidPayload);
187+
const signature = await generateSignature(timestamp, payloadString);
188+
const headers = {
189+
"x-payload-signature": signature,
190+
"x-timestamp": timestamp,
191+
};
192+
193+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
194+
"Invalid webhook payload: version 2 must have a data object",
195+
);
196+
});
197+
198+
it("should throw error for missing required fields", async () => {
199+
const invalidPayload = {
200+
version: 2,
201+
data: {
202+
transactionId: "tx123",
203+
// Missing other required fields
204+
},
205+
};
206+
const payloadString = JSON.stringify(invalidPayload);
207+
const signature = await generateSignature(timestamp, payloadString);
208+
const headers = {
209+
"x-payload-signature": signature,
210+
"x-timestamp": timestamp,
211+
};
212+
213+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
214+
"Invalid webhook payload: missing required field 'paymentId'",
215+
);
216+
});
217+
218+
it("should throw error for invalid action type", async () => {
219+
const invalidPayload = {
220+
version: 2,
221+
data: {
222+
...validPayload.data,
223+
action: "INVALID_ACTION", // Invalid action type
224+
},
225+
};
226+
const payloadString = JSON.stringify(invalidPayload);
227+
const signature = await generateSignature(timestamp, payloadString);
228+
const headers = {
229+
"x-payload-signature": signature,
230+
"x-timestamp": timestamp,
231+
};
232+
233+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
234+
"Invalid webhook payload: action must be one of: TRANSFER, BUY, SELL",
235+
);
236+
});
237+
238+
it("should throw error for invalid status type", async () => {
239+
const invalidPayload = {
240+
version: 2,
241+
data: {
242+
...validPayload.data,
243+
status: "INVALID_STATUS", // Invalid status type
244+
},
245+
};
246+
const payloadString = JSON.stringify(invalidPayload);
247+
const signature = await generateSignature(timestamp, payloadString);
248+
const headers = {
249+
"x-payload-signature": signature,
250+
"x-timestamp": timestamp,
251+
};
252+
253+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
254+
"Invalid webhook payload: status must be one of: PENDING, FAILED, COMPLETED",
255+
);
256+
});
257+
258+
it("should throw error for invalid hex address", async () => {
259+
const invalidPayload = {
260+
version: 2,
261+
data: {
262+
...validPayload.data,
263+
destinationToken: "invalid-address", // Invalid hex address
264+
},
265+
};
266+
const payloadString = JSON.stringify(invalidPayload);
267+
const signature = await generateSignature(timestamp, payloadString);
268+
const headers = {
269+
"x-payload-signature": signature,
270+
"x-timestamp": timestamp,
271+
};
272+
273+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
274+
"Invalid webhook payload: destinationToken must be a valid hex address",
275+
);
276+
});
277+
278+
it("should throw error for invalid transactions array", async () => {
279+
const invalidPayload = {
280+
version: 2,
281+
data: {
282+
...validPayload.data,
283+
transactions: "not-an-array", // Invalid transactions type
284+
},
285+
};
286+
const payloadString = JSON.stringify(invalidPayload);
287+
const signature = await generateSignature(timestamp, payloadString);
288+
const headers = {
289+
"x-payload-signature": signature,
290+
"x-timestamp": timestamp,
291+
};
292+
293+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
294+
"Invalid webhook payload: transactions must be an array",
295+
);
296+
});
297+
298+
it("should throw error for invalid developerFeeBps type", async () => {
299+
const invalidPayload = {
300+
version: 2,
301+
data: {
302+
...validPayload.data,
303+
developerFeeBps: "100", // Invalid type (string instead of number)
304+
},
305+
};
306+
const payloadString = JSON.stringify(invalidPayload);
307+
const signature = await generateSignature(timestamp, payloadString);
308+
const headers = {
309+
"x-payload-signature": signature,
310+
"x-timestamp": timestamp,
311+
};
312+
313+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
314+
"Invalid webhook payload: developerFeeBps must be a number",
315+
);
316+
});
317+
318+
it("should throw error for invalid purchaseData type", async () => {
319+
const invalidPayload = {
320+
version: 2,
321+
data: {
322+
...validPayload.data,
323+
purchaseData: null, // Invalid purchaseData type
324+
},
325+
};
326+
const payloadString = JSON.stringify(invalidPayload);
327+
const signature = await generateSignature(timestamp, payloadString);
328+
const headers = {
329+
"x-payload-signature": signature,
330+
"x-timestamp": timestamp,
331+
};
332+
333+
await expect(parse(payloadString, headers, secret)).rejects.toThrow(
334+
"Invalid webhook payload: purchaseData must be an object",
335+
);
336+
});
337+
});
338+
});

0 commit comments

Comments
 (0)