diff --git a/examples/custom-receiver/.gitignore b/examples/custom-receiver/.gitignore new file mode 100644 index 000000000..572332923 --- /dev/null +++ b/examples/custom-receiver/.gitignore @@ -0,0 +1,4 @@ +.env +dist/ +node_modules/ +package-lock.json diff --git a/examples/custom-receiver/README.md b/examples/custom-receiver/README.md new file mode 100644 index 000000000..dd0d720dd --- /dev/null +++ b/examples/custom-receiver/README.md @@ -0,0 +1,53 @@ +# Bolt for JavaScript Koa Receiver Example App + +This is a quick example app to demonstrate how to implement a custom receiver to integrate `App` with 3rd party web framework, which allows developers to directly access Node.js http package interface. + +## Install Dependencies + +To link to latest source code, you can run the following script: + +``` +./link.sh +``` + +## Setup Environment Variables + +This app requires you setup a few environment variables. You can find these values in your [app configuration](https://api.slack.com/apps). + +```bash +export SLACK_CLIENT_ID=YOUR_SLACK_CLIENT_ID +export SLACK_CLIENT_SECRET=YOUR_SLACK_CLIENT_SECRET +export SLACK_SIGNING_SECRET=YOUR_SLACK_SIGNING_SECRET +``` + +## Run the App + +Start the app with the following command: + +``` +npm start +``` + +### Running with OAuth + +Only implement OAuth if you plan to distribute your application across multiple workspaces. Uncomment out the OAuth specific comments in the code. If you are on dev instance, you will have to uncomment out those options as well. + +Start `ngrok` so we can access the app on an external network and create a redirect URL for OAuth. + +``` +ngrok http 3000 +``` + +This output should include a forwarding address for `http` and `https` (we'll use the `https` one). It should look something like the following: + +``` +Forwarding https://3cb89939.ngrok.io -> http://localhost:3000 +``` + +Then navigate to **OAuth & Permissions** in your app configuration and click **Add a Redirect URL**. The redirect URL should be set to your `ngrok` forwarding address with the `slack/oauth_redirect` path appended. ex: + +``` +https://3cb89939.ngrok.io/slack/oauth_redirect +``` + +Start the OAuth flow from https://{your own subdomain}.ngrok.io/slack/install \ No newline at end of file diff --git a/examples/custom-receiver/link.sh b/examples/custom-receiver/link.sh new file mode 100755 index 000000000..ca5e30ed4 --- /dev/null +++ b/examples/custom-receiver/link.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +current_dir=`dirname $0` +cd ${current_dir} +npm unlink @slack/bolt \ + && npm i \ + && cd ../.. \ + && npm link \ + && cd - \ + && npm i \ + && npm link @slack/bolt diff --git a/examples/custom-receiver/package.json b/examples/custom-receiver/package.json new file mode 100644 index 000000000..5c5ee702f --- /dev/null +++ b/examples/custom-receiver/package.json @@ -0,0 +1,26 @@ +{ + "name": "bolt-oauth-example", + "version": "1.0.0", + "description": "Example app using OAuth", + "main": "app.js", + "scripts": { + "lint": "eslint --fix --ext .ts src", + "build": "npm run lint && tsc -p .", + "build:watch": "npm run lint && tsc -w -p .", + "start": "npm run build && node dist/app.js" + }, + "license": "MIT", + "dependencies": { + "@koa/router": "^10.1.1", + "@slack/logger": "^3.0.0", + "@slack/oauth": "^2.5.0-rc.1", + "dotenv": "^8.2.0", + "koa": "^2.13.4" + }, + "devDependencies": { + "@types/koa__router": "^8.0.11", + "@types/node": "^14.14.35", + "ts-node": "^9.1.1", + "typescript": "^4.2.3" + } +} diff --git a/examples/custom-receiver/src/KoaReceiver.ts b/examples/custom-receiver/src/KoaReceiver.ts new file mode 100644 index 000000000..e94dde121 --- /dev/null +++ b/examples/custom-receiver/src/KoaReceiver.ts @@ -0,0 +1,335 @@ +/* eslint-disable node/no-extraneous-import */ +/* eslint-disable import/no-extraneous-dependencies */ +import { InstallProvider, CallbackOptions } from '@slack/oauth'; +import { ConsoleLogger, LogLevel, Logger } from '@slack/logger'; +import Router from '@koa/router'; +import Koa from 'koa'; +import { Server, IncomingMessage, ServerResponse } from 'http'; +import { + App, + CodedError, + Receiver, + ReceiverEvent, + ReceiverInconsistentStateError, + HTTPModuleFunctions as httpFunc, + HTTPResponseAck, + InstallProviderOptions, + InstallURLOptions, + BufferedIncomingMessage, + ReceiverDispatchErrorHandlerArgs, + ReceiverProcessEventErrorHandlerArgs, + ReceiverUnhandledRequestHandlerArgs, +} from '@slack/bolt'; + +// TODO: import from @slack/oauth +export interface InstallPathOptions { + beforeRedirection?: ( + request: IncomingMessage, + response: ServerResponse, + options?: InstallURLOptions + ) => Promise; +} + +export interface InstallerOptions { + stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore + stateVerification?: InstallProviderOptions['stateVerification']; // defaults true + authVersion?: InstallProviderOptions['authVersion']; // default 'v2' + metadata?: InstallURLOptions['metadata']; + installPath?: string; + directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install + renderHtmlForInstallPath?: (url: string) => string; + redirectUriPath?: string; + installPathOptions?: InstallPathOptions; + callbackOptions?: CallbackOptions; + userScopes?: InstallURLOptions['userScopes']; + clientOptions?: InstallProviderOptions['clientOptions']; + authorizationUrl?: InstallProviderOptions['authorizationUrl']; +} + +export interface KoaReceiverOptions { + signingSecret: string | (() => PromiseLike); + logger?: Logger; + logLevel?: LogLevel; + path?: string; + signatureVerification?: boolean; + processBeforeResponse?: boolean; + clientId?: string; + clientSecret?: string; + stateSecret?: InstallProviderOptions['stateSecret']; // required when using default stateStore + redirectUri?: string; + installationStore?: InstallProviderOptions['installationStore']; // default MemoryInstallationStore + scopes?: InstallURLOptions['scopes']; + installerOptions?: InstallerOptions; + koa: Koa; + router: Router; + customPropertiesExtractor?: ( + request: BufferedIncomingMessage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Record; + // NOTE: As http.RequestListener is not an async function, this cannot be async + dispatchErrorHandler?: (args: ReceiverDispatchErrorHandlerArgs) => void; + processEventErrorHandler?: ( + args: ReceiverProcessEventErrorHandlerArgs + ) => Promise; + // NOTE: As we use setTimeout under the hood, this cannot be async + unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestTimeoutMillis?: number; +} + +export default class KoaRecevier implements Receiver { + private app: App | undefined; + + private logger: Logger; + + private signingSecretProvider: string | (() => PromiseLike); + + private signatureVerification: boolean; + + private processBeforeResponse: boolean; + + private path: string; + + private unhandledRequestTimeoutMillis: number; + + private customPropertiesExtractor: ( + request: BufferedIncomingMessage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Record; + + private dispatchErrorHandler: (args: ReceiverDispatchErrorHandlerArgs) => void; + + private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; + + private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; + + // ---------------------------- + + private koa: Koa; + + private router: Router; + + private server: Server | undefined; + + private installer: InstallProvider | undefined; + + private installerOptions: InstallerOptions | undefined; + + public constructor(options: KoaReceiverOptions) { + this.signatureVerification = options.signatureVerification ?? true; + this.signingSecretProvider = options.signingSecret; + this.customPropertiesExtractor = options.customPropertiesExtractor !== undefined ? + options.customPropertiesExtractor : + (_) => ({}); + this.path = options.path ?? '/slack/events'; + this.unhandledRequestTimeoutMillis = options.unhandledRequestTimeoutMillis ?? 3001; + + this.koa = options.koa; + this.router = options.router; + this.logger = initializeLogger(options.logger, options.logLevel); + this.processBeforeResponse = options.processBeforeResponse ?? false; + this.dispatchErrorHandler = options.dispatchErrorHandler ?? httpFunc.defaultDispatchErrorHandler; + this.processEventErrorHandler = options.processEventErrorHandler ?? httpFunc.defaultProcessEventErrorHandler; + this.unhandledRequestHandler = options.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; + + this.installerOptions = options.installerOptions; + if ( + this.installerOptions && + this.installerOptions.installPath === undefined + ) { + this.installerOptions.installPath = '/slack/install'; + } + if ( + this.installerOptions && + this.installerOptions.redirectUriPath === undefined + ) { + this.installerOptions.redirectUriPath = '/slack/oauth_redirect'; + } + if (options.clientId && options.clientSecret) { + this.installer = new InstallProvider({ + ...this.installerOptions, + clientId: options.clientId, + clientSecret: options.clientSecret, + stateSecret: options.stateSecret, + installationStore: options.installationStore, + logger: options.logger, + logLevel: options.logLevel, + installUrlOptions: { + scopes: options.scopes ?? [], + userScopes: this.installerOptions?.userScopes, + metadata: this.installerOptions?.metadata, + redirectUri: options.redirectUri, + }, + }); + } + } + + private _sigingSecret: string | undefined; + + private async signingSecret(): Promise { + if (this._sigingSecret === undefined) { + this._sigingSecret = typeof this.signingSecretProvider === 'string' ? + this.signingSecretProvider : + await this.signingSecretProvider(); + } + return this._sigingSecret; + } + + public init(app: App): void { + this.app = app; + if ( + this.installer && + this.installerOptions && + this.installerOptions.installPath && + this.installerOptions.redirectUriPath + ) { + this.router.get(this.installerOptions.installPath, async (ctx) => { + await this.installer?.handleInstallPath( + ctx.req, + ctx.res, + this.installerOptions?.installPathOptions, + ); + }); + this.router.get(this.installerOptions.redirectUriPath, async (ctx) => { + await this.installer?.handleCallback( + ctx.req, + ctx.res, + this.installerOptions?.callbackOptions, + ); + }); + } + + this.router.post(this.path, async (ctx) => { + const { req, res } = ctx; + // Verify authenticity + let bufferedReq: BufferedIncomingMessage; + try { + bufferedReq = await httpFunc.parseAndVerifyHTTPRequest( + { + // If enabled: false, this method returns bufferredReq without verification + enabled: this.signatureVerification, + signingSecret: await this.signingSecret(), + }, + req, + ); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const e = err as any; + this.logger.warn(`Request verification failed: ${e.message}`); + httpFunc.buildNoBodyResponse(res, 401); + return; + } + + // Parse request body + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let body: any; + try { + body = httpFunc.parseHTTPRequestBody(bufferedReq); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const e = err as any; + this.logger.warn(`Malformed request body: ${e.message}`); + httpFunc.buildNoBodyResponse(res, 400); + return; + } + + // Handle SSL checks + if (body.ssl_check) { + httpFunc.buildSSLCheckResponse(res); + return; + } + + // Handle URL verification + if (body.type === 'url_verification') { + httpFunc.buildUrlVerificationResponse(res, body); + return; + } + + const ack = new HTTPResponseAck({ + logger: this.logger, + processBeforeResponse: this.processBeforeResponse, + unhandledRequestHandler: this.unhandledRequestHandler, + unhandledRequestTimeoutMillis: this.unhandledRequestTimeoutMillis, + httpRequest: bufferedReq, + httpResponse: res, + }); + // Structure the ReceiverEvent + const event: ReceiverEvent = { + body, + ack: ack.bind(), + retryNum: httpFunc.extractRetryNumFromHTTPRequest(req), + retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req), + customProperties: this.customPropertiesExtractor(bufferedReq), + }; + + // Send the event to the app for processing + try { + await this.app?.processEvent(event); + if (ack.storedResponse !== undefined) { + // in the case of processBeforeResponse: true + httpFunc.buildContentResponse(res, ack.storedResponse); + this.logger.debug('stored response sent'); + } + } catch (error) { + const acknowledgedByHandler = await this.processEventErrorHandler({ + error: error as Error | CodedError, + logger: this.logger, + request: req, + response: res, + storedResponse: ack.storedResponse, + }); + if (acknowledgedByHandler) { + // If the value is false, we don't touch the value as a race condition + // with ack() call may occur especially when processBeforeResponse: false + ack.markAsAcknowledged(); + } + } + }); + } + + public start(port: number = 3000): Promise { + // Enable routes + this.koa.use(this.router.routes()).use(this.router.allowedMethods()); + + // TODO: error handler here + return new Promise((resolve, reject) => { + try { + this.server = this.koa.listen(port); + resolve(this.server); + } catch (e) { + reject(e); + } + }); + } + + public stop(): Promise { + if (this.server === undefined) { + const errorMessage = 'The receiver cannot be stopped because it was not started.'; + return Promise.reject(new ReceiverInconsistentStateError(errorMessage)); + } + return new Promise((resolve, reject) => { + this.server?.close((error) => { + if (error !== undefined) { + return reject(error); + } + + this.server = undefined; + return resolve(); + }); + }); + } +} + +// TODO: move +export function initializeLogger( + logger: Logger | undefined, + logLevel: LogLevel | undefined, +): Logger { + if (logger !== undefined) { + return logger; + } + const newLogger = new ConsoleLogger(); + if (logLevel !== undefined) { + newLogger.setLevel(logLevel); + } + return newLogger; +} diff --git a/examples/custom-receiver/src/app.ts b/examples/custom-receiver/src/app.ts new file mode 100644 index 000000000..734d5bea5 --- /dev/null +++ b/examples/custom-receiver/src/app.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable import/no-internal-modules */ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable import/extensions */ +/* eslint-disable node/no-extraneous-import */ +/* eslint-disable import/no-extraneous-dependencies */ + +import Router from '@koa/router'; +import Koa from 'koa'; +import { App, FileInstallationStore } from '@slack/bolt'; +import { FileStateStore } from '@slack/oauth'; +import { ConsoleLogger, LogLevel } from '@slack/logger'; +import KoaRecevier from './KoaReceiver'; + +const logger = new ConsoleLogger(); +logger.setLevel(LogLevel.DEBUG); +const koa = new Koa(); +const router = new Router(); + +router.get('/', async (ctx) => { + ctx.redirect('/slack/install'); +}); + +const receiver = new KoaRecevier({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + signingSecret: process.env.SLACK_SIGNING_SECRET!, + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + scopes: ['commands', 'chat:write', 'app_mentions:read'], + installationStore: new FileInstallationStore(), + installerOptions: { + directInstall: true, + stateStore: new FileStateStore({}), + }, + koa, + router, +}); + +const app = new App({ + logLevel: LogLevel.DEBUG, + logger, + receiver, +}); + +app.event('app_mention', async ({ event, say }) => { + await say({ + text: `<@${event.user}> Hi there :wave:`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `<@${event.user}> Hi there :wave:`, + }, + }, + ], + }); +}); + +(async () => { + await app.start(); + logger.info('⚡️ Bolt app is running!'); +})(); diff --git a/examples/custom-receiver/tsconfig.eslint.json b/examples/custom-receiver/tsconfig.eslint.json new file mode 100644 index 000000000..d19325c71 --- /dev/null +++ b/examples/custom-receiver/tsconfig.eslint.json @@ -0,0 +1,13 @@ +// This config is only used to allow ESLint to use a different include / exclude setting than the actual build +{ + // extend the build config to share compilerOptions + "extends": "./tsconfig.json", + "compilerOptions": { + // Setting "noEmit" prevents misuses of this config such as using it to produce a build + "noEmit": true + }, + "include": [ + // Since extending a config overwrites the entire value for "include", those value are copied here + "src/**/*", + ] +} diff --git a/examples/custom-receiver/tsconfig.json b/examples/custom-receiver/tsconfig.json new file mode 100644 index 000000000..9bb02861c --- /dev/null +++ b/examples/custom-receiver/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "allowJs": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/src/errors.ts b/src/errors.ts index ab4bf0fcc..b8ed369c7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import type { BufferedIncomingMessage } from './receivers/verify-request'; +import type { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; export interface CodedError extends Error { code: string; // This can be a value from ErrorCode, or WebClient's ErrorCode, or a NodeJS error code diff --git a/src/index.ts b/src/index.ts index 1c63deed1..5be1a0061 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,16 @@ export { default as SocketModeReceiver, SocketModeReceiverOptions } from './rece export { default as HTTPReceiver, HTTPReceiverOptions } from './receivers/HTTPReceiver'; export { default as AwsLambdaReceiver, AwsLambdaReceiverOptions } from './receivers/AwsLambdaReceiver'; +export { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; +export { + HTTPModuleFunctions, + RequestVerificationOptions, + ReceiverDispatchErrorHandlerArgs, + ReceiverProcessEventErrorHandlerArgs, + ReceiverUnhandledRequestHandlerArgs, +} from './receivers/HTTPModuleFunctions'; +export { HTTPResponseAck } from './receivers/HTTPResponseAck'; + export * from './errors'; export * from './middleware/builtin'; export * from './types'; diff --git a/src/receivers/BufferedIncomingMessage.ts b/src/receivers/BufferedIncomingMessage.ts new file mode 100644 index 000000000..a755267f7 --- /dev/null +++ b/src/receivers/BufferedIncomingMessage.ts @@ -0,0 +1,5 @@ +import { IncomingMessage } from 'http'; + +export interface BufferedIncomingMessage extends IncomingMessage { + rawBody: Buffer; +} diff --git a/src/receivers/ExpressReceiver.ts b/src/receivers/ExpressReceiver.ts index 38dff9f4a..7cfa75312 100644 --- a/src/receivers/ExpressReceiver.ts +++ b/src/receivers/ExpressReceiver.ts @@ -12,7 +12,6 @@ import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOpt import App from '../App'; import { ReceiverAuthenticityError, - ReceiverMultipleAckError, ReceiverInconsistentStateError, ErrorCode, CodedError, @@ -21,7 +20,8 @@ import { AnyMiddlewareArgs, Receiver, ReceiverEvent } from '../types'; import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; import { verifyRedirectOpts } from './verify-redirect-opts'; import { StringIndexed } from '../types/helpers'; -import { extractRetryNum, extractRetryReason } from './http-utils'; +import { HTTPModuleFunctions as httpFunc } from './HTTPModuleFunctions'; +import { HTTPResponseAck } from './HTTPResponseAck'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -264,55 +264,25 @@ export default class ExpressReceiver implements Receiver { } private async requestHandler(req: Request, res: Response): Promise { - let isAcknowledged = false; - setTimeout(() => { - if (!isAcknowledged) { - this.logger.error( - 'An incoming event was not acknowledged within 3 seconds. Ensure that the ack() argument is called in a listener.', - ); - } - }, 3001); - - let storedResponse; + const ack = new HTTPResponseAck({ + logger: this.logger, + processBeforeResponse: this.processBeforeResponse, + unhandledRequestTimeoutMillis: 3001, // TODO: this.unhandledRequestTimeoutMillis + httpRequest: req, + httpResponse: res, + }); const event: ReceiverEvent = { body: req.body, - ack: async (response): Promise => { - this.logger.debug('ack() begin'); - if (isAcknowledged) { - throw new ReceiverMultipleAckError(); - } - isAcknowledged = true; - if (this.processBeforeResponse) { - if (!response) { - storedResponse = ''; - } else { - storedResponse = response; - } - this.logger.debug('ack() response stored'); - } else { - if (!response) { - res.send(''); - } else if (typeof response === 'string') { - res.send(response); - } else { - res.json(response); - } - this.logger.debug('ack() response sent'); - } - }, - retryNum: extractRetryNum(req), - retryReason: extractRetryReason(req), + ack: ack.bind(), + retryNum: httpFunc.extractRetryNumFromHTTPRequest(req), + retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req), customProperties: this.customPropertiesExtractor(req), }; try { await this.bolt?.processEvent(event); - if (storedResponse !== undefined) { - if (typeof storedResponse === 'string') { - res.send(storedResponse); - } else { - res.json(storedResponse); - } + if (ack.storedResponse !== undefined) { + httpFunc.buildContentResponse(res, ack.storedResponse); this.logger.debug('stored response sent'); } } catch (err) { @@ -322,12 +292,12 @@ export default class ExpressReceiver implements Receiver { const errorCode = (err as CodedError).code; if (errorCode === ErrorCode.AuthorizationError) { // authorize function threw an exception, which means there is no valid installation data - res.status(401).send(); - isAcknowledged = true; + httpFunc.buildNoBodyResponse(res, 401); + ack.markAsAcknowledged(); return; } } - res.status(500).send(); + httpFunc.buildNoBodyResponse(res, 500); throw err; } } diff --git a/src/receivers/HTTPModuleFunctions.ts b/src/receivers/HTTPModuleFunctions.ts new file mode 100644 index 000000000..c15b0baff --- /dev/null +++ b/src/receivers/HTTPModuleFunctions.ts @@ -0,0 +1,264 @@ +/* eslint-disable import/prefer-default-export */ +import { createHmac } from 'crypto'; +import { parse as qsParse } from 'querystring'; +import rawBody from 'raw-body'; +import tsscmp from 'tsscmp'; +import type { Logger } from '@slack/logger'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { CodedError, ErrorCode } from '../errors'; +import { BufferedIncomingMessage } from './BufferedIncomingMessage'; + +const verifyErrorPrefix = 'Failed to verify authenticity'; + +export class HTTPModuleFunctions { + // ------------------------------------------ + // Request header extraction + // ------------------------------------------ + + public static extractRetryNumFromHTTPRequest(req: IncomingMessage): number | undefined { + let retryNum; + const retryNumHeaderValue = req.headers['x-slack-retry-num']; + if (retryNumHeaderValue === undefined) { + retryNum = undefined; + } else if (typeof retryNumHeaderValue === 'string') { + retryNum = parseInt(retryNumHeaderValue, 10); + } else if (Array.isArray(retryNumHeaderValue) && retryNumHeaderValue.length > 0) { + retryNum = parseInt(retryNumHeaderValue[0], 10); + } + return retryNum; + } + + public static extractRetryReasonFromHTTPRequest(req: IncomingMessage): string | undefined { + let retryReason; + const retryReasonHeaderValue = req.headers['x-slack-retry-reason']; + if (retryReasonHeaderValue === undefined) { + retryReason = undefined; + } else if (typeof retryReasonHeaderValue === 'string') { + retryReason = retryReasonHeaderValue; + } else if (Array.isArray(retryReasonHeaderValue) && retryReasonHeaderValue.length > 0) { + // eslint-disable-next-line prefer-destructuring + retryReason = retryReasonHeaderValue[0]; + } + return retryReason; + } + + // ------------------------------------------ + // HTTP request parsing and verification + // ------------------------------------------ + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static parseHTTPRequestBody(req: BufferedIncomingMessage): any { + const bodyAsString = req.rawBody.toString(); + const contentType = req.headers['content-type']; + if (contentType === 'application/x-www-form-urlencoded') { + const parsedQs = qsParse(bodyAsString); + const { payload } = parsedQs; + if (typeof payload === 'string') { + return JSON.parse(payload); + } + return parsedQs; + } + return JSON.parse(bodyAsString); + } + + public static async parseAndVerifyHTTPRequest( + options: RequestVerificationOptions, + req: IncomingMessage, + _res?: ServerResponse, + ): Promise { + const { signingSecret } = options; + + // Consume the readable stream (or use the previously consumed readable stream) + const bufferedReq = await HTTPModuleFunctions.bufferIncomingMessage(req); + + if (options.enabled !== undefined && !options.enabled) { + // As the validation is disabled, immediately return the bufferred reuest + return bufferedReq; + } + + // Find the relevant request headers + const signature = HTTPModuleFunctions.getHeader(req, 'x-slack-signature'); + const requestTimestampSec = Number(HTTPModuleFunctions.getHeader(req, 'x-slack-request-timestamp')); + if (Number.isNaN(requestTimestampSec)) { + throw new Error( + `${verifyErrorPrefix}: header x-slack-request-timestamp did not have the expected type (${requestTimestampSec})`, + ); + } + + // Calculate time-dependent values + const nowMsFn = options.nowMilliseconds ?? (() => Date.now()); + const nowMs = nowMsFn(); + const fiveMinutesAgoSec = Math.floor(nowMs / 1000) - 60 * 5; + + // Enforce verification rules + + // Rule 1: Check staleness + if (requestTimestampSec < fiveMinutesAgoSec) { + throw new Error(`${verifyErrorPrefix}: stale`); + } + + // Rule 2: Check signature + // Separate parts of signature + const [signatureVersion, signatureHash] = signature.split('='); + // Only handle known versions + if (signatureVersion !== 'v0') { + throw new Error(`${verifyErrorPrefix}: unknown signature version`); + } + // Compute our own signature hash + const hmac = createHmac('sha256', signingSecret); + hmac.update(`${signatureVersion}:${requestTimestampSec}:${bufferedReq.rawBody.toString()}`); + const ourSignatureHash = hmac.digest('hex'); + if (!tsscmp(signatureHash, ourSignatureHash)) { + throw new Error(`${verifyErrorPrefix}: signature mismatch`); + } + + // Checks have passed! Return the value that has a side effect (the buffered request) + return bufferedReq; + } + + public static isBufferedIncomingMessage(req: IncomingMessage): req is BufferedIncomingMessage { + return Buffer.isBuffer((req as BufferedIncomingMessage).rawBody); + } + + public static getHeader(req: IncomingMessage, header: string): string { + const value = req.headers[header]; + if (value === undefined || Array.isArray(value)) { + throw new Error(`${verifyErrorPrefix}: header ${header} did not have the expected type (${value})`); + } + return value; + } + + public static async bufferIncomingMessage(req: IncomingMessage): Promise { + if (HTTPModuleFunctions.isBufferedIncomingMessage(req)) { + return req; + } + const bufferedRequest = req as BufferedIncomingMessage; + bufferedRequest.rawBody = await rawBody(req); + return bufferedRequest; + } + + // ------------------------------------------ + // HTTP response builder methods + // ------------------------------------------ + + public static buildNoBodyResponse(res: ServerResponse, status: number): void { + res.writeHead(status); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static buildUrlVerificationResponse(res: ServerResponse, body: any): void { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ challenge: body.challenge })); + } + + public static buildSSLCheckResponse(res: ServerResponse): void { + res.writeHead(200); + res.end(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static buildContentResponse(res: ServerResponse, body: string | any | undefined): void { + if (!body) { + res.writeHead(200); + res.end(); + } else if (typeof body === 'string') { + res.writeHead(200); + res.end(body); + } else { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + } + } + + // ------------------------------------------ + // Error handlers for event processing + // ------------------------------------------ + + // The default dispathErrorHandler implementation: + // Developers can customize this behavior by passing dispatchErrorHandler to the constructor + // Note that it was not possible to make this function async due to the limitation of http module + public static defaultDispatchErrorHandler(args: ReceiverDispatchErrorHandlerArgs): void { + const { error, logger, request, response } = args; + if ('code' in error) { + if (error.code === ErrorCode.HTTPReceiverDeferredRequestError) { + logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); + response.writeHead(404); + response.end(); + return; + } + } + logger.error(`An unexpected error occurred during a request (${request.method}) made to ${request.url}`); + logger.debug(`Error details: ${error}`); + response.writeHead(500); + response.end(); + } + + // The default processEventErrorHandler implementation: + // Developers can customize this behavior by passing processEventErrorHandler to the constructor + public static async defaultProcessEventErrorHandler( + args: ReceiverProcessEventErrorHandlerArgs, + ): Promise { + const { error, response, logger, storedResponse } = args; + if ('code' in error) { + // CodedError has code: string + const errorCode = (error as CodedError).code; + if (errorCode === ErrorCode.AuthorizationError) { + // authorize function threw an exception, which means there is no valid installation data + response.writeHead(401); + response.end(); + return true; + } + } + logger.error('An unhandled error occurred while Bolt processed an event'); + logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); + response.writeHead(500); + response.end(); + return false; + } + + // The default unhandledRequestHandler implementation: + // Developers can customize this behavior by passing unhandledRequestHandler to the constructor + // Note that this method cannot be an async function to align with the implementation using setTimeout + public static defaultUnhandledRequestHandler(args: ReceiverUnhandledRequestHandlerArgs): void { + const { logger } = args; + logger.error( + 'An incoming event was not acknowledged within 3 seconds. ' + + 'Ensure that the ack() argument is called in a listener.', + ); + } +} + +export interface RequestVerificationOptions { + enabled?: boolean; + signingSecret: string; + nowMilliseconds?: () => number; + logger?: Logger; +} + +// which handles errors occurred while dispatching a rqeuest +export interface ReceiverDispatchErrorHandlerArgs { + error: Error | CodedError; + logger: Logger; + request: IncomingMessage; + response: ServerResponse; +} + +// The arguments for the processEventErrorHandler, +// which handles errors `await app.processEvent(even)` method throws +export interface ReceiverProcessEventErrorHandlerArgs { + error: Error | CodedError; + logger: Logger; + request: IncomingMessage; + response: ServerResponse; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storedResponse: any; +} + +// The arguments for the unhandledRequestHandler, +// which deals with any unhandled incoming requests from Slack. +// (The default behavior is just printing error logs) +export interface ReceiverUnhandledRequestHandlerArgs { + logger: Logger; + request: IncomingMessage; + response: ServerResponse; +} diff --git a/src/receivers/HTTPReceiver.spec.ts b/src/receivers/HTTPReceiver.spec.ts index e44a876e5..57d67bbd4 100644 --- a/src/receivers/HTTPReceiver.spec.ts +++ b/src/receivers/HTTPReceiver.spec.ts @@ -13,7 +13,7 @@ import { HTTPReceiverDeferredRequestError, CodedError, } from '../errors'; -import { defaultDispatchErrorHandler, defaultProcessEventErrorHandler, defaultUnhandledRequestHandler } from './HTTPReceiver'; +import { HTTPModuleFunctions as httpFunc } from './HTTPModuleFunctions'; /* Testing Harness */ @@ -610,7 +610,7 @@ describe('HTTPReceiver', function () { fakeRes.writeHead = sinon.fake(); fakeRes.end = sinon.fake(); - defaultDispatchErrorHandler({ + httpFunc.defaultDispatchErrorHandler({ error: { code: 'foo' } as CodedError, logger: noopLogger, request: fakeReq, @@ -627,7 +627,7 @@ describe('HTTPReceiver', function () { fakeRes.writeHead = sinon.fake(); fakeRes.end = sinon.fake(); - const result = await defaultProcessEventErrorHandler({ + const result = await httpFunc.defaultProcessEventErrorHandler({ error: { code: 'foo' } as CodedError, logger: noopLogger, request: fakeReq, @@ -646,7 +646,7 @@ describe('HTTPReceiver', function () { fakeRes.writeHead = sinon.fake(); fakeRes.end = sinon.fake(); - defaultUnhandledRequestHandler({ + httpFunc.defaultUnhandledRequestHandler({ logger: noopLogger, request: fakeReq, response: fakeRes, diff --git a/src/receivers/HTTPReceiver.ts b/src/receivers/HTTPReceiver.ts index b87aa128a..528a20438 100644 --- a/src/receivers/HTTPReceiver.ts +++ b/src/receivers/HTTPReceiver.ts @@ -2,26 +2,29 @@ import { createServer, Server, ServerOptions, RequestListener, IncomingMessage, ServerResponse } from 'http'; import { createServer as createHttpsServer, Server as HTTPSServer, ServerOptions as HTTPSServerOptions } from 'https'; import { ListenOptions } from 'net'; -import { parse as qsParse } from 'querystring'; import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions } from '@slack/oauth'; import { URL } from 'url'; -import { verify as verifySlackAuthenticity, BufferedIncomingMessage } from './verify-request'; import { verifyRedirectOpts } from './verify-redirect-opts'; import App from '../App'; import { Receiver, ReceiverEvent } from '../types'; import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; import { - ReceiverMultipleAckError, ReceiverInconsistentStateError, HTTPReceiverDeferredRequestError, - ErrorCode, CodedError, } from '../errors'; import { CustomRoute, prepareRoutes, ReceiverRoutes } from './custom-routes'; import { StringIndexed } from '../types/helpers'; -import { extractRetryNum, extractRetryReason } from './http-utils'; +import { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import { + HTTPModuleFunctions as httpFunc, + ReceiverDispatchErrorHandlerArgs, + ReceiverProcessEventErrorHandlerArgs, + ReceiverUnhandledRequestHandlerArgs, +} from './HTTPModuleFunctions'; +import { HTTPResponseAck } from './HTTPResponseAck'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -78,10 +81,10 @@ export interface HTTPReceiverOptions { installerOptions?: HTTPReceiverInstallerOptions; customPropertiesExtractor?: (request: BufferedIncomingMessage) => StringIndexed; // NOTE: As http.RequestListener is not an async function, this cannot be async - dispatchErrorHandler?: (args: HTTPReceiverDispatchErrorHandlerArgs) => void; - processEventErrorHandler?: (args: HTTPReceiverProcessEventErrorHandlerArgs) => Promise; + dispatchErrorHandler?: (args: ReceiverDispatchErrorHandlerArgs) => void; + processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: As we use setTimeout under the hood, this cannot be async - unhandledRequestHandler?: (args: HTTPReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; } @@ -104,87 +107,6 @@ export interface HTTPReceiverInstallerOptions { port?: number; } -// The arguments for the dispatchErrorHandler, -// which handles errors occurred while dispatching a rqeuest -export interface HTTPReceiverDispatchErrorHandlerArgs { - error: Error | CodedError; - logger: Logger; - request: IncomingMessage; - response: ServerResponse; -} - -// The default dispathErrorHandler implementation: -// Developers can customize this behavior by passing dispatchErrorHandler to the constructor -// Note that it was not possible to make this function async due to the limitation of http module -export function defaultDispatchErrorHandler(args: HTTPReceiverDispatchErrorHandlerArgs): void { - const { error, logger, request, response } = args; - if ('code' in error) { - if (error.code === ErrorCode.HTTPReceiverDeferredRequestError) { - logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); - response.writeHead(404); - response.end(); - return; - } - } - logger.error(`An unexpected error occurred during a request (${request.method}) made to ${request.url}`); - logger.debug(`Error details: ${error}`); - response.writeHead(500); - response.end(); -} - -// The arguments for the processEventErrorHandler, -// which handles errors `await app.processEvent(even)` method throws -export interface HTTPReceiverProcessEventErrorHandlerArgs { - error: Error | CodedError; - logger: Logger; - request: IncomingMessage; - response: ServerResponse; - storedResponse: any; -} - -// The default processEventErrorHandler implementation: -// Developers can customize this behavior by passing processEventErrorHandler to the constructor -export async function defaultProcessEventErrorHandler( - args: HTTPReceiverProcessEventErrorHandlerArgs, -): Promise { - const { error, response, logger, storedResponse } = args; - if ('code' in error) { - // CodedError has code: string - const errorCode = (error as CodedError).code; - if (errorCode === ErrorCode.AuthorizationError) { - // authorize function threw an exception, which means there is no valid installation data - response.writeHead(401); - response.end(); - return true; - } - } - logger.error('An unhandled error occurred while Bolt processed an event'); - logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); - response.writeHead(500); - response.end(); - return false; -} - -// The arguments for the unhandledRequestHandler, -// which deals with any unhandled incoming requests from Slack. -// (The default behavior is just printing error logs) -export interface HTTPReceiverUnhandledRequestHandlerArgs { - logger: Logger; - request: IncomingMessage; - response: ServerResponse; -} - -// The default unhandledRequestHandler implementation: -// Developers can customize this behavior by passing unhandledRequestHandler to the constructor -// Note that this method cannot be an async function to align with the implementation using setTimeout -export function defaultUnhandledRequestHandler(args: HTTPReceiverUnhandledRequestHandlerArgs): void { - const { logger } = args; - logger.error( - 'An incoming event was not acknowledged within 3 seconds. ' + - 'Ensure that the ack() argument is called in a listener.', - ); -} - /** * Receives HTTP requests with Events, Slash Commands, and Actions */ @@ -227,11 +149,11 @@ export default class HTTPReceiver implements Receiver { private customPropertiesExtractor: (request: BufferedIncomingMessage) => StringIndexed; - private dispatchErrorHandler: (args: HTTPReceiverDispatchErrorHandlerArgs) => void; + private dispatchErrorHandler: (args: ReceiverDispatchErrorHandlerArgs) => void; - private processEventErrorHandler: (args: HTTPReceiverProcessEventErrorHandlerArgs) => Promise; + private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; - private unhandledRequestHandler: (args: HTTPReceiverUnhandledRequestHandlerArgs) => void; + private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; private unhandledRequestTimeoutMillis: number; @@ -252,9 +174,9 @@ export default class HTTPReceiver implements Receiver { scopes = undefined, installerOptions = {}, customPropertiesExtractor = (_req) => ({}), - dispatchErrorHandler = defaultDispatchErrorHandler, - processEventErrorHandler = defaultProcessEventErrorHandler, - unhandledRequestHandler = defaultUnhandledRequestHandler, + dispatchErrorHandler = httpFunc.defaultDispatchErrorHandler, + processEventErrorHandler = httpFunc.defaultProcessEventErrorHandler, + unhandledRequestHandler = httpFunc.defaultUnhandledRequestHandler, unhandledRequestTimeoutMillis = 3001, }: HTTPReceiverOptions) { // Initialize instance variables, substituting defaults for each value @@ -485,7 +407,7 @@ export default class HTTPReceiver implements Receiver { // Verify authenticity try { - bufferedReq = await verifySlackAuthenticity( + bufferedReq = await httpFunc.parseAndVerifyHTTPRequest( { // If enabled: false, this method returns bufferredReq without verification enabled: this.signatureVerification, @@ -496,8 +418,7 @@ export default class HTTPReceiver implements Receiver { } catch (err) { const e = err as any; this.logger.warn(`Request verification failed: ${e.message}`); - res.writeHead(401); - res.end(); + httpFunc.buildNoBodyResponse(res, 401); return; } @@ -506,91 +427,49 @@ export default class HTTPReceiver implements Receiver { // req object, so that its as reusable as possible. Later, we should consider adding an option for assigning the // parsed body to `req.body`, as this convention has been established by the popular `body-parser` package. try { - body = parseBody(bufferedReq); + body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { const e = err as any; this.logger.warn(`Malformed request body: ${e.message}`); - res.writeHead(400); - res.end(); + httpFunc.buildNoBodyResponse(res, 400); return; } // Handle SSL checks if (body.ssl_check) { - res.writeHead(200); - res.end(); + httpFunc.buildNoBodyResponse(res, 200); return; } // Handle URL verification if (body.type === 'url_verification') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ challenge: body.challenge })); + httpFunc.buildUrlVerificationResponse(res, body); return; } - // Setup ack timeout warning - let isAcknowledged = false; - setTimeout(() => { - if (!isAcknowledged) { - this.unhandledRequestHandler({ - logger: this.logger, - request: req, - response: res, - }); - } - }, this.unhandledRequestTimeoutMillis); - + const ack = new HTTPResponseAck({ + logger: this.logger, + processBeforeResponse: this.processBeforeResponse, + unhandledRequestHandler: this.unhandledRequestHandler, + unhandledRequestTimeoutMillis: this.unhandledRequestTimeoutMillis, + httpRequest: bufferedReq, + httpResponse: res, + }); // Structure the ReceiverEvent - let storedResponse; const event: ReceiverEvent = { body, - ack: async (response) => { - this.logger.debug('ack() begin'); - if (isAcknowledged) { - throw new ReceiverMultipleAckError(); - } - isAcknowledged = true; - if (this.processBeforeResponse) { - // In the case where processBeforeResponse: true is enabled, we don't send the HTTP response immediately. - // We hold off until the listener execution is completed. - if (!response) { - storedResponse = ''; - } else { - storedResponse = response; - } - this.logger.debug('ack() response stored'); - } else { - if (!response) { - res.writeHead(200); - res.end(); - } else if (typeof response === 'string') { - res.writeHead(200); - res.end(response); - } else { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(response)); - } - this.logger.debug('ack() response sent'); - } - }, - retryNum: extractRetryNum(req), - retryReason: extractRetryReason(req), + ack: ack.bind(), + retryNum: httpFunc.extractRetryNumFromHTTPRequest(req), + retryReason: httpFunc.extractRetryReasonFromHTTPRequest(req), customProperties: this.customPropertiesExtractor(bufferedReq), }; // Send the event to the app for processing try { await this.app?.processEvent(event); - if (storedResponse !== undefined) { + if (ack.storedResponse !== undefined) { // in the case of processBeforeResponse: true - if (typeof storedResponse === 'string') { - res.writeHead(200); - res.end(storedResponse); - } else { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(storedResponse)); - } + httpFunc.buildContentResponse(res, ack.storedResponse); this.logger.debug('stored response sent'); } } catch (error) { @@ -599,12 +478,12 @@ export default class HTTPReceiver implements Receiver { logger: this.logger, request: req, response: res, - storedResponse, + storedResponse: ack.storedResponse, }); if (acknowledgedByHandler) { // If the value is false, we don't touch the value as a race condition // with ack() call may occur especially when processBeforeResponse: false - isAcknowledged = true; + ack.markAsAcknowledged(); } } })(); @@ -676,19 +555,3 @@ export default class HTTPReceiver implements Receiver { } } } - -// Helpers - -function parseBody(req: BufferedIncomingMessage) { - const bodyAsString = req.rawBody.toString(); - const contentType = req.headers['content-type']; - if (contentType === 'application/x-www-form-urlencoded') { - const parsedQs = qsParse(bodyAsString); - const { payload } = parsedQs; - if (typeof payload === 'string') { - return JSON.parse(payload); - } - return parsedQs; - } - return JSON.parse(bodyAsString); -} diff --git a/src/receivers/HTTPResponseAck.ts b/src/receivers/HTTPResponseAck.ts new file mode 100644 index 000000000..6f9667633 --- /dev/null +++ b/src/receivers/HTTPResponseAck.ts @@ -0,0 +1,88 @@ +import { Logger } from '@slack/logger'; +import { IncomingMessage, ServerResponse } from 'http'; +import { AckFn } from '../types'; +import { ReceiverMultipleAckError } from '../errors'; +import { HTTPModuleFunctions as httpFunc, ReceiverUnhandledRequestHandlerArgs } from './HTTPModuleFunctions'; + +export interface AckArgs { + logger: Logger; + processBeforeResponse: boolean; + unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestTimeoutMillis?: number; + httpRequest: IncomingMessage, + httpResponse: ServerResponse, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HTTResponseBody = any | string | undefined; + +export class HTTPResponseAck { + private logger: Logger; + + private isAcknowledged: boolean; + + private processBeforeResponse: boolean; + + private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; + + private unhandledRequestTimeoutMillis: number; + + private httpRequest: IncomingMessage; + + private httpResponse: ServerResponse; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public storedResponse: any | string | undefined; + + public constructor(args: AckArgs) { + this.logger = args.logger; + this.isAcknowledged = false; + this.processBeforeResponse = args.processBeforeResponse; + this.unhandledRequestHandler = args.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; + this.unhandledRequestTimeoutMillis = args.unhandledRequestTimeoutMillis ?? 3001; + this.httpRequest = args.httpRequest; + this.httpResponse = args.httpResponse; + this.storedResponse = undefined; + this.init(); + } + + private init(): HTTPResponseAck { + setTimeout(() => { + if (!this.isAcknowledged) { + this.unhandledRequestHandler({ + logger: this.logger, + request: this.httpRequest, + response: this.httpResponse, + }); + } + }, this.unhandledRequestTimeoutMillis); + return this; + } + + public bind(): AckFn { + return async (responseBody) => { + this.logger.debug(`ack() call begins (body: ${responseBody})`); + if (this.isAcknowledged) { + throw new ReceiverMultipleAckError(); + } + this.markAsAcknowledged(); + if (this.processBeforeResponse) { + // In the case where processBeforeResponse: true is enabled, + // we don't send the HTTP response immediately. We hold off until the listener execution is completed. + if (!responseBody) { + this.storedResponse = ''; + } else { + this.storedResponse = responseBody; + } + this.logger.debug(`ack() response stored (body: ${responseBody})`); + } else { + httpFunc.buildContentResponse(this.httpResponse, responseBody); + this.logger.debug(`ack() response sent (body: ${responseBody})`); + } + }; + } + + public markAsAcknowledged(): void { + this.isAcknowledged = true; + } +} diff --git a/src/receivers/http-utils.ts b/src/receivers/http-utils.ts index 9e712ab21..ad00d28cd 100644 --- a/src/receivers/http-utils.ts +++ b/src/receivers/http-utils.ts @@ -1,5 +1,6 @@ import { IncomingMessage } from 'http'; +// Deprecated: this function will be removed in the near future export function extractRetryNum(req: IncomingMessage): number | undefined { let retryNum; const retryNumHeaderValue = req.headers['x-slack-retry-num']; @@ -13,6 +14,7 @@ export function extractRetryNum(req: IncomingMessage): number | undefined { return retryNum; } +// Deprecated: this function will be removed in the near future export function extractRetryReason(req: IncomingMessage): string | undefined { let retryReason; const retryReasonHeaderValue = req.headers['x-slack-retry-reason']; diff --git a/src/receivers/render-html-for-install-path.ts b/src/receivers/render-html-for-install-path.ts index 15b782022..5609d6082 100644 --- a/src/receivers/render-html-for-install-path.ts +++ b/src/receivers/render-html-for-install-path.ts @@ -1,3 +1,5 @@ +// Deprecated: this function will be removed in the near future +// Use the ones from @slack/oauth (v2.5 or newer) instead export default function defaultRenderHtmlForInstallPath(addToSlackUrl: string): string { return ` @@ -14,6 +16,7 @@ export default function defaultRenderHtmlForInstallPath(addToSlackUrl: string): `; } +// Deprecated: this function will be removed in the near future // For backward-compatibility export function renderHtmlForInstallPath(addToSlackUrl: string): string { return defaultRenderHtmlForInstallPath(addToSlackUrl); diff --git a/src/receivers/verify-request.ts b/src/receivers/verify-request.ts index fffe85a8c..502ba8735 100644 --- a/src/receivers/verify-request.ts +++ b/src/receivers/verify-request.ts @@ -1,122 +1,22 @@ -/** - * Functions used to verify the authenticity of incoming HTTP requests from Slack. - * - * The functions in this file are intentionally generic (don't depend on any particular web framework) and - * time-independent (for testing) so they can be used in a wide variety of applications. The intention is to distribute - * these functions in its own package. - * - * For now, there is some duplication between the contents of this file and ExpressReceiver.ts. Later, the duplication - * can be reduced by implementing the equivalent functionality in terms of the functions in this file. - */ - -import { createHmac } from 'crypto'; -import rawBody from 'raw-body'; -import tsscmp from 'tsscmp'; +// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. import type { Logger } from '@slack/logger'; import type { IncomingMessage, ServerResponse } from 'http'; +import { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import { HTTPModuleFunctions, RequestVerificationOptions } from './HTTPModuleFunctions'; -const verifyErrorPrefix = 'Failed to verify authenticity'; - -export interface VerifyOptions { +// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. +export interface VerifyOptions extends RequestVerificationOptions { enabled?: boolean; signingSecret: string; nowMs?: () => number; logger?: Logger; } -export interface BufferedIncomingMessage extends IncomingMessage { - rawBody: Buffer; -} - -/** - * Verify the authenticity of an incoming HTTP request from Slack and buffer the HTTP body. - * - * When verification succeeds, the returned promise is resolved. When verification fails, the returned promise is - * rejected with an error describing the reason. IMPORTANT: The error messages may contain sensitive information about - * failures, do not return the error message text to users in a production environment. It's recommended to catch all - * errors and return an opaque failure (HTTP status code 401, no body). - * - * Verification requires consuming `req` as a Readable stream. If the `req` was consumed before this function is called, - * then this function expects it to be stored as a Buffer at `req.rawBody`. This is a convention used by infrastructure - * platforms such as Google Cloud Platform. When the function returns, the buffered body is stored at the `req.rawBody` - * property for further handling. - * - * The function is designed to be curry-able for use as a standard http RequestListener, and therefore keeps `req` and - * `res` are the last arguments. However, the function is also async, which means when it is curried for use as a - * RequestListener, the caller should also capture and use the return value. - */ +// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. export async function verify( options: VerifyOptions, req: IncomingMessage, _res?: ServerResponse, ): Promise { - const { signingSecret } = options; - - // Consume the readable stream (or use the previously consumed readable stream) - const bufferedReq = await bufferIncomingMessage(req); - - if (options.enabled !== undefined && !options.enabled) { - // As the validation is disabled, immediately return the bufferred reuest - return bufferedReq; - } - - // Find the relevant request headers - const signature = getHeader(req, 'x-slack-signature'); - const requestTimestampSec = Number(getHeader(req, 'x-slack-request-timestamp')); - if (Number.isNaN(requestTimestampSec)) { - throw new Error( - `${verifyErrorPrefix}: header x-slack-request-timestamp did not have the expected type (${requestTimestampSec})`, - ); - } - - // Calculate time-dependent values - const nowMsFn = options.nowMs ?? (() => Date.now()); - const nowMs = nowMsFn(); - const fiveMinutesAgoSec = Math.floor(nowMs / 1000) - 60 * 5; - - // Enforce verification rules - - // Rule 1: Check staleness - if (requestTimestampSec < fiveMinutesAgoSec) { - throw new Error(`${verifyErrorPrefix}: stale`); - } - - // Rule 2: Check signature - // Separate parts of signature - const [signatureVersion, signatureHash] = signature.split('='); - // Only handle known versions - if (signatureVersion !== 'v0') { - throw new Error(`${verifyErrorPrefix}: unknown signature version`); - } - // Compute our own signature hash - const hmac = createHmac('sha256', signingSecret); - hmac.update(`${signatureVersion}:${requestTimestampSec}:${bufferedReq.rawBody.toString()}`); - const ourSignatureHash = hmac.digest('hex'); - if (!tsscmp(signatureHash, ourSignatureHash)) { - throw new Error(`${verifyErrorPrefix}: signature mismatch`); - } - - // Checks have passed! Return the value that has a side effect (the buffered request) - return bufferedReq; -} - -async function bufferIncomingMessage(req: IncomingMessage): Promise { - if (isBufferedIncomingMessage(req)) { - return req; - } - const bufferedRequest = req as BufferedIncomingMessage; - bufferedRequest.rawBody = await rawBody(req); - return bufferedRequest; -} - -function isBufferedIncomingMessage(req: IncomingMessage): req is BufferedIncomingMessage { - return Buffer.isBuffer((req as BufferedIncomingMessage).rawBody); -} - -function getHeader(req: IncomingMessage, header: string): string { - const value = req.headers[header]; - if (value === undefined || Array.isArray(value)) { - throw new Error(`${verifyErrorPrefix}: header ${header} did not have the expected type (${value})`); - } - return value; + return HTTPModuleFunctions.parseAndVerifyHTTPRequest(options, req, _res); } diff --git a/types-tests/error.test-d.ts b/types-tests/error.test-d.ts index 81997f0fa..db25f3e07 100644 --- a/types-tests/error.test-d.ts +++ b/types-tests/error.test-d.ts @@ -2,7 +2,7 @@ import App from '../src/App'; import { expectType } from 'tsd'; import { CodedError } from '../src/errors'; import { IncomingMessage, ServerResponse } from 'http'; -import { BufferedIncomingMessage } from '../src/receivers/verify-request'; +import { BufferedIncomingMessage } from '../src/receivers/BufferedIncomingMessage'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' });