From 2b63309147eaa45cda88e692adb00ab5c3f0fa64 Mon Sep 17 00:00:00 2001 From: Santi Date: Sat, 26 Sep 2020 16:38:46 +0000 Subject: [PATCH] Add secret support for 'GitHub Webhook' --- README.md | 1 + package.json | 1 + src/config.ts | 2 + src/routes/github/index.ts | 36 ++++++++++--- src/schemas/gitHubWebhookHeaders.ts | 2 +- test/routes/github/index.spec.ts | 79 +++++++++++++++++++++++++++++ yarn.lock | 2 +- 7 files changed, 113 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7879053..e4e3697 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ We make use of the following environment variables: | NOTIFY_ISSUES_ASSIGNED_TO | A comma-separated list of GitHub user names. Only issues assigned to these users will be notified or leave it empty to receive all notifications. | No | _empty array_ | | IGNORE_PR_OPENED_BY | A comma-separated list of GitHub user names. Only PR not opened by these users will be notified or leave it empty to receive all notifications. | No | _empty array_ | | NOTIFY_CHECK_RUNS_FOR | Comma-separated list of branches to notify Check Runs for. Leave empty to notify for any branch | No | _empty_ _array_ | +| GITHUB_SECRET | Allows you to set a secret in order to verify that the request are from GitHub | No | _empty_ | ### GitHub Configuration diff --git a/package.json b/package.json index 92289ef..e12f4a9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev": "nodemon src/index.js" }, "dependencies": { + "@hapi/boom": "^9.1.0", "@hapi/hapi": "^19.1.1", "@hapi/joi": "^17.1.1", "axios": "^0.19.2", diff --git a/src/config.ts b/src/config.ts index 121698c..4e1f82a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ export interface Config { TWITCH_BOT_NAME?: string; TWITCH_BOT_TOKEN?: string; TWITCH_BOT_CHANNEL?: string; + GITHUB_SECRET?: string; port: number | string; NOTIFY_CHECK_RUNS_FOR: string[]; NOTIFY_ISSUES_ASSIGNED_TO: string[]; @@ -35,5 +36,6 @@ export const getConfig = (): Config => { NOTIFY_CHECK_RUNS_FOR: process.env['NOTIFY_CHECK_RUNS_FOR']?.split(',') || [], NOTIFY_ISSUES_ASSIGNED_TO: process.env['NOTIFY_ISSUES_ASSIGNED_TO']?.split(',') || [], IGNORE_PR_OPENED_BY: process.env['IGNORE_PR_OPENED_BY']?.split(',') || [], + GITHUB_SECRET: process.env['GITHUB_SECRET'], }; }; diff --git a/src/routes/github/index.ts b/src/routes/github/index.ts index 2c490e5..614651f 100644 --- a/src/routes/github/index.ts +++ b/src/routes/github/index.ts @@ -1,12 +1,13 @@ -import { gitHubWebhookPayload } from '../../schemas/gitHubWebhookPayload'; -import { gitHubWebhookHeaders } from '../../schemas/gitHubWebhookHeaders'; -import { StreamLabs } from '../../services/StreamLabs'; -import { TwitchChat } from '../../services/TwitchChat'; +import { Boom, forbidden } from '@hapi/boom'; +import crypto from 'crypto'; +import { Request, ResponseObject, ResponseToolkit, ServerRoute } from '@hapi/hapi'; import { Config } from '../../config'; - import { reactionBuild } from '../../reactions/github'; -import { Request, ResponseObject, ResponseToolkit, ServerRoute } from '@hapi/hapi'; import { RepositoryWebhookPayload } from '../../schemas/github/repository-webhook-payload'; +import { gitHubWebhookHeaders } from '../../schemas/gitHubWebhookHeaders'; +import { gitHubWebhookPayload } from '../../schemas/gitHubWebhookPayload'; +import { StreamLabs } from '../../services/StreamLabs'; +import { TwitchChat } from '../../services/TwitchChat'; export const routes = (config: Config): ServerRoute[] => [ { @@ -18,11 +19,30 @@ export const routes = (config: Config): ServerRoute[] => [ payload: gitHubWebhookPayload(), }, }, - handler: async (request: Request, h: ResponseToolkit): Promise => { + handler: async (request: Request, h: ResponseToolkit): Promise => { const { payload, headers } = (request as unknown) as { payload: RepositoryWebhookPayload; - headers: { 'x-github-event': string }; + headers: { 'x-github-event': string; 'x-hub-signature': string }; }; + + if (config.GITHUB_SECRET) { + if (!headers['x-hub-signature']) { + console.error("missing 'x-hub-signature' header"); + return forbidden(); + } + + const hmac = crypto.createHmac('sha1', config.GITHUB_SECRET); + const digest = Buffer.from( + 'sha1=' + hmac.update(JSON.stringify(payload)).digest('hex'), + 'utf8', + ); + const checksum = Buffer.from(headers['x-hub-signature'], 'utf8'); + if (checksum.length !== digest.length || !crypto.timingSafeEqual(digest, checksum)) { + console.error('unable to verify request signature'); + return forbidden(); + } + } + const event = headers['x-github-event']; const streamlabs = new StreamLabs({ token: config.STREAMLABS_TOKEN || '' }, request); diff --git a/src/schemas/gitHubWebhookHeaders.ts b/src/schemas/gitHubWebhookHeaders.ts index 228ad4a..4f36954 100644 --- a/src/schemas/gitHubWebhookHeaders.ts +++ b/src/schemas/gitHubWebhookHeaders.ts @@ -1,5 +1,5 @@ import { object, Schema, string } from '@hapi/joi'; export function gitHubWebhookHeaders(): Schema { - return object({ 'x-github-event': string().required() }).unknown(); + return object({ 'x-github-event': string().required(), 'x-hub-signature': string() }).unknown(); } diff --git a/test/routes/github/index.spec.ts b/test/routes/github/index.spec.ts index c424c6c..57c833e 100644 --- a/test/routes/github/index.spec.ts +++ b/test/routes/github/index.spec.ts @@ -60,4 +60,83 @@ describe('POST /github', () => { expect(statusCode).toBe(200); expect(result).toEqual({ message: `Ignoring event: 'project'` }); }); + + describe("with 'GITHUB_SECRET' configured", () => { + it("rejects requests without 'X-Hub-Signature' header", async () => { + const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' }); + const request = { + method: 'POST', + url: '/github', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Event': 'project', + }, + payload: { + hook: { events: ['created'] }, + sender: { + login: 'user', + }, + repository: { + full_name: 'org/repo', + }, + }, + }; + + const { statusCode } = await subject.inject(request); + + expect(statusCode).toEqual(403); + }); + + it("rejects requests with invalid 'X-Hub-Signature' header", async () => { + const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' }); + const request = { + method: 'POST', + url: '/github', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Event': 'project', + 'X-Hub-Signature': 'patatas', + }, + payload: { + hook: { events: ['created'] }, + sender: { + login: 'user', + }, + repository: { + full_name: 'org/repo', + }, + }, + }; + + const { statusCode } = await subject.inject(request); + + expect(statusCode).toEqual(403); + }); + + it("accept requests with valid 'X-Hub-Signature' header", async () => { + const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' }); + const request = { + method: 'POST', + url: '/github', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Event': 'project', + 'X-Hub-Signature': 'sha1=7027fb0d07cb42f7c273aa2258f54f6626ca3f3c', + }, + payload: { + hook: { events: ['created'] }, + sender: { + login: 'user', + }, + repository: { + full_name: 'org/repo', + }, + }, + }; + + const { statusCode } = await subject.inject(request); + + expect(statusCode).toEqual(200); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index ac96a02..7171e25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -245,7 +245,7 @@ dependencies: "@hapi/hoek" "9.x.x" -"@hapi/boom@9.x.x", "@hapi/boom@^9.0.0": +"@hapi/boom@9.x.x", "@hapi/boom@^9.0.0", "@hapi/boom@^9.1.0": version "9.1.0" resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.0.tgz#0d9517657a56ff1e0b42d0aca9da1b37706fec56" integrity sha512-4nZmpp4tXbm162LaZT45P7F7sgiem8dwAh2vHWT6XX24dozNjGMg6BvKCRvtCUcmcXqeMIUqWN8Rc5X8yKuROQ==