-
Notifications
You must be signed in to change notification settings - Fork 190
Description
Hey,
I've recently started using Svix to handle Clerk webhooks. For this, I tried adding the svix JS package to my Astro project to validate webhook signatures. Unfortunately, this ended up doubling my project's bundle size from 1 MB to 2 MB. 😱
As a workaround, I copied the following code and added @stablelib/base64
+ fast-sha256
as dependencies:
svix-webhooks/javascript/src/index.ts
Lines 803 to 939 in d0443bd
class ExtendableError extends Error { | |
constructor(message: any) { | |
super(message); | |
Object.setPrototypeOf(this, ExtendableError.prototype); | |
this.name = "ExtendableError"; | |
this.stack = new Error(message).stack; | |
} | |
} | |
export class WebhookVerificationError extends ExtendableError { | |
constructor(message: string) { | |
super(message); | |
Object.setPrototypeOf(this, WebhookVerificationError.prototype); | |
this.name = "WebhookVerificationError"; | |
} | |
} | |
export interface WebhookRequiredHeaders { | |
"svix-id": string; | |
"svix-timestamp": string; | |
"svix-signature": string; | |
} | |
export interface WebhookUnbrandedRequiredHeaders { | |
"webhook-id": string; | |
"webhook-timestamp": string; | |
"webhook-signature": string; | |
} | |
export interface WebhookOptions { | |
format?: "raw"; | |
} | |
export class Webhook { | |
private static prefix = "whsec_"; | |
private readonly key: Uint8Array; | |
constructor(secret: string | Uint8Array, options?: WebhookOptions) { | |
if (!secret) { | |
throw new Error("Secret can't be empty."); | |
} | |
if (options?.format === "raw") { | |
if (secret instanceof Uint8Array) { | |
this.key = secret; | |
} else { | |
this.key = Uint8Array.from(secret, (c) => c.charCodeAt(0)); | |
} | |
} else { | |
if (typeof secret !== "string") { | |
throw new Error("Expected secret to be of type string"); | |
} | |
if (secret.startsWith(Webhook.prefix)) { | |
secret = secret.substring(Webhook.prefix.length); | |
} | |
this.key = base64.decode(secret); | |
} | |
} | |
public verify( | |
payload: string | Buffer, | |
headers_: | |
| WebhookRequiredHeaders | |
| WebhookUnbrandedRequiredHeaders | |
| Record<string, string> | |
): unknown { | |
const headers: Record<string, string> = {}; | |
for (const key of Object.keys(headers_)) { | |
headers[key.toLowerCase()] = (headers_ as Record<string, string>)[key]; | |
} | |
let msgId = headers["svix-id"]; | |
let msgSignature = headers["svix-signature"]; | |
let msgTimestamp = headers["svix-timestamp"]; | |
if (!msgSignature || !msgId || !msgTimestamp) { | |
msgId = headers["webhook-id"]; | |
msgSignature = headers["webhook-signature"]; | |
msgTimestamp = headers["webhook-timestamp"]; | |
if (!msgSignature || !msgId || !msgTimestamp) { | |
throw new WebhookVerificationError("Missing required headers"); | |
} | |
} | |
const timestamp = this.verifyTimestamp(msgTimestamp); | |
const computedSignature = this.sign(msgId, timestamp, payload); | |
const expectedSignature = computedSignature.split(",")[1]; | |
const passedSignatures = msgSignature.split(" "); | |
const encoder = new globalThis.TextEncoder(); | |
for (const versionedSignature of passedSignatures) { | |
const [version, signature] = versionedSignature.split(","); | |
if (version !== "v1") { | |
continue; | |
} | |
if (timingSafeEqual(encoder.encode(signature), encoder.encode(expectedSignature))) { | |
return JSON.parse(payload.toString()); | |
} | |
} | |
throw new WebhookVerificationError("No matching signature found"); | |
} | |
public sign(msgId: string, timestamp: Date, payload: string | Buffer): string { | |
if (typeof payload === "string") { | |
// Do nothing, already a string | |
} else if (payload.constructor.name === "Buffer") { | |
payload = payload.toString(); | |
} else { | |
throw new Error("Expected payload to be of type string or Buffer. Please refer to https://docs.svix.com/receiving/verifying-payloads/how for more information."); | |
} | |
const encoder = new TextEncoder(); | |
const timestampNumber = Math.floor(timestamp.getTime() / 1000); | |
const toSign = encoder.encode(`${msgId}.${timestampNumber}.${payload}`); | |
const expectedSignature = base64.encode(sha256.hmac(this.key, toSign)); | |
return `v1,${expectedSignature}`; | |
} | |
private verifyTimestamp(timestampHeader: string): Date { | |
const now = Math.floor(Date.now() / 1000); | |
const timestamp = parseInt(timestampHeader, 10); | |
if (isNaN(timestamp)) { | |
throw new WebhookVerificationError("Invalid Signature Headers"); | |
} | |
if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) { | |
throw new WebhookVerificationError("Message timestamp too old"); | |
} | |
if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) { | |
throw new WebhookVerificationError("Message timestamp too new"); | |
} | |
return new Date(timestamp * 1000); | |
} | |
} |
Surprisingly, this had almost no impact on the bundle size.
I'm no expert on ESM or bundling, but there seems to be a problem with the code's structure that prevents proper tree shaking.
This is especially concerning in a constrained environment like Cloudflare Workers/Pages, where bundle size is (even more) important.
(Happy to provide more info if needed.)