Repository navigation
Expand file tree
/
Copy pathWebhookSignature.ts
More file actions
65 lines (59 loc) · 2.87 KB
/
Copy pathWebhookSignature.ts
File metadata and controls
65 lines (59 loc) · 2.87 KB
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
/**
* Checks webhook requests' X-DocSpring-Signature header (https://docspring.com/docs/webhooks/verifying-signatures/).
*/
import * as crypto from "crypto";
/** The number of seconds a webhook request's signature stays valid (verifyWebhookSignature) */
export const WEBHOOK_SIGNATURE_TOLERANCE = 300;
export interface VerifyWebhookSignatureOptions {
/** How many seconds old (or early) the timestamp can be. 5 minutes by default. */
tolerance?: number;
/** The current Unix time in seconds, for tests. The system clock by default. */
now?: number;
}
/**
* Checks a webhook request's X-DocSpring-Signature header. Returns true when one of its v1 signatures is the
* HMAC-SHA256 of "{t}.{payload}" with the webhook's secret, and its timestamp t is at most `tolerance` seconds from
* `now`. Returns false for anything else, including a missing or malformed header.
*
* @param payload The request body exactly as it was received (a string, or a Buffer from express.raw()), not parsed
* JSON.
* @param signatureHeader The X-DocSpring-Signature header.
* @param secret The webhook's signing secret.
*/
export function verifyWebhookSignature(
payload: string | Uint8Array,
signatureHeader: string | null | undefined,
secret: string,
options: VerifyWebhookSignatureOptions = {}
): boolean {
const tolerance = options.tolerance ?? WEBHOOK_SIGNATURE_TOLERANCE;
let body: Buffer;
if (typeof payload === "string") {
body = Buffer.from(payload, "utf8");
} else if (payload instanceof Uint8Array) {
body = Buffer.from(payload);
} else {
return false;
}
if (typeof signatureHeader !== "string" || typeof secret !== "string" || secret === "") return false;
const parts = signatureHeader.split(",").map((part): [string, string] => {
const trimmed = part.trim();
const separator = trimmed.indexOf("=");
return separator === -1 ? [trimmed, ""] : [trimmed.slice(0, separator), trimmed.slice(separator + 1)];
});
const timestampPart = parts.find(([key]) => key === "t");
const signatures = parts.filter(([key, value]) => key === "v1" && value !== "").map(([, value]) => value);
if (!timestampPart || !/^[0-9]+$/.test(timestampPart[1]) || signatures.length === 0) return false;
const timestamp = timestampPart[1];
const now = options.now ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > tolerance) return false;
const hmac = crypto.createHmac("sha256", secret);
hmac.update(`${timestamp}.`, "utf8");
hmac.update(body);
const expected = Buffer.from(hmac.digest("hex"), "utf8");
// Constant time, so a timing attack can't find a valid signature byte by byte
return signatures.some((signature) => {
const actual = Buffer.from(signature, "utf8");
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
});
}