File size: 5,045 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | import { convexTest } from "convex-test";
import { afterEach, describe, expect, test, vi } from "vitest";
import schema from "../schema";
const modules = import.meta.glob("../**/*.ts");
const TEST_NOW_SECONDS = 1_700_000_000;
const TEST_NOW_MS = TEST_NOW_SECONDS * 1000;
const SECRET_BYTES = new Uint8Array([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87,
0x98, 0xa9, 0xba, 0xcb, 0xdc, 0xed, 0xfe, 0x0f,
]);
const RESEND_WEBHOOK_SECRET = `whsec_${btoa(String.fromCharCode(...SECRET_BYTES))}`;
function makePayload(): string {
return JSON.stringify({
type: "email.opened",
created_at: "2023-11-14T22:13:20.000Z",
data: { email_id: "email_test_resend_signature" },
});
}
async function signPayload(
payload: string,
{
messageId = "msg_test_resend_signature",
timestamp = String(TEST_NOW_SECONDS),
}: { messageId?: string; timestamp?: string } = {},
): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
SECRET_BYTES,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const toSign = `${messageId}.${timestamp}.${payload}`;
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(toSign),
);
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
function replaceFirstBase64Char(value: string): string {
return `${value[0] === "A" ? "B" : "A"}${value.slice(1)}`;
}
async function postResendWebhook(
svixSignature: string | undefined,
{
payload = makePayload(),
messageId = "msg_test_resend_signature",
timestamp = String(TEST_NOW_SECONDS),
}: { payload?: string; messageId?: string; timestamp?: string } = {},
) {
const t = convexTest(schema, modules);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"svix-id": messageId,
"svix-timestamp": timestamp,
};
if (svixSignature !== undefined) {
headers["svix-signature"] = svixSignature;
}
return await t.fetch("/resend-webhook", {
method: "POST",
headers,
body: payload,
});
}
describe("Resend webhook signature verification (#4678)", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.RESEND_WEBHOOK_SECRET;
});
test("accepts a valid Svix/Resend signature", async () => {
vi.spyOn(Date, "now").mockReturnValue(TEST_NOW_MS);
process.env.RESEND_WEBHOOK_SECRET = RESEND_WEBHOOK_SECRET;
const payload = makePayload();
const signature = await signPayload(payload);
const res = await postResendWebhook(`v1,${signature}`, { payload });
expect(res.status).toBe(200);
});
test("rejects an invalid same-length signature", async () => {
vi.spyOn(Date, "now").mockReturnValue(TEST_NOW_MS);
process.env.RESEND_WEBHOOK_SECRET = RESEND_WEBHOOK_SECRET;
const payload = makePayload();
const signature = await signPayload(payload);
const invalidSignature = replaceFirstBase64Char(signature);
const res = await postResendWebhook(`v1,${invalidSignature}`, { payload });
expect(res.status).toBe(401);
expect(await res.text()).toBe("Invalid signature");
});
test("rejects an invalid different-length signature", async () => {
vi.spyOn(Date, "now").mockReturnValue(TEST_NOW_MS);
process.env.RESEND_WEBHOOK_SECRET = RESEND_WEBHOOK_SECRET;
const payload = makePayload();
const signature = await signPayload(payload);
const res = await postResendWebhook(`v1,${signature.slice(0, -4)}`, {
payload,
});
expect(res.status).toBe(401);
expect(await res.text()).toBe("Invalid signature");
});
test("rejects malformed or missing signature headers", async () => {
vi.spyOn(Date, "now").mockReturnValue(TEST_NOW_MS);
process.env.RESEND_WEBHOOK_SECRET = RESEND_WEBHOOK_SECRET;
const payload = makePayload();
const signature = await signPayload(payload);
const malformed = await postResendWebhook("not-a-pair v1,");
const extraFields = await postResendWebhook(`v1,${signature},extra`, {
payload,
});
const missing = await postResendWebhook(undefined);
expect(malformed.status).toBe(401);
expect(await malformed.text()).toBe("Invalid signature");
expect(extraFields.status).toBe(401);
expect(await extraFields.text()).toBe("Invalid signature");
expect(missing.status).toBe(401);
expect(await missing.text()).toBe("Invalid signature");
});
test("rejects stale timestamps", async () => {
vi.spyOn(Date, "now").mockReturnValue(TEST_NOW_MS);
process.env.RESEND_WEBHOOK_SECRET = RESEND_WEBHOOK_SECRET;
const payload = makePayload();
const staleTimestamp = String(TEST_NOW_SECONDS - 301);
const signature = await signPayload(payload, { timestamp: staleTimestamp });
const res = await postResendWebhook(`v1,${signature}`, {
payload,
timestamp: staleTimestamp,
});
expect(res.status).toBe(401);
expect(await res.text()).toBe("Invalid signature");
});
});
|