Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding svix for webhook validation adds almost 1 MB to the JS bundle #1483

Open
mlafeldt opened this issue Oct 11, 2024 · 2 comments
Open

Adding svix for webhook validation adds almost 1 MB to the JS bundle #1483

mlafeldt opened this issue Oct 11, 2024 · 2 comments
Assignees
Labels
lib/javascript JavaScript client library

Comments

@mlafeldt
Copy link

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:

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.)

@mlafeldt
Copy link
Author

mlafeldt commented Oct 11, 2024

Looks like I could also use https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/javascript as a lightweight alternative. However, it expects different non-branded headers...

Update: This works perfectly fine.

  // Map unbranded webhook headers to Svix headers
  const svixHeaders = {
    'webhook-id': request.headers.get('svix-id') as string,
    'webhook-timestamp': request.headers.get('svix-timestamp') as string,
    'webhook-signature': request.headers.get('svix-signature') as string,
  }

So, I'm now using the tiny standardwebhooks package for validation. 👍

@svix-jplatte
Copy link
Member

Some work has already happened on this by a contributor in #1179.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
lib/javascript JavaScript client library
Projects
None yet
Development

No branches or pull requests

3 participants