Skip to content

Commit c49927e

Browse files
committed
feat: add CloudflareWorkerReceiver implementation and tests
- Introduced a new receiver for handling Slack events in Cloudflare Workers. - Implemented signature verification and request handling logic. - Added unit tests to validate functionality and edge cases. - Updated package.json to include @cloudflare/workers-types as a dependency.
1 parent 657416f commit c49927e

3 files changed

Lines changed: 739 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
},
5656
"devDependencies": {
5757
"@biomejs/biome": "^1.9.0",
58+
"@cloudflare/workers-types": "^4.20250428.0",
5859
"@tsconfig/node18": "^18.2.4",
5960
"@types/chai": "^4.1.7",
6061
"@types/mocha": "^10.0.1",
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import crypto from 'node:crypto';
2+
import querystring from 'node:querystring';
3+
import type { ExecutionContext } from '@cloudflare/workers-types';
4+
import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger';
5+
import type App from '../App';
6+
import { ReceiverMultipleAckError } from '../errors';
7+
import type { Receiver, ReceiverEvent } from '../types/receiver';
8+
import type { StringIndexed } from '../types/utilities';
9+
10+
function bufferEqual(a: Buffer, b: Buffer) {
11+
if (a.length !== b.length) {
12+
return false;
13+
}
14+
if (crypto.timingSafeEqual) {
15+
return crypto.timingSafeEqual(a, b);
16+
}
17+
for (let i = 0; i < a.length; i++) {
18+
if (a[i] !== b[i]) {
19+
return false;
20+
}
21+
}
22+
return true;
23+
}
24+
25+
function timeSafeCompare(a: string | number, b: string | number) {
26+
const sa = String(a);
27+
const sb = String(b);
28+
const randomBytes = new Uint8Array(32);
29+
30+
// Fill the array with cryptographically secure random values
31+
const key = crypto.getRandomValues(randomBytes);
32+
const ah = crypto.createHmac('sha256', key).update(sa).digest();
33+
const bh = crypto.createHmac('sha256', key).update(sb).digest();
34+
35+
return bufferEqual(ah, bh) && a === b;
36+
}
37+
38+
export interface ReceiverInvalidRequestSignatureHandlerArgs {
39+
rawBody: string;
40+
signature: string;
41+
ts: number;
42+
request: Request;
43+
response: Promise<Response>;
44+
}
45+
46+
export interface CloudflareWorkerReceiverOptions {
47+
/**
48+
* The Slack Signing secret to be used as an input to signature verification to ensure that requests are coming from
49+
* Slack.
50+
*
51+
* If the {@link signatureVerification} flag is set to `false`, this can be set to any value as signature verification
52+
* using this secret will not be performed.
53+
*
54+
* @see {@link https://api.slack.com/authentication/verifying-requests-from-slack#about} for details about signing secrets
55+
*/
56+
signingSecret: string;
57+
/**
58+
* The {@link Logger} for the receiver
59+
*
60+
* @default ConsoleLogger
61+
*/
62+
logger?: Logger;
63+
/**
64+
* The {@link LogLevel} to be used for the logger.
65+
*
66+
* @default LogLevel.INFO
67+
*/
68+
logLevel?: LogLevel;
69+
/**
70+
* Flag that determines whether Bolt should {@link https://api.slack.com/authentication/verifying-requests-from-slack|verify Slack's signature on incoming requests}.
71+
*
72+
* @default true
73+
*/
74+
signatureVerification?: boolean;
75+
/**
76+
* Optional `function` that can extract custom properties from an incoming receiver event
77+
* @param request The API Gateway event {@link Request}
78+
* @returns An object containing custom properties
79+
*
80+
* @default noop
81+
*/
82+
customPropertiesExtractor?: (request: Request) => StringIndexed;
83+
invalidRequestSignatureHandler?: (args: ReceiverInvalidRequestSignatureHandlerArgs) => void;
84+
unhandledRequestTimeoutMillis?: number;
85+
processBeforeResponse?: boolean;
86+
}
87+
88+
/*
89+
* Receiver implementation for Cloudflare Workers
90+
*/
91+
export default class CloudflareReceiver implements Receiver {
92+
private signingSecret: string;
93+
94+
private app?: App;
95+
96+
private _logger: Logger;
97+
98+
get logger() {
99+
return this._logger;
100+
}
101+
102+
private signatureVerification: boolean;
103+
104+
private customPropertiesExtractor: (request: Request) => StringIndexed;
105+
106+
private invalidRequestSignatureHandler: (args: ReceiverInvalidRequestSignatureHandlerArgs) => void;
107+
108+
private unhandledRequestTimeoutMillis: number;
109+
110+
private processBeforeResponse: boolean;
111+
112+
public constructor({
113+
signingSecret,
114+
logger = undefined,
115+
logLevel = LogLevel.INFO,
116+
signatureVerification = true,
117+
customPropertiesExtractor = (_) => ({}),
118+
invalidRequestSignatureHandler,
119+
unhandledRequestTimeoutMillis = 3001,
120+
processBeforeResponse = false,
121+
}: CloudflareWorkerReceiverOptions) {
122+
// Initialize instance variables, substituting defaults for each value
123+
this.signingSecret = signingSecret;
124+
this.signatureVerification = signatureVerification;
125+
this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis;
126+
this.processBeforeResponse = processBeforeResponse;
127+
this._logger =
128+
logger ??
129+
(() => {
130+
const defaultLogger = new ConsoleLogger();
131+
defaultLogger.setLevel(logLevel);
132+
return defaultLogger;
133+
})();
134+
this.customPropertiesExtractor = customPropertiesExtractor;
135+
if (invalidRequestSignatureHandler) {
136+
this.invalidRequestSignatureHandler = invalidRequestSignatureHandler;
137+
} else {
138+
this.invalidRequestSignatureHandler = this.defaultInvalidRequestSignatureHandler;
139+
}
140+
}
141+
142+
public init(app: App): void {
143+
this.app = app;
144+
}
145+
146+
// biome-ignore lint/suspicious/noExplicitAny: TODO: what should the REceiver interface here be? probably needs work
147+
public start(..._args: any[]): Promise<ReturnType<typeof this.toHandler>> {
148+
return new Promise((resolve, reject) => {
149+
try {
150+
const handler = this.toHandler();
151+
resolve(handler);
152+
} catch (error) {
153+
reject(error);
154+
}
155+
});
156+
}
157+
158+
// biome-ignore lint/suspicious/noExplicitAny: TODO: what should the REceiver interface here be? probably needs work
159+
public stop(..._args: any[]): Promise<void> {
160+
return new Promise((resolve, _reject) => {
161+
resolve();
162+
});
163+
}
164+
165+
public toHandler() {
166+
return async (request: Request, _env: unknown, ctx: ExecutionContext): Promise<Response> => {
167+
this.logger.debug(`Cloudflare request: ${JSON.stringify(request, null, 2)}`);
168+
169+
const rawBody = await request.text();
170+
171+
// biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything
172+
const body: any = this.parseRequestBody(rawBody, request.headers.get('Content-Type') ?? undefined, this.logger);
173+
174+
// ssl_check (for Slash Commands)
175+
if (
176+
typeof body !== 'undefined' &&
177+
body != null &&
178+
typeof body.ssl_check !== 'undefined' &&
179+
body.ssl_check != null
180+
) {
181+
return Promise.resolve(new Response(null, { status: 200 }));
182+
}
183+
184+
if (this.signatureVerification) {
185+
// request signature verification
186+
const signature = request.headers.get('X-Slack-Signature') as string;
187+
const ts = Number(request.headers.get('X-Slack-Request-Timestamp'));
188+
if (!this.isValidRequestSignature(this.signingSecret, rawBody, signature, ts)) {
189+
const response = Promise.resolve(new Response(null, { status: 401 }));
190+
this.invalidRequestSignatureHandler({
191+
rawBody,
192+
signature,
193+
ts,
194+
request,
195+
response,
196+
});
197+
return response;
198+
}
199+
}
200+
201+
// url_verification (Events API)
202+
if (
203+
typeof body !== 'undefined' &&
204+
body != null &&
205+
typeof body.type !== 'undefined' &&
206+
body.type != null &&
207+
body.type === 'url_verification'
208+
) {
209+
return Promise.resolve(
210+
new Response(JSON.stringify({ challenge: body.challenge }), {
211+
status: 200,
212+
headers: { 'Content-Type': 'application/json' },
213+
}),
214+
);
215+
}
216+
217+
// Setup ack timeout warning
218+
let isAcknowledged = false;
219+
const noAckTimeoutId = setTimeout(() => {
220+
if (!isAcknowledged) {
221+
this.logger.error(
222+
`An incoming event was not acknowledged within ${this.unhandledRequestTimeoutMillis} ms. Ensure that the ack() argument is called in a listener.`,
223+
);
224+
}
225+
}, this.unhandledRequestTimeoutMillis);
226+
227+
let ackResolve: (() => void) | undefined;
228+
const ackPromise = new Promise<void>((resolve) => {
229+
ackResolve = resolve;
230+
});
231+
232+
// Structure the ReceiverEvent
233+
// biome-ignore lint/suspicious/noExplicitAny: request responses can be anything
234+
let storedResponse: any;
235+
const retryNum = request.headers.get('X-Slack-Retry-Num');
236+
const retryReason = request.headers.get('X-Slack-Retry-Reason');
237+
const event: ReceiverEvent = {
238+
body,
239+
ack: async (response) => {
240+
if (isAcknowledged) {
241+
throw new ReceiverMultipleAckError();
242+
}
243+
isAcknowledged = true;
244+
clearTimeout(noAckTimeoutId);
245+
if (typeof response === 'undefined' || response == null) {
246+
storedResponse = '';
247+
} else {
248+
storedResponse = response;
249+
}
250+
if (!this.processBeforeResponse) {
251+
ackResolve?.();
252+
}
253+
},
254+
retryNum: retryNum ? Number(retryNum) : undefined,
255+
retryReason: retryReason ?? undefined,
256+
customProperties: this.customPropertiesExtractor(request),
257+
};
258+
259+
// Send the event to the app for processing
260+
try {
261+
if (this.processBeforeResponse) {
262+
await this.app?.processEvent(event);
263+
} else {
264+
const processEventPromise = this.app?.processEvent(event);
265+
await Promise.race([processEventPromise, ackPromise]);
266+
if (processEventPromise) {
267+
ctx.waitUntil(processEventPromise);
268+
}
269+
}
270+
271+
if (storedResponse !== undefined) {
272+
if (typeof storedResponse === 'string') {
273+
return new Response(storedResponse);
274+
}
275+
return new Response(JSON.stringify(storedResponse));
276+
}
277+
} catch (err) {
278+
this.logger.error('An unhandled error occurred while Bolt processed an event');
279+
this.logger.debug(`Error details: ${err}, storedResponse: ${storedResponse}`);
280+
return new Response('Internal server error', { status: 500 });
281+
}
282+
// No matching handler; clear ack warning timeout and return a 404.
283+
clearTimeout(noAckTimeoutId);
284+
this.logger.info(`No request handler matched the request: ${request.url}`);
285+
return new Response('', { status: 404 });
286+
};
287+
}
288+
289+
private parseRequestBody(
290+
stringBody: string,
291+
contentType: string | undefined,
292+
logger: Logger,
293+
// biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything
294+
): any {
295+
if (contentType === 'application/x-www-form-urlencoded') {
296+
const parsedBody = querystring.parse(stringBody);
297+
if (typeof parsedBody.payload === 'string') {
298+
return JSON.parse(parsedBody.payload);
299+
}
300+
return parsedBody;
301+
}
302+
if (contentType === 'application/json') {
303+
return JSON.parse(stringBody);
304+
}
305+
306+
logger.warn(`Unexpected content-type detected: ${contentType}`);
307+
try {
308+
// Parse this body anyway
309+
return JSON.parse(stringBody);
310+
} catch (e) {
311+
logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`);
312+
throw e;
313+
}
314+
}
315+
316+
private isValidRequestSignature(
317+
signingSecret: string,
318+
body: string,
319+
signature: string,
320+
requestTimestamp: number,
321+
): boolean {
322+
if (!signature || !requestTimestamp) {
323+
return false;
324+
}
325+
// Divide current date to match Slack ts format
326+
// Subtract 5 minutes from current time
327+
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
328+
if (requestTimestamp < fiveMinutesAgo) {
329+
return false;
330+
}
331+
332+
const hmac = crypto.createHmac('sha256', signingSecret);
333+
const [version, hash] = signature.split('=');
334+
hmac.update(`${version}:${requestTimestamp}:${body}`);
335+
const computedHash = hmac.digest('hex');
336+
337+
if (!timeSafeCompare(hash, computedHash)) {
338+
return false;
339+
}
340+
341+
return true;
342+
}
343+
344+
private defaultInvalidRequestSignatureHandler(args: ReceiverInvalidRequestSignatureHandlerArgs): void {
345+
const { signature, ts } = args;
346+
347+
this.logger.info(
348+
`Invalid request signature detected (X-Slack-Signature: ${signature}, X-Slack-Request-Timestamp: ${ts})`,
349+
);
350+
}
351+
}

0 commit comments

Comments
 (0)