├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directories 28 | node_modules 29 | jspm_packages 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | # Optional REPL history 35 | .node_repl_history 36 | 37 | .idea/ 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "7" 5 | - "6" 6 | - "5" 7 | - "4" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jiang Sheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-github-webhook 2 | [![Build Status](https://travis-ci.org/Gisonrg/express-github-webhook.svg?branch=master)](https://travis-ci.org/Gisonrg/express-github-webhook) 3 | 4 | A Express middleware for handle Github [Webhooks](https://developer.github.com/webhooks/) 5 | 6 | To Install: 7 | ----------- 8 | ``` 9 | npm install express-github-webhook 10 | ``` 11 | 12 | To Use: 13 | ------- 14 | 15 | **Make sure you use [body-parser](https://github.com/expressjs/body-parser) middleware for your Express app** 16 | 17 | ```javascript 18 | var GithubWebHook = require('express-github-webhook'); 19 | var webhookHandler = GithubWebHook({ path: '/webhook', secret: 'secret' }); 20 | 21 | // use in your express app 22 | let app = express(); 23 | app.use(bodyParser.json()); // must use bodyParser in express 24 | app.use(webhookHandler); // use our middleware 25 | 26 | // Now could handle following events 27 | webhookHandler.on('*', function (event, repo, data) { 28 | }); 29 | 30 | webhookHandler.on('event', function (repo, data) { 31 | }); 32 | 33 | webhookHandler.on('reponame', function (event, data) { 34 | }); 35 | 36 | webhookHandler.on('error', function (err, req, res) { 37 | }); 38 | ``` 39 | 40 | Where **'event'** is the event name to listen to (sent by [GitHub](https://developer.github.com/webhooks/#events), such as 'push'), **'reponame'** is the name of your repo. 41 | 42 | **'error'** event is a special event, which will be triggered when something goes wrong in the handler (like failed to verify the signature). 43 | 44 | Available options for creating handler are: 45 | 46 | * path: the path for the GitHub callback, only request that matches this path will be handled by the middleware. 47 | * secret (option): the secret used to verify the signature of the hook. If secret is set, then request without signature will fail the handler. If secret is not set, then the signature of the request (if any) will be ignored. [Read more](https://developer.github.com/webhooks/securing/) 48 | * deliveryHeader (option): header name for the delivery ID, defaults to `x-github-delivery` 49 | * eventHeader (option): header name for the event type, defaults to `x-github-event` 50 | * signatureHeader (option): header name for the event signature, defaults to `x-hub-signature` 51 | * signData (option): signature function used to compute and check event signatures, defaults to GitHub SHA1 HMAC signature. Will receive the secret and data to sign as arguments. 52 | 53 | 54 | TODO 55 | ----------- 56 | * Add support for content type of `application/x-www-form-urlencoded` 57 | * Provide more available options 58 | * Get rid of `body-parser` middleware 59 | 60 | License 61 | ======= 62 | 63 | [MIT](LICENSE) 64 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Module dependencies 5 | */ 6 | const EventEmitter = require('events').EventEmitter; 7 | const crypto = require('crypto'); 8 | const bufferEq = require('buffer-equal-constant-time'); 9 | 10 | /** 11 | * Helper functions 12 | */ 13 | function signData(secret, data) { 14 | return 'sha1=' + crypto.createHmac('sha1', secret).update(data).digest('hex'); 15 | } 16 | 17 | function verifySignature(secret, data, signature, signData) { 18 | return bufferEq(new Buffer(signature), new Buffer(signData(secret, data))); 19 | } 20 | 21 | const GithubWebhook = function(options) { 22 | if (typeof options !== 'object') { 23 | throw new TypeError('must provide an options object'); 24 | } 25 | 26 | if (typeof options.path !== 'string') { 27 | throw new TypeError('must provide a \'path\' option'); 28 | } 29 | 30 | options.secret = options.secret || ''; 31 | options.deliveryHeader = options.deliveryHeader || 'x-github-delivery'; 32 | options.eventHeader = options.eventHeader || 'x-github-event'; 33 | options.signatureHeader = options.signatureHeader || 'x-hub-signature'; 34 | options.signData = options.signData || signData; 35 | 36 | // Make handler able to emit events 37 | Object.assign(githookHandler, EventEmitter.prototype); 38 | EventEmitter.call(githookHandler); 39 | 40 | return githookHandler; 41 | 42 | function githookHandler(req, res, next) { 43 | if (req.method !== 'POST' || req.url.split('?').shift() !== options.path) { 44 | return next(); 45 | } 46 | 47 | function reportError(message) { 48 | // respond error to sender 49 | res.status(400).send({ 50 | error: message 51 | }); 52 | 53 | // emit error 54 | githookHandler.emit('error', new Error(message), req, res); 55 | } 56 | 57 | // check header fields 58 | let id = req.headers[options.deliveryHeader]; 59 | if (!id) { 60 | return reportError('No id found in the request'); 61 | } 62 | 63 | let event = req.headers[options.eventHeader]; 64 | if (!event) { 65 | return reportError('No event found in the request'); 66 | } 67 | 68 | let sign = req.headers[options.signatureHeader] || ''; 69 | if (options.secret && !sign) { 70 | return reportError('No signature found in the request'); 71 | } 72 | 73 | if (!req.body) { 74 | return reportError('Make sure body-parser is used'); 75 | } 76 | 77 | // verify signature (if any) 78 | if (options.secret && !verifySignature(options.secret, JSON.stringify(req.body), sign, options.signData)) { 79 | return reportError('Failed to verify signature'); 80 | } 81 | 82 | // parse payload 83 | let payloadData = req.body; 84 | const repo = payloadData.repository && payloadData.repository.name; 85 | 86 | // emit events 87 | githookHandler.emit('*', event, repo, payloadData); 88 | githookHandler.emit(event, repo, payloadData); 89 | if (repo) { 90 | githookHandler.emit(repo, event, payloadData); 91 | } 92 | 93 | res.status(200).send({ 94 | success: true 95 | }); 96 | } 97 | }; 98 | 99 | /** 100 | * Module exports 101 | */ 102 | module.exports = GithubWebhook; 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-github-webhook", 3 | "version": "1.0.6", 4 | "description": "A Express middleware for handle Github Webhooks", 5 | "main": "index.js", 6 | "repository": "Gisonrg/express-github-webhook", 7 | "scripts": { 8 | "test": "node test/index | tap-spec" 9 | }, 10 | "keywords": [ 11 | "github", 12 | "webhook", 13 | "express", 14 | "middleware" 15 | ], 16 | "author": "Jiang Sheng (http://gisonrg.me)", 17 | "license": "MIT", 18 | "dependencies": { 19 | "buffer-equal-constant-time": "^1.0.1" 20 | }, 21 | "devDependencies": { 22 | "body-parser": "^1.15.0", 23 | "express": "^4.13.4", 24 | "supertest": "^1.2.0", 25 | "tap-spec": "^4.1.1", 26 | "tape": "^4.5.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('supertest'); 4 | const crypto = require('crypto'); 5 | const test = require('tape'); 6 | const express = require('express'); 7 | const bodyParser = require('body-parser'); 8 | const GithubWebHook = require('../'); 9 | 10 | function signData(secret, data) { 11 | return 'sha1=' + crypto.createHmac('sha1', secret).update(data).digest('hex'); 12 | } 13 | 14 | function customSign(secret, data) { 15 | return 'custom=' + crypto.createHmac('sha256', secret).update(data).digest('hex'); 16 | } 17 | 18 | test('Invalid construction of GithubWebHook handler', function (t) { 19 | t.plan(4); 20 | t.equal(typeof GithubWebHook, 'function', 'GithubWebHook exports a function'); 21 | t.throws(GithubWebHook, /must provide an options object/, 'throws if no options'); 22 | t.throws(GithubWebHook.bind(null, ''), /must provide an options object/, 'throws if option is not an object'); 23 | t.throws(GithubWebHook.bind(null, {}), /must provide a 'path' option/, 'throws if no path option'); 24 | }); 25 | 26 | test('GithubWebHook handler is an EventEmitter', function (t) { 27 | t.plan(5); 28 | 29 | const options = {path: '/hook', secret: 'secret'}; 30 | let handler = GithubWebHook(options); 31 | t.equal(typeof handler.on, 'function', 'has h.on()'); 32 | t.equal(typeof handler.emit, 'function', 'has h.emit()'); 33 | t.equal(typeof handler.removeListener, 'function', 'has h.removeListener()'); 34 | 35 | handler.on('foo', function (bar) { 36 | t.equal(bar, 'bar', 'got event'); 37 | }); 38 | 39 | handler.emit('foo', 'bar'); 40 | 41 | t.throws(handler.emit.bind(handler, 'error', new Error('error')), /error/, 'works as a EventEmitter'); 42 | }); 43 | 44 | test('Ignore unmatched path', function (t) { 45 | t.plan(3); 46 | 47 | /** 48 | * Create mock express app 49 | */ 50 | let webhookHandler = GithubWebHook({path: '/github/hook'}); 51 | let app = express(); 52 | 53 | app.use(webhookHandler); // use our middleware 54 | app.use(function(req, res) { 55 | res.status(200).send({message: 'Here'}); 56 | }); 57 | 58 | request(app) 59 | .get('/') 60 | .expect('Content-Type', /json/) 61 | .expect(200) 62 | .end(function (err, res) { 63 | t.deepEqual(res.body, {message: 'Here'}, 'ignore pathunmatched request'); 64 | }); 65 | 66 | request(app) 67 | .get('/github/hook/') 68 | .expect('Content-Type', /json/) 69 | .expect(200) 70 | .end(function (err, res) { 71 | t.deepEqual(res.body, {message: 'Here'}, 'ignore path unmatched request'); 72 | }); 73 | 74 | request(app) 75 | .get('/github') 76 | .expect('Content-Type', /json/) 77 | .expect(200) 78 | .end(function (err, res) { 79 | t.deepEqual(res.body, {message: 'Here'}, 'ignore path unmatched request'); 80 | }); 81 | }); 82 | 83 | test('Ignore unmatched request method', function (t) { 84 | t.plan(1); 85 | 86 | /** 87 | * Create mock express app 88 | */ 89 | let webhookHandler = GithubWebHook({path: '/github/hook'}); 90 | let app = express(); 91 | 92 | app.use(webhookHandler); // use our middleware 93 | app.use(function(req, res) { 94 | res.status(200).send({message: 'Here'}); 95 | }); 96 | 97 | request(app) 98 | .get('/github/hook') 99 | .expect('Content-Type', /json/) 100 | .expect(200) 101 | .end(function (err, res) { 102 | t.deepEqual(res.body, {message: 'Here'}, 'ignore unmatched request method'); 103 | }); 104 | }); 105 | 106 | test('Invalid request meta', function (t) { 107 | t.plan(6); 108 | /** 109 | * Create mock express app 110 | */ 111 | let webhookHandler = GithubWebHook({path: '/github/hook', secret: 'secret'}); 112 | let app = express(); 113 | app.use(bodyParser.json()); 114 | app.use(webhookHandler); // use our middleware 115 | app.use(function(req, res) { 116 | res.status(200).send({message: 'Here'}); 117 | t.fail(true, 'should not reach here'); 118 | }); 119 | 120 | request(app) 121 | .post('/github/hook') 122 | .set('Content-Type', 'application/json') 123 | .expect('Content-Type', /json/) 124 | .expect(400) 125 | .end(function(err, res) { 126 | t.deepEqual(res.body, {error: 'No id found in the request'}, 'request should have id'); 127 | }); 128 | 129 | request(app) 130 | .post('/github/hook') 131 | .set('Content-Type', 'application/json') 132 | .set('X-GitHub-Delivery', 'id') 133 | .expect('Content-Type', /json/) 134 | .expect(400) 135 | .end(function(err, res) { 136 | t.deepEqual(res.body, {error: 'No event found in the request'}, 'request should have event'); 137 | }); 138 | 139 | request(app) 140 | .post('/github/hook') 141 | .set('Content-Type', 'application/json') 142 | .set('X-GitHub-Delivery', 'id') 143 | .set('X-GitHub-Event', 'event') 144 | .expect('Content-Type', /json/) 145 | .expect(400) 146 | .end(function(err, res) { 147 | t.deepEqual(res.body, {error: 'No signature found in the request'}, 'request should have signature'); 148 | }); 149 | 150 | webhookHandler.on('error', function(err, req, res) { 151 | t.ok(err, 'error caught'); 152 | }); 153 | }); 154 | 155 | test('Invalid signature', function (t) { 156 | t.plan(2); 157 | /** 158 | * Create mock express app 159 | */ 160 | let webhookHandler = GithubWebHook({path: '/github/hook', secret: 'secret'}); 161 | let app = express(); 162 | app.use(bodyParser.json()); 163 | app.use(webhookHandler); // use our middleware 164 | app.use(function(req, res) { 165 | res.status(200).send({message: 'Here'}); 166 | t.fail(true, 'should not reach here'); 167 | }); 168 | 169 | let invalidSignature = 'signature'; 170 | 171 | request(app) 172 | .post('/github/hook') 173 | .set('Content-Type', 'application/json') 174 | .set('X-GitHub-Delivery', 'id') 175 | .set('X-GitHub-Event', 'event') 176 | .set('X-Hub-Signature', invalidSignature) 177 | .expect('Content-Type', /json/) 178 | .expect(400) 179 | .end(function(err, res) { 180 | t.deepEqual(res.body, {error: 'Failed to verify signature'}, 'signature does not match'); 181 | }); 182 | 183 | webhookHandler.on('error', function(err, req, res) { 184 | t.ok(err, 'error caught'); 185 | }); 186 | }); 187 | 188 | test('No body-parser is used', function (t) { 189 | t.plan(2); 190 | /** 191 | * Create mock express app 192 | */ 193 | let webhookHandler = GithubWebHook({path: '/github/hook', secret: 'secret'}); 194 | let app = express(); 195 | app.use(webhookHandler); // use our middleware 196 | app.use(function(req, res) { 197 | res.status(200).send({message: 'Here'}); 198 | t.fail(true, 'should not reach here'); 199 | }); 200 | 201 | let invalidSignature = 'signature'; 202 | 203 | request(app) 204 | .post('/github/hook') 205 | .set('Content-Type', 'application/json') 206 | .set('X-GitHub-Delivery', 'id') 207 | .set('X-GitHub-Event', 'event') 208 | .set('X-Hub-Signature', invalidSignature) 209 | .expect('Content-Type', /json/) 210 | .expect(400) 211 | .end(function(err, res) { 212 | t.deepEqual(res.body, {error: 'Make sure body-parser is used'}, 'Verify use of body-parser'); 213 | }); 214 | 215 | webhookHandler.on('error', function(err, req, res) { 216 | t.ok(err, 'error caught'); 217 | }); 218 | }); 219 | 220 | test('Accept a valid request with json data', function (t) { 221 | t.plan(8); 222 | /** 223 | * Create mock express app 224 | */ 225 | let webhookHandler = GithubWebHook({path: '/github/hook', secret: 'secret'}); 226 | let app = express(); 227 | app.use(bodyParser.json()); 228 | app.use(webhookHandler); // use our middleware 229 | app.use(function(req, res) { 230 | res.status(200).send({message: 'Here'}); 231 | t.fail(true, 'should not reach here'); 232 | }); 233 | 234 | /** 235 | * Mock request data 236 | */ 237 | var data = { 238 | ref: 'ref', 239 | foo: 'bar', 240 | repository: { 241 | name: 'repo' 242 | } 243 | }; 244 | var json = JSON.stringify(data); 245 | 246 | request(app) 247 | .post('/github/hook') 248 | .send(json) 249 | .set('Content-Type', 'application/json') 250 | .set('X-GitHub-Delivery', 'id') 251 | .set('X-GitHub-Event', 'push') 252 | .set('X-Hub-Signature', signData('secret', json)) 253 | .expect('Content-Type', /json/) 254 | .expect(200) 255 | .end(function(err, res) { 256 | t.deepEqual(res.body, {success:true}, 'accept valid json request'); 257 | }); 258 | 259 | webhookHandler.on('repo', function(event, data) { 260 | t.equal(event, 'push', 'receive a push event on event \'repo\''); 261 | t.deepEqual(data, data, 'receive correct data on event \'repo\''); 262 | }); 263 | 264 | webhookHandler.on('push', function(repo, data) { 265 | t.equal(repo, 'repo', 'receive a event for repo on event \'push\''); 266 | t.deepEqual(data, data, 'receive correct data on event \'push\''); 267 | }); 268 | 269 | webhookHandler.on('*', function(event, repo, data) { 270 | t.equal(event, 'push', 'receive a push event on event \'*\''); 271 | t.equal(repo, 'repo', 'receive a event for repo on event \'*\''); 272 | t.deepEqual(data, data, 'receive correct data on event \'*\''); 273 | }); 274 | }); 275 | 276 | test('Custom headers', function(t) { 277 | t.plan(4); 278 | /** 279 | * Create mock express app 280 | */ 281 | let webhookHandler = GithubWebHook({ 282 | path: '/github/hook', 283 | secret: 'secret', 284 | deliveryHeader: 'x-my-delivery', 285 | eventHeader: 'x-my-event', 286 | signatureHeader: 'x-my-signature' 287 | }); 288 | let app = express(); 289 | app.use(bodyParser.json()); 290 | app.use(webhookHandler); // use our middleware 291 | app.use(function(req, res) { 292 | res.status(200).send({message: 'Here'}); 293 | t.fail(true, 'should not reach here'); 294 | }); 295 | 296 | /** 297 | * Mock request data 298 | */ 299 | var data = { 300 | ref: 'ref', 301 | foo: 'bar', 302 | repository: { 303 | name: 'repo' 304 | } 305 | }; 306 | var json = JSON.stringify(data); 307 | 308 | request(app) 309 | .post('/github/hook') 310 | .send(json) 311 | .set('Content-Type', 'application/json') 312 | .set('X-My-Delivery', 'id') 313 | .set('X-My-Event', 'push') 314 | .set('X-My-Signature', signData('secret', json)) 315 | .expect('Content-Type', /json/) 316 | .expect(200) 317 | .end(function(err, res) { 318 | t.deepEqual(res.body, {success:true}, 'accept valid json request'); 319 | }); 320 | 321 | webhookHandler.on('*', function(event, repo, data) { 322 | t.equal(event, 'push', 'receive a push event on event \'*\''); 323 | t.equal(repo, 'repo', 'receive a event for repo on event \'*\''); 324 | t.deepEqual(data, data, 'receive correct data on event \'*\''); 325 | }); 326 | }) 327 | 328 | test('Custom signature function', function(t) { 329 | t.plan(4); 330 | /** 331 | * Create mock express app 332 | */ 333 | let webhookHandler = GithubWebHook({ 334 | path: '/github/hook', 335 | secret: 'secret', 336 | signData: customSign 337 | }); 338 | let app = express(); 339 | app.use(bodyParser.json()); 340 | app.use(webhookHandler); // use our middleware 341 | app.use(function(req, res) { 342 | res.status(200).send({message: 'Here'}); 343 | t.fail(true, 'should not reach here'); 344 | }); 345 | 346 | /** 347 | * Mock request data 348 | */ 349 | var data = { 350 | ref: 'ref', 351 | foo: 'bar', 352 | repository: { 353 | name: 'repo' 354 | } 355 | }; 356 | var json = JSON.stringify(data); 357 | 358 | request(app) 359 | .post('/github/hook') 360 | .send(json) 361 | .set('Content-Type', 'application/json') 362 | .set('X-GitHub-Delivery', 'id') 363 | .set('X-GitHub-Event', 'push') 364 | .set('X-Hub-Signature', customSign('secret', json)) 365 | .expect('Content-Type', /json/) 366 | .expect(200) 367 | .end(function(err, res) { 368 | t.deepEqual(res.body, {success:true}, 'accept valid json request'); 369 | }); 370 | 371 | webhookHandler.on('*', function(event, repo, data) { 372 | t.equal(event, 'push', 'receive a push event on event \'*\''); 373 | t.equal(repo, 'repo', 'receive a event for repo on event \'*\''); 374 | t.deepEqual(data, data, 'receive correct data on event \'*\''); 375 | }); 376 | }) 377 | 378 | test('Invalid signature with custom signature function', function (t) { 379 | t.plan(2); 380 | /** 381 | * Create mock express app 382 | */ 383 | let webhookHandler = GithubWebHook({ 384 | path: '/github/hook', 385 | secret: 'secret', 386 | signData: customSign 387 | }); 388 | let app = express(); 389 | app.use(bodyParser.json()); 390 | app.use(webhookHandler); // use our middleware 391 | app.use(function(req, res) { 392 | res.status(200).send({message: 'Here'}); 393 | t.fail(true, 'should not reach here'); 394 | }); 395 | 396 | let invalidSignature = 'signature'; 397 | 398 | request(app) 399 | .post('/github/hook') 400 | .set('Content-Type', 'application/json') 401 | .set('X-GitHub-Delivery', 'id') 402 | .set('X-GitHub-Event', 'event') 403 | .set('X-Hub-Signature', invalidSignature) 404 | .expect('Content-Type', /json/) 405 | .expect(400) 406 | .end(function(err, res) { 407 | t.deepEqual(res.body, {error: 'Failed to verify signature'}, 'signature does not match'); 408 | }); 409 | 410 | webhookHandler.on('error', function(err, req, res) { 411 | t.ok(err, 'error caught'); 412 | }); 413 | }); 414 | --------------------------------------------------------------------------------