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");
  });
});