Skip to content

Commit 598b280

Browse files
committed
Webhook signature verification tool
1 parent d1cf79d commit 598b280

File tree

5 files changed

+347
-1
lines changed

5 files changed

+347
-1
lines changed

index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { IKCallback } from "./libs/interfaces/IKCallback";
4040
import manage from "./libs/manage";
4141
import signature from "./libs/signature";
4242
import upload from "./libs/upload";
43+
import { verify as verifyWebhookSignature } from "./utils/webhook-signature";
4344
import customMetadataField from "./libs/manage/custom-metadata-field";
4445
/*
4546
Implementations
@@ -76,7 +77,15 @@ const promisify = function <T = void>(thisContext: ImageKit, fn: Function) {
7677
};
7778
};
7879

79-
class ImageKit {
80+
export const Webhook = {
81+
verify: verifyWebhookSignature,
82+
}
83+
84+
class ImageKitStaticUtils {
85+
static Webhook = Webhook;
86+
}
87+
88+
class ImageKit extends ImageKitStaticUtils {
8089
options: ImageKitOptions = {
8190
uploadEndpoint: "https://upload.imagekit.io/api/v1/files/upload",
8291
publicKey: "",
@@ -86,6 +95,7 @@ class ImageKit {
8695
};
8796

8897
constructor(opts: ImageKitOptions = {} as ImageKitOptions) {
98+
super()
8999
this.options = _.extend(this.options, opts);
90100
if (!this.options.publicKey) {
91101
throw new Error(errorMessages.MANDATORY_PUBLIC_KEY_MISSING.message);

libs/interfaces/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { MoveFolderOptions, MoveFolderResponse, MoveFolderError } from "./MoveFo
1717
import { DeleteFileVersionOptions, RestoreFileVersionOptions } from "./FileVersion"
1818
import { CreateCustomMetadataFieldOptions, CustomMetadataField, UpdateCustomMetadataFieldOptions, GetCustomMetadataFieldsOptions } from "./CustomMetatadaField"
1919
import { RenameFileOptions, RenameFileResponse } from "./Rename"
20+
import {
21+
WebhookEvent,
22+
WebhookEventVideoAccepted,
23+
WebhookEventVideoCompleted,
24+
WebhookEventVideoFailed,
25+
} from "./webhookEvent";
2026

2127
type FinalUrlOptions = ImageKitOptions & UrlOptions; // actual options used to construct url
2228

@@ -56,5 +62,9 @@ export type {
5662
UpdateCustomMetadataFieldOptions,
5763
RenameFileOptions,
5864
RenameFileResponse,
65+
WebhookEvent,
66+
WebhookEventVideoAccepted,
67+
WebhookEventVideoCompleted,
68+
WebhookEventVideoFailed,
5969
};
6070
export type { IKCallback } from "./IKCallback";

libs/interfaces/webhookEvent.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
type Asset = {
2+
url: string;
3+
};
4+
5+
type TransformationOptions = {
6+
video_codec: string;
7+
audio_codec: string;
8+
auto_rotate: boolean;
9+
quality: number;
10+
format: string;
11+
};
12+
13+
interface WebhookEventBase {
14+
type: string;
15+
id: string;
16+
created_at: string; // Date
17+
}
18+
19+
/** WebhookEvent for "video.transformation.*" type */
20+
interface WebhookEventVideoBase extends WebhookEventBase {
21+
request: {
22+
x_request_id: string;
23+
url: string;
24+
user_agent: string;
25+
};
26+
}
27+
28+
export interface WebhookEventVideoAccepted extends WebhookEventVideoBase {
29+
type: "video.transformation.accepted";
30+
data: {
31+
asset: Asset;
32+
transformation: {
33+
type: string;
34+
options: TransformationOptions;
35+
};
36+
};
37+
}
38+
39+
export interface WebhookEventVideoCompleted extends WebhookEventVideoBase {
40+
type: "video.transformation.completed";
41+
timings: {
42+
donwload_duration: number;
43+
encoding_duration: number;
44+
};
45+
data: {
46+
asset: Asset;
47+
transformation: {
48+
type: string;
49+
options: TransformationOptions;
50+
output: {
51+
url: string;
52+
video_metadata: {
53+
duration: number;
54+
width: number;
55+
height: number;
56+
bitrate: number;
57+
};
58+
};
59+
};
60+
};
61+
}
62+
63+
export interface WebhookEventVideoFailed extends WebhookEventVideoBase {
64+
type: "video.transformation.failed";
65+
data: {
66+
asset: Asset;
67+
transformation: {
68+
type: string;
69+
options: TransformationOptions;
70+
error: {
71+
reason: string;
72+
};
73+
};
74+
};
75+
}
76+
77+
export type WebhookEvent =
78+
| WebhookEventVideoAccepted
79+
| WebhookEventVideoCompleted
80+
| WebhookEventVideoFailed;

tests/webhook-signature.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Webhook } from "../index";
2+
import { expect } from "chai";
3+
4+
// Sample webhook data
5+
const WEBHOOK_REQUEST_SAMPLE_SECRET = "whsec_xeO2UNkfKMQnfJf7Q/Qx+fYptL1wabXd";
6+
const WEBHOOK_REQUEST_SAMPLE_TIMESTAMP = new Date(1655788406333);
7+
const WEBHOOK_REQUEST_SAMPLE_SIGNATURE_HEADER =
8+
"t=1655788406333,v1=d30758f47fcb31e1fa0109d3b3e2a6c623e699aaf1461cba6bd462ef58ea4b31";
9+
const WEBHOOK_REQUEST_SAMPLE_RAW_BODY =
10+
'{"type":"video.transformation.accepted","id":"58e6d24d-6098-4319-be8d-40c3cb0a402d","created_at":"2022-06-20T11:59:58.461Z","request":{"x_request_id":"fa98fa2e-d6cd-45b4-acf5-bc1d2bbb8ba9","url":"http://ik.imagekit.io/demo/sample-video.mp4?tr=f-webm,q-10","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:101.0) Gecko/20100101 Firefox/101.0"},"data":{"asset":{"url":"http://ik.imagekit.io/demo/sample-video.mp4"},"transformation":{"type":"video-transformation","options":{"video_codec":"vp9","audio_codec":"opus","auto_rotate":true,"quality":10,"format":"webm"}}}}';
11+
const WEBHOOK_REQUEST_SAMPLE = Object.seal({
12+
secret: WEBHOOK_REQUEST_SAMPLE_SECRET,
13+
timestamp: WEBHOOK_REQUEST_SAMPLE_TIMESTAMP,
14+
signatureHeader: WEBHOOK_REQUEST_SAMPLE_SIGNATURE_HEADER,
15+
rawBody: WEBHOOK_REQUEST_SAMPLE_RAW_BODY,
16+
body: JSON.parse(WEBHOOK_REQUEST_SAMPLE_RAW_BODY),
17+
});
18+
19+
describe("WebhookSignature", function () {
20+
const { verify } = Webhook;
21+
22+
context("Test Webhook.verify() - Positive cases", () => {
23+
it("Verify with body as string", () => {
24+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
25+
const { timestamp, event } = verify(
26+
webhookRequest.rawBody,
27+
webhookRequest.signatureHeader,
28+
webhookRequest.secret
29+
);
30+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
31+
expect(event).to.deep.equal(webhookRequest.body);
32+
});
33+
it("Verify with body as Buffer", () => {
34+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
35+
const { timestamp, event } = verify(
36+
Buffer.from(webhookRequest.rawBody),
37+
webhookRequest.signatureHeader,
38+
webhookRequest.secret
39+
);
40+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
41+
expect(event).to.deep.equal(webhookRequest.body);
42+
});
43+
it("Verify with body as Uint8Array", () => {
44+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
45+
const rawBody = Uint8Array.from(Buffer.from(webhookRequest.rawBody));
46+
const { timestamp, event } = verify(
47+
rawBody,
48+
webhookRequest.signatureHeader,
49+
webhookRequest.secret
50+
);
51+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
52+
expect(event).to.deep.equal(webhookRequest.body);
53+
});
54+
});
55+
56+
context("Test WebhookSignature.verify() - Negative cases", () => {
57+
it("Timestamp missing", () => {
58+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
59+
const invalidSignature =
60+
"v1=b6bc2aa82491c32f1cbef0eb52b7ffffff467ea65a03b5d4ccdcfb9e0941c946";
61+
try {
62+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
63+
expect.fail("Expected exception");
64+
} catch (e) {
65+
expect(e.message).to.equal("Timestamp missing");
66+
}
67+
});
68+
it("Timestamp invalid", () => {
69+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
70+
const invalidSignature =
71+
"t=notANumber,v1=b6bc2aa82491c32f1cbef0eb52b7ffffff467ea65a03b5d4ccdcfb9e0941c946";
72+
try {
73+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
74+
expect.fail("Expected exception");
75+
} catch (e) {
76+
expect(e.message).to.equal("Timestamp invalid");
77+
}
78+
});
79+
it("Signature missing", () => {
80+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
81+
const invalidSignature = "t=1656326161409";
82+
try {
83+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
84+
expect.fail("Expected exception");
85+
} catch (e) {
86+
expect(e.message).to.equal("Signature missing");
87+
}
88+
});
89+
it("Incorrect signature - v1 manipulated", () => {
90+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
91+
const invalidSignature = `t=${webhookRequest.timestamp.getTime()},v1=d66b01d8f1e158d1af7646184716037510ac8ce0a1e70b726a1b698f954785b2`;
92+
try {
93+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
94+
expect.fail("Expected exception");
95+
} catch (e) {
96+
expect(e.message).to.equal("Incorrect signature");
97+
}
98+
});
99+
it("Incorrect signature - incorrect request body", () => {
100+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
101+
const incorrectBody = { hello: "world" };
102+
const incorrectRawBody = JSON.stringify(incorrectBody);
103+
try {
104+
verify(
105+
incorrectRawBody,
106+
webhookRequest.signatureHeader,
107+
webhookRequest.secret
108+
);
109+
expect.fail("Expected exception");
110+
} catch (e) {
111+
expect(e.message).to.equal("Incorrect signature");
112+
}
113+
});
114+
it("Incorrect signature - timestamp manipulated", () => {
115+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
116+
const incorrectSignature = webhookRequest.signatureHeader.replace(
117+
`t=${webhookRequest.timestamp.getTime()}`,
118+
`t=${webhookRequest.timestamp.getTime() + 1}`
119+
); // Correct timestamp replaced with incorrect timestamp
120+
try {
121+
verify(
122+
webhookRequest.rawBody,
123+
incorrectSignature,
124+
webhookRequest.secret
125+
);
126+
expect.fail("Expected exception");
127+
} catch (e) {
128+
expect(e.message).to.equal("Incorrect signature");
129+
}
130+
});
131+
it("Incorrect signature - different secret", () => {
132+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
133+
try {
134+
verify(
135+
webhookRequest.rawBody,
136+
webhookRequest.signatureHeader,
137+
"A different secret"
138+
);
139+
expect.fail("Expected exception");
140+
} catch (e) {
141+
expect(e.message).to.equal("Incorrect signature");
142+
}
143+
});
144+
});
145+
});

utils/webhook-signature.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createHmac } from "crypto";
2+
import { isNaN } from "lodash";
3+
import type { WebhookEvent } from "../libs/interfaces";
4+
5+
class WebhookSignatureError extends Error {
6+
constructor(message: string) {
7+
super(message);
8+
this.name = "WebhookSignatureError";
9+
}
10+
}
11+
12+
/**
13+
* @description Enum for Webhook signature item names
14+
*/
15+
enum SignatureItems {
16+
Timestamp = "t",
17+
V1 = "v1",
18+
}
19+
20+
const HASH_ALGORITHM = "sha256";
21+
22+
/**
23+
* @param timstamp - Webhook request timestamp
24+
* @param payload - Webhook payload as UTF8 encoded string
25+
* @param secret - Webhook secret as UTF8 encoded string
26+
* @returns Hmac with webhook secret as key and `${timestamp}.${payload}` as hash payload.
27+
*/
28+
const computeHmac = (
29+
timstamp: Date,
30+
payload: string,
31+
secret: string
32+
): string => {
33+
const hashPayload = `${timstamp.getTime()}.${payload}`;
34+
return createHmac(HASH_ALGORITHM, secret).update(hashPayload).digest("hex");
35+
};
36+
37+
/**
38+
* @description Extract items from webhook signature string
39+
*/
40+
const deserializeSignature = (
41+
signature: string
42+
): {
43+
timestamp: number;
44+
v1: string;
45+
} => {
46+
const items = signature.split(",");
47+
const itemMap = items.map((item) => item.split("=")); // eg. [["t", 1656921250765], ["v1", 'afafafafafaf']]
48+
const timestampString = itemMap.find(
49+
([key]) => key === SignatureItems.Timestamp
50+
)?.[1]; // eg. 1656921250765
51+
52+
// parse timestamp
53+
if (timestampString === undefined) {
54+
throw new WebhookSignatureError("Timestamp missing");
55+
}
56+
const timestamp = parseInt(timestampString, 10);
57+
if (isNaN(timestamp) || timestamp < 0) {
58+
throw new WebhookSignatureError("Timestamp invalid");
59+
}
60+
61+
// parse v1 signature
62+
const v1 = itemMap.find(([key]) => key === SignatureItems.V1)?.[1]; // eg. 'afafafafafaf'
63+
if (v1 === undefined) {
64+
throw new WebhookSignatureError("Signature missing");
65+
}
66+
67+
return { timestamp, v1 };
68+
};
69+
70+
/**
71+
* @param payload - Webhook request (Raw body)
72+
* @param signature - Webhook signature as UTF8 encoded strings (Stored in `x-ik-signature` header of the request)
73+
* @param secret - Webhook secret as UTF8 encoded string [Copy from ImageKit dashboard](https://imagekit.io/dashboard/developer/webhooks)
74+
* @returns \{ `timstamp`: Verified UNIX epoch timestamp if signature, `event`: Parsed webhook event payload \}
75+
*/
76+
export const verify = (
77+
payload: string | Uint8Array,
78+
signature: string,
79+
secret: string
80+
): {
81+
timestamp: number;
82+
event: WebhookEvent;
83+
} => {
84+
const { timestamp, v1 } = deserializeSignature(signature);
85+
const payloadAsString: string =
86+
typeof payload === "string"
87+
? payload
88+
: Buffer.from(payload).toString("utf8");
89+
const computedHmac = computeHmac(
90+
new Date(timestamp),
91+
payloadAsString,
92+
secret
93+
);
94+
if (v1 !== computedHmac) {
95+
throw new WebhookSignatureError("Incorrect signature");
96+
}
97+
return {
98+
timestamp,
99+
event: JSON.parse(payloadAsString) as WebhookEvent,
100+
};
101+
};

0 commit comments

Comments
 (0)