-
Notifications
You must be signed in to change notification settings - Fork 399
/
ExpressReceiver.ts
283 lines (252 loc) · 8.8 KB
/
ExpressReceiver.ts
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
import { EventEmitter } from 'events';
import { Receiver, ReceiverEvent, ReceiverAckTimeoutError } from './types';
import { createServer, Server, Agent } from 'http';
import { SecureContextOptions } from 'tls';
import express, { Request, Response, Application, RequestHandler, NextFunction } from 'express';
import axios, { AxiosInstance } from 'axios';
import rawBody from 'raw-body';
import querystring from 'querystring';
import crypto from 'crypto';
import tsscmp from 'tsscmp';
import { ErrorCode, errorWithCode } from './errors';
import { Logger, ConsoleLogger } from '@slack/logger';
// TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations?
// if that's the reason, let's document that with a comment.
export interface ExpressReceiverOptions {
signingSecret: string;
logger?: Logger;
endpoints?: string | {
[endpointType: string]: string;
};
agent?: Agent;
clientTls?: Pick<SecureContextOptions, 'pfx' | 'key' | 'passphrase' | 'cert' | 'ca'>;
}
/**
* Receives HTTP requests with Events, Slash Commands, and Actions
*/
export default class ExpressReceiver extends EventEmitter implements Receiver {
/* Express app */
public app: Application;
private server: Server;
private axios: AxiosInstance;
constructor({
signingSecret = '',
logger = new ConsoleLogger(),
endpoints = { events: '/slack/events' },
agent = undefined,
clientTls = undefined,
}: ExpressReceiverOptions) {
super();
this.app = express();
this.app.use(this.errorHandler.bind(this));
// TODO: what about starting an https server instead of http? what about other options to create the server?
this.server = createServer(this.app);
this.axios = axios.create(Object.assign(
{
httpAgent: agent,
httpsAgent: agent,
},
clientTls,
));
const expressMiddleware: RequestHandler[] = [
verifySignatureAndParseBody(logger, signingSecret),
respondToSslCheck,
respondToUrlVerification,
this.requestHandler.bind(this),
];
const endpointList: string[] = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints);
for (const endpoint of endpointList) {
this.app.post(endpoint, ...expressMiddleware);
}
}
private requestHandler(req: Request, res: Response): void {
let timer: NodeJS.Timer | undefined = setTimeout(
() => {
this.emit('error', receiverAckTimeoutError(
'An incoming event was not acknowledged before the timeout. ' +
'Ensure that the ack() argument is called in your listeners.',
));
timer = undefined;
},
2800,
);
const event: ReceiverEvent = {
body: req.body as { [key: string]: any },
ack: (response: any): void => {
// TODO: if app tries acknowledging more than once, emit a warning
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
if (!response) res.send('');
if (typeof response === 'string') {
res.send(response);
} else {
res.json(response);
}
}
},
respond: undefined,
};
if (req.body && req.body.response_url) {
event.respond = (response): void => {
this.axios.post(req.body.response_url, response)
.catch((e) => {
this.emit('error', e);
});
};
}
this.emit('message', event);
}
// TODO: the arguments should be defined as the arguments of Server#listen()
// TODO: the return value should be defined as a type that both http and https servers inherit from, or a union
public start(port: number): Promise<Server> {
return new Promise((resolve, reject) => {
try {
// TODO: what about other listener options?
// TODO: what about asynchronous errors? should we attach a handler for this.server.on('error', ...)?
// if so, how can we check for only errors related to listening, as opposed to later errors?
this.server.listen(port, () => {
resolve(this.server);
});
} catch (error) {
reject(error);
}
});
}
// TODO: the arguments should be defined as the arguments to close() (which happen to be none), but for sake of
// generic types
public stop(): Promise<void> {
return new Promise((resolve, reject) => {
// TODO: what about synchronous errors?
this.server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
private errorHandler(err: any, _req: Request, _res: Response, next: NextFunction): void {
this.emit('error', err);
// Forward to express' default error handler (which knows how to print stack traces in development)
next(err);
}
}
const respondToSslCheck: RequestHandler = (req, res, next) => {
if (req.body && req.body.ssl_check) {
res.send();
return;
}
next();
};
const respondToUrlVerification: RequestHandler = (req, res, next) => {
if (req.body && req.body.type && req.body.type === 'url_verification') {
res.json({ challenge: req.body.challenge });
return;
}
next();
};
/**
* This request handler has two responsibilities:
* - Verify the request signature
* - Parse request.body and assign the successfully parsed object to it.
*/
export function verifySignatureAndParseBody(
logger: Logger,
signingSecret: string): RequestHandler {
return async (req, _res, next) => {
try {
// *** Request verification ***
let stringBody: string;
// On some environments like GCP (Google Cloud Platform),
// req.body can be pre-parsed and be passed as req.rawBody here
const preparsedRawBody: any = (req as any).rawBody;
if (preparsedRawBody !== undefined) {
stringBody = preparsedRawBody.toString();
} else {
stringBody = (await rawBody(req)).toString();
}
const signature = req.headers['x-slack-signature'] as string;
const ts = Number(req.headers['x-slack-request-timestamp']);
try {
await verifyRequestSignature(signingSecret, stringBody, signature, ts);
} catch (e) {
return next(e);
}
// *** Parsing body ***
// As the verification passed, parse the body as an object and assign it to req.body
// Following middlewares can expect `req.body` is already a parsed one.
// This handler parses `req.body` or `req.rawBody`(on Google Could Platform)
// and overwrites `req.body` with the parsed JS object.
const contentType = req.headers['content-type'];
req.body = parseRequestBody(logger, stringBody, contentType);
next();
} catch (error) {
next(error);
}
};
}
// TODO: this should be imported from another package
async function verifyRequestSignature(
signingSecret: string,
body: string,
signature: string,
requestTimestamp: number): Promise<void> {
if (!signature || !requestTimestamp) {
const error = errorWithCode(
'Slack request signing verification failed. Some headers are missing.',
ErrorCode.ExpressReceiverAuthenticityError,
);
throw error;
}
// Divide current date to match Slack ts format
// Subtract 5 minutes from current time
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 5);
if (requestTimestamp < fiveMinutesAgo) {
const error = errorWithCode(
'Slack request signing verification failed. Timestamp is too old.',
ErrorCode.ExpressReceiverAuthenticityError,
);
throw error;
}
const hmac = crypto.createHmac('sha256', signingSecret);
const [version, hash] = signature.split('=');
hmac.update(`${version}:${requestTimestamp}:${body}`);
if (!tsscmp(hash, hmac.digest('hex'))) {
const error = errorWithCode(
'Slack request signing verification failed. Signature mismatch.',
ErrorCode.ExpressReceiverAuthenticityError,
);
throw error;
}
}
function parseRequestBody(
logger: Logger,
stringBody: string,
contentType: string | undefined) {
if (contentType === 'application/x-www-form-urlencoded') {
const parsedBody = querystring.parse(stringBody);
if (typeof parsedBody.payload === 'string') {
return JSON.parse(parsedBody.payload);
} else {
return parsedBody;
}
} else if (contentType === 'application/json') {
return JSON.parse(stringBody);
} else {
logger.warn(`Unexpected content-type detected: ${contentType}`);
try {
// Parse this body anyway
return JSON.parse(stringBody);
} catch (e) {
logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`);
throw e;
}
}
}
function receiverAckTimeoutError(message: string): ReceiverAckTimeoutError {
const error = new Error(message);
(error as ReceiverAckTimeoutError).code = ErrorCode.ReceiverAckTimeoutError;
return (error as ReceiverAckTimeoutError);
}