Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for http.IncomingMessage #6

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 42 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,66 @@

# validate-slack-request

A simple module to validate Slack requests based on [this article](https://api.slack.com/docs/verifying-requests-from-slack). The module requires a valid expressJS request object as defined [here](https://expressjs.com/en/api.html#reqhttps://expressjs.com/en/api.html#req). See more about that in the [API section](#api)
A simple module to validate Slack requests passed in via an **Express**, **Next**, or a **Node.js** `http` endpoint handler function, based off of the specification included in [the official Slack guide](https://api.slack.com/docs/verifying-requests-from-slack) for request validation.

Disclaimer: this module is not developed nor endorsed by Slack
**Disclaimer**: this module is not developed nor endorsed by Slack.

# Installation

To install use:
## Installation

```$ npm install validate-slack-request```

Since Slack is sending requests using a POST with `application/x-www-form-urlencoded` encoded payload, the `express.urlencoded` module needs to be enabled in Express in order to parse the POST payload. To enable it, add the following to your main express script (e.g. `app.js` or `server.js`)
## Usage

```javascript
const validateSlackRequest = require('validateSlackRequest')

const signSecret = process.env.SLACK_SIGNING_SECRET

const endpointHandler = async (req, res) => { // ← your endpoint handler

const isValid = await validateSlackRequest(signSecret, req)

if (!isValid) {
// for Express & Next.js
res.status(403).send("Invalid Slack signing secret")
return

// for the Node.js http module
res.statusCode = 403
res.end("Invalid Slack signing secret")
return
}
}
```
### For Express Users
If using Express, you must register the `express.urlencoded` middleware with your Express app:
```javascript
app.use(express.urlencoded({ extended: true })) // for parsing application/x-www-form-urlencoded
const express = require("express")
const app = express()

app.use(express.urlencoded({ extended: true })) // ← register it with your app
```
See the [API section](#API) below to learn more about why we require the `express.urlencoded` middleware.

# API

```validateSlackRequest (slackAppSigningSecret, httpReq, logging)```

Where:
* `slackAppSigningSecret`: this is the Slack Signing Secret assigned to the app when created. This can be accessed from the Slack app settings under "Basic Information". Look for "Signing secret".
* `httpReq`: Express request object as defined [here](https://expressjs.com/en/api.html#reqhttps://expressjs.com/en/api.html#req). If this module is used outside Express, make sure that `httpreq` exposes the following:
* `get()`: used to retrieve HTTP request headers (e.g. `httpReq.get('Content-Type')`)
* `.body` : JSON object representing the body of the HTTP POST request.
* `logging`: Optional parameter (default value is `false`) to print log information to console.
* `slackAppSigningSecret`: the [Slack Signing Secret](https://api.slack.com/authentication/verifying-requests-from-slack#about) assigned to your [Slack app](https://api.slack.com/authentication/verifying-requests-from-slack#about). We recommend storing this in an environment variable for security (see [Usage](#Usage) above).

# Example
* `httpReq`: the `req` parameter passed to your endpoint handler function.
- for **Express** users, this is a [Request](https://expressjs.com/en/api.html#req) object**
- for **Node.js** `http` module users, this is an [IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage) object
- for **Next.js** users, this is a [_modified_ IncomingMessage](https://nextjs.org/docs/api-routes/introduction) object
- _don't see your framework here?_ [Open an Issue 😃](https://github.com/gverni/validate-slack-request/issues/new/choose)

In express it can be added to your route using:

```
const slackValidateRequest = require('validate-slack-request')
* `logging`: Optional parameter (default value is `false`) to print log information to console.

...
### \** For Express Users
Slack sends POST requests with an `application/x-www-form-urlencoded` encoded payload. **Express users** must register the `express.urlencoded` middleware with their Express app so that `validate-slack-request` can access that payload. See the sample code provided above under [Usage](#For-Express-Users) for guidance.

router.post('/', function (req, res, next) {
if (validateSlackRequest(process.env.SLACK_APP_SIGNING_SECRET, req)) {
// Valid request - Send appropriate response
res.send(...)
}
...
}
```

Above example assumes that the signing secret is stored in environment variable `SLACK_APP_SIGNING_SECRET` (hardcoding of this variable is not advised)

# Errors
### Errors

Following errors are thrown when invalid arguments are passed:

Expand Down
39 changes: 33 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,53 @@ function fixedEncodeURIComponent (str) {
})
}

// Reads the body payload from an http.IncomingMessage stream
function getBodyFromStream(httpReq) {
return new Promise((resolve, reject) => {
let bodyString = ''
httpReq.on('data', chunk => bodyString += chunk.toString());
httpReq.on('end', () => {
resolve(bodyString)
})
httpReq.on('error', err => {
reject(err)
})
})
}

/**
* Validate incoming Slack request
*
* @param {string} slackAppSigningSecret - Slack application signing secret
* @param {object} httpReq - Express request object
* @param {object} httpReq - http.IncomingMessage or Express request object
* @param {boolean} [logging=false] - Enable logging to console
*
* @returns {boolean} Result of vlaidation
* @returns {boolean} Result of validation
*/
function validateSlackRequest (slackAppSigningSecret, httpReq, logging) {
async function validateSlackRequest (slackAppSigningSecret, httpReq, logging) {
logging = logging || false
if (typeof logging !== 'boolean') {
throw new Error('Invalid type for logging. Provided ' + typeof logging + ', expected boolean')
}
if (!slackAppSigningSecret || typeof slackAppSigningSecret !== 'string' || slackAppSigningSecret === '') {
throw new Error('Invalid slack app signing secret')
}
const xSlackRequestTimeStamp = httpReq.get('X-Slack-Request-Timestamp')
const SlackSignature = httpReq.get('X-Slack-Signature')
const bodyPayload = fixedEncodeURIComponent(querystring.stringify(httpReq.body).replace(/%20/g, '+')) // Fix for #1

const get = (
httpReq.get
||(function(str) {
return this.headers[str.toLowerCase()]
})).bind(httpReq); // support for http.IncomingRequest headers

const xSlackRequestTimeStamp = get('X-Slack-Request-Timestamp')
const SlackSignature = get('X-Slack-Signature')
let bodyPayload
if (typeof httpReq.body === 'object') {
bodyPayload = querystring.stringify(httpReq.body)
} else {
bodyPayload = await getBodyFromStream(httpReq) // support for http.IncomingRequest stream
}
bodyPayload = fixedEncodeURIComponent(bodyPayload.replace(/%20/g, '+')) // Fix for #1
if (!(xSlackRequestTimeStamp && SlackSignature && bodyPayload)) {
if (logging) { console.log('Missing part in Slack\'s request') }
return false
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "validate-slack-request",
"version": "0.1.4",
"description": "A simple module to validate slack request in Express",
"version": "0.1.5",
"description": "A simple module to validate Slack requests",
"main": "index.js",
"scripts": {
"test": "mocha"
Expand All @@ -12,6 +12,7 @@
},
"keywords": [
"slack",
"http",
"express"
],
"author": "gverni",
Expand Down
33 changes: 33 additions & 0 deletions test/express.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Tests for the Express request object
*/

const runTests = require('./util')

function getTestHttpRequest (textArgs, bodyChanges) {
return {
'headers': {
'x-slack-request-timestamp': '1531420618',
'x-slack-signature': textArgs ? 'v0=a3e650d30d1e91901834f91d048c9d3c0a50e4dcffcef7bc67884e95df8588ce' : 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503',
},
'body': {
'token': 'xyzz0WbapA4vBCDEFasx0q6G',
'team_id': 'T1DC2JH3J',
'team_domain': 'testteamnow',
'channel_id': 'G8PSS9T3V',
'channel_name': 'foobar',
'user_id': 'U2CERLKJA',
'user_name': 'roadrunner',
'command': '/webhook-collect',
'text': textArgs || '',
'response_url': 'https://hooks.slack.com/commands/T1DC2JH3J/397700885554/96rGlfmibIGlgcZRskXaIFfN',
'trigger_id': '398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c',
...bodyChanges
},
get: function (element) {
return this.headers[element.toLowerCase()]
}
}
}

runTests('Express', getTestHttpRequest)
41 changes: 41 additions & 0 deletions test/http.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Tests for the standard http request object
*/

const EventEmitter = require('events')
const { stringify } = require('querystring')

const runTests = require('./util')
const eventEmitter = new EventEmitter()

function getTestHttpRequest (textArgs, bodyChanges) {
const body = {
'token': 'xyzz0WbapA4vBCDEFasx0q6G',
'team_id': 'T1DC2JH3J',
'team_domain': 'testteamnow',
'channel_id': 'G8PSS9T3V',
'channel_name': 'foobar',
'user_id': 'U2CERLKJA',
'user_name': 'roadrunner',
'command': '/webhook-collect',
'text': textArgs || '',
'response_url': 'https://hooks.slack.com/commands/T1DC2JH3J/397700885554/96rGlfmibIGlgcZRskXaIFfN',
'trigger_id': '398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c',
...bodyChanges
}

process.nextTick(() => {
eventEmitter.emit('data', stringify(body))
eventEmitter.emit('end')
});

return {
'headers': {
'x-slack-request-timestamp': '1531420618',
'x-slack-signature': textArgs ? 'v0=a3e650d30d1e91901834f91d048c9d3c0a50e4dcffcef7bc67884e95df8588ce' : 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503',
},
on: (name, cb) => eventEmitter.on(name, cb)
}
}

runTests('http', getTestHttpRequest)
30 changes: 30 additions & 0 deletions test/next.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Tests for the Next.js request object
*/

const runTests = require('./util')

function getTestHttpRequest (textArgs, bodyChanges) {
return {
'headers': {
'x-slack-request-timestamp': '1531420618',
'x-slack-signature': textArgs ? 'v0=a3e650d30d1e91901834f91d048c9d3c0a50e4dcffcef7bc67884e95df8588ce' : 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503',
},
'body': {
'token': 'xyzz0WbapA4vBCDEFasx0q6G',
'team_id': 'T1DC2JH3J',
'team_domain': 'testteamnow',
'channel_id': 'G8PSS9T3V',
'channel_name': 'foobar',
'user_id': 'U2CERLKJA',
'user_name': 'roadrunner',
'command': '/webhook-collect',
'text': textArgs || '',
'response_url': 'https://hooks.slack.com/commands/T1DC2JH3J/397700885554/96rGlfmibIGlgcZRskXaIFfN',
'trigger_id': '398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c',
...bodyChanges
},
}
}

runTests('Next.js', getTestHttpRequest)
101 changes: 101 additions & 0 deletions test/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* eslint-env mocha */
const assert = require('assert')
const slackValidateRequest = require('..')


// Test object. Simulate an express request object
const slackSigningSecret = '8f742231b10e8888abcd99yyyzzz85a5'
let testHttpRequest

/**
* Runs a set of tests against an HTTP request
* object generated by the given framework
* @param {string} frameworkName
* @param {function} getTestHttpRequest
*/
function runTests(frameworkName, getTestHttpRequest) {
describe(`${frameworkName} request test`, function () {
describe('Basic test', async function () {
it('should return true with test object', async function () {
assert.equal(await slackValidateRequest(slackSigningSecret, getTestHttpRequest()), true)
})
})

describe('Test multiple args', function () {
it('should return true', async function () {
assert.equal(await slackValidateRequest(slackSigningSecret, getTestHttpRequest('args1 args2')), true)
})
})

describe('Test special characters in command', function() {
it('should return true', async function() {
testHttpRequest = getTestHttpRequest('(!)')
testHttpRequest.headers['x-slack-signature'] = 'v0=85b7bd32a59380ae4a50db6d76eed906f36daec1660ceced4907f44eaaf60757'
assert.equal(await slackValidateRequest('slackSigningSecret', testHttpRequest), true)
})
})

describe('Wrong signature', function () {
it('should return false if the signature doesn\'t match', async function () {
testHttpRequest = getTestHttpRequest()
testHttpRequest.headers['x-slack-signature'] = 'v0=a2114d57b58eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503'
assert.equal(await slackValidateRequest(slackSigningSecret, testHttpRequest), false)
})
})

describe('Wrong Signing Secret', function () {
it('should return false if the signing secret is not the correct one', async function () {
var tmpSlackSigningSecret = '9f742231b10e8888abcd99yyyzzz85a5'
assert.equal(await slackValidateRequest(tmpSlackSigningSecret, getTestHttpRequest()), false)
})
})

describe('Wrong Timestamp', function () {
it('should return false if the timestamp is wrong', async function () {
testHttpRequest = getTestHttpRequest()
testHttpRequest.headers['x-slack-request-timestamp'] = '1531420619'
assert.equal(await slackValidateRequest(slackSigningSecret, testHttpRequest), false)
})
})

describe('Wrong body', function () {
it('should return false if the body is not the correct one', async function () {
testHttpRequest = getTestHttpRequest(undefined, { text: 'test' })
assert.equal(await slackValidateRequest(slackSigningSecret, testHttpRequest), false)
})
})

describe('Using an invalid slack request', function () {
it('should return false', async function () {
testHttpRequest = getTestHttpRequest()
delete testHttpRequest.headers['x-slack-request-timestamp']
delete testHttpRequest.headers['x-slack-signature']
assert.equal(await slackValidateRequest(slackSigningSecret, testHttpRequest), false)
})
})

describe('Using invalid slack app signing secret', function() {
it('should throw an error if it\'s undfined', async function() {
testHttpRequest = getTestHttpRequest()
assert.rejects(async () => { await slackValidateRequest(undefined, testHttpRequest) })
})
it('should throw an error if it\'s an empty string', async function() {
testHttpRequest = getTestHttpRequest()
assert.rejects(async () => { await slackValidateRequest('', testHttpRequest) })
})
it('should throw an error if it\'s a non-string', async function() {
testHttpRequest = getTestHttpRequest()
assert.rejects(async () => { await slackValidateRequest(12344, testHttpRequest) })
})
})

describe('Check validity of logging argument', function() {
it('should throw an error if logging is not a boolean', async function() {
testHttpRequest = getTestHttpRequest()
assert.rejects(async () => { await slackValidateRequest(slackSigningSecret, testHttpRequest, 1) })
})
})
})
}

module.exports = runTests
Loading