Skip to content

Commit 09b2a7e

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

File tree

4 files changed

+294
-0
lines changed

4 files changed

+294
-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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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: "payment",
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+
console.log("Payload string:", v1PayloadString); // Debug log
128+
const signature = await generateSignature(timestamp, v1PayloadString);
129+
const headers = {
130+
"x-payload-signature": signature,
131+
"x-timestamp": timestamp,
132+
};
133+
134+
await expect(parse(v1PayloadString, headers, secret)).rejects.toThrow(
135+
"Invalid webhook payload: version 1 is no longer supported, please upgrade to webhook version 2.",
136+
);
137+
});
138+
139+
it("should accept payload within tolerance window", async () => {
140+
const recentTimestamp = (Math.floor(Date.now() / 1000) - 200).toString(); // 200 seconds old
141+
const signature = await generateSignature(recentTimestamp, payloadString);
142+
const headers = {
143+
"x-payload-signature": signature,
144+
"x-timestamp": recentTimestamp,
145+
};
146+
147+
const result = await parse(payloadString, headers, secret, 300);
148+
expect(result).toEqual(validPayload);
149+
});
150+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Parses an incoming webhook from thirdweb.
3+
*
4+
* @param payload - The raw text body received from thirdweb.
5+
* @param headers - The webhook headers received from thirdweb.
6+
* @param secret - The webhook secret to verify the payload with.
7+
*/
8+
export async function parse<T extends Record<string, unknown>>(
9+
/**
10+
* Raw text body received from thirdweb.
11+
*/
12+
payload: string,
13+
14+
/**
15+
* The webhook headers received from thirdweb.
16+
*/
17+
headers: Record<string, string>,
18+
19+
/**
20+
* The webhook secret to verify the payload with.
21+
*/
22+
secret: string,
23+
24+
/**
25+
* The tolerance in seconds for the timestamp verification.
26+
*/
27+
tolerance = 300, // Default to 5 minutes if not specified
28+
) {
29+
// Get the signature and timestamp from headers
30+
const receivedSignature =
31+
headers["x-payload-signature"] || headers["x-pay-signature"];
32+
const receivedTimestamp =
33+
headers["x-timestamp"] || headers["x-pay-timestamp"];
34+
35+
if (!receivedSignature || !receivedTimestamp) {
36+
throw new Error("Missing required webhook headers: signature or timestamp");
37+
}
38+
39+
// Verify timestamp
40+
const now = Math.floor(Date.now() / 1000);
41+
const timestamp = Number.parseInt(receivedTimestamp, 10);
42+
const diff = Math.abs(now - timestamp);
43+
44+
if (diff > tolerance) {
45+
throw new Error(
46+
`Webhook timestamp is too old. Difference: ${diff}s, tolerance: ${tolerance}s`,
47+
);
48+
}
49+
50+
// Generate signature using the same method as the sender
51+
const encoder = new TextEncoder();
52+
const key = await crypto.subtle.importKey(
53+
"raw",
54+
encoder.encode(secret),
55+
{ name: "HMAC", hash: "SHA-256" },
56+
false,
57+
["sign"],
58+
);
59+
60+
const signature = await crypto.subtle.sign(
61+
"HMAC",
62+
key,
63+
encoder.encode(`${receivedTimestamp}.${payload}`),
64+
);
65+
66+
// Convert the signature to hex string
67+
const computedSignature = Array.from(new Uint8Array(signature))
68+
.map((b) => b.toString(16).padStart(2, "0"))
69+
.join("");
70+
71+
// Compare signatures
72+
if (computedSignature !== receivedSignature) {
73+
throw new Error("Invalid webhook signature");
74+
}
75+
76+
// Parse the payload as JSON
77+
let parsedPayload: WebhookPayload<T>;
78+
try {
79+
parsedPayload = JSON.parse(payload) as WebhookPayload<T>;
80+
} catch {
81+
throw new Error("Invalid webhook payload: not valid JSON");
82+
}
83+
84+
// Check version after successful JSON parsing
85+
if (parsedPayload.version === 1) {
86+
throw new Error(
87+
"Invalid webhook payload: version 1 is no longer supported, please upgrade to webhook version 2.",
88+
);
89+
}
90+
91+
return parsedPayload;
92+
}
93+
94+
export type WebhookPayload<T = Record<string, unknown>> =
95+
| {
96+
version: 1;
97+
data: Record<string, unknown>;
98+
}
99+
| {
100+
version: 2;
101+
data: {
102+
transactionId: string;
103+
paymentId: string;
104+
clientId: string;
105+
action: string;
106+
status: string;
107+
originToken: string;
108+
originAmount: string;
109+
destinationToken: `0x${string}`;
110+
destinationAmount: string;
111+
sender: `0x${string}`;
112+
receiver: `0x${string}`;
113+
type: string;
114+
transactions: string[];
115+
developerFeeBps: number;
116+
developerFeeRecipient: `0x${string}`;
117+
purchaseData: T;
118+
};
119+
};

packages/thirdweb/src/bridge/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ export * as Buy from "./Buy.js";
22
export * as Sell from "./Sell.js";
33
export * as Transfer from "./Transfer.js";
44
export * as Onramp from "./Onramp.js";
5+
export * as Webhook from "./Webhook.js";
56
export { status } from "./Status.js";
67
export { routes } from "./Routes.js";
78
export { chains } from "./Chains.js";
9+
export { parse } from "./Webhook.js";
810

911
export type { Chain } from "./types/Chain.js";
1012
export type { Quote, PreparedQuote } from "./types/Quote.js";
@@ -17,3 +19,4 @@ export type {
1719
export type { Status } from "./types/Status.js";
1820
export type { Token } from "./types/Token.js";
1921
export type { BridgeAction } from "./types/BridgeAction.js";
22+
export type { WebhookPayload } from "./Webhook.js";

0 commit comments

Comments
 (0)