Skip to content

Commit f3516e3

Browse files
committed
Add secret support for 'GitHub Webhook'
1 parent 17178f2 commit f3516e3

File tree

7 files changed

+114
-11
lines changed

7 files changed

+114
-11
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ You can deploy this webhook in different ways
6262

6363
0. Get the source code
6464
```
65-
git clone https://github.com/streamdevs/webhook.git
65+
git clone https://github.com/streamdevs/webhook.**git**
6666
```
6767
1. Change into the source code directory
6868
```
@@ -102,6 +102,7 @@ We make use of the following environment variables:
102102
| 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_ |
103103
| 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_ |
104104
| NOTIFY_CHECK_RUNS_FOR | Comma-separated list of branches to notify Check Runs for. Leave empty to notify for any branch | No | _empty_ _array_ |
105+
| GITHUB_SECRET | Allows you to set a secret in order to verify that the request are from GitHub | No | _empty_ |
105106

106107
### GitHub Configuration
107108

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dev": "nodemon src/index.js"
1515
},
1616
"dependencies": {
17+
"@hapi/boom": "^9.1.0",
1718
"@hapi/hapi": "^19.1.1",
1819
"@hapi/joi": "^17.1.1",
1920
"axios": "^0.19.2",

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface Config {
44
TWITCH_BOT_NAME?: string;
55
TWITCH_BOT_TOKEN?: string;
66
TWITCH_BOT_CHANNEL?: string;
7+
GITHUB_SECRET?: string;
78
port: number | string;
89
NOTIFY_CHECK_RUNS_FOR: string[];
910
NOTIFY_ISSUES_ASSIGNED_TO: string[];
@@ -35,5 +36,6 @@ export const getConfig = (): Config => {
3536
NOTIFY_CHECK_RUNS_FOR: process.env['NOTIFY_CHECK_RUNS_FOR']?.split(',') || [],
3637
NOTIFY_ISSUES_ASSIGNED_TO: process.env['NOTIFY_ISSUES_ASSIGNED_TO']?.split(',') || [],
3738
IGNORE_PR_OPENED_BY: process.env['IGNORE_PR_OPENED_BY']?.split(',') || [],
39+
GITHUB_SECRET: process.env['GITHUB_SECRET'],
3840
};
3941
};

src/routes/github/index.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { gitHubWebhookPayload } from '../../schemas/gitHubWebhookPayload';
2-
import { gitHubWebhookHeaders } from '../../schemas/gitHubWebhookHeaders';
3-
import { StreamLabs } from '../../services/StreamLabs';
4-
import { TwitchChat } from '../../services/TwitchChat';
1+
import { Boom, forbidden } from '@hapi/boom';
2+
import crypto from 'crypto';
3+
import { Request, ResponseObject, ResponseToolkit, ServerRoute } from '@hapi/hapi';
54
import { Config } from '../../config';
6-
75
import { reactionBuild } from '../../reactions/github';
8-
import { Request, ResponseObject, ResponseToolkit, ServerRoute } from '@hapi/hapi';
96
import { RepositoryWebhookPayload } from '../../schemas/github/repository-webhook-payload';
7+
import { gitHubWebhookHeaders } from '../../schemas/gitHubWebhookHeaders';
8+
import { gitHubWebhookPayload } from '../../schemas/gitHubWebhookPayload';
9+
import { StreamLabs } from '../../services/StreamLabs';
10+
import { TwitchChat } from '../../services/TwitchChat';
1011

1112
export const routes = (config: Config): ServerRoute[] => [
1213
{
@@ -18,11 +19,30 @@ export const routes = (config: Config): ServerRoute[] => [
1819
payload: gitHubWebhookPayload(),
1920
},
2021
},
21-
handler: async (request: Request, h: ResponseToolkit): Promise<ResponseObject> => {
22+
handler: async (request: Request, h: ResponseToolkit): Promise<ResponseObject | Boom> => {
2223
const { payload, headers } = (request as unknown) as {
2324
payload: RepositoryWebhookPayload;
24-
headers: { 'x-github-event': string };
25+
headers: { 'x-github-event': string; 'x-hub-signature': string };
2526
};
27+
28+
if (config.GITHUB_SECRET) {
29+
if (!headers['x-hub-signature']) {
30+
console.error("missing 'x-hub-signature' header");
31+
return forbidden();
32+
}
33+
34+
const hmac = crypto.createHmac('sha1', config.GITHUB_SECRET);
35+
const digest = Buffer.from(
36+
'sha1=' + hmac.update(JSON.stringify(payload)).digest('hex'),
37+
'utf8',
38+
);
39+
const checksum = Buffer.from(headers['x-hub-signature'], 'utf8');
40+
if (checksum.length !== digest.length || !crypto.timingSafeEqual(digest, checksum)) {
41+
console.error('unable to verify request signature');
42+
return forbidden();
43+
}
44+
}
45+
2646
const event = headers['x-github-event'];
2747

2848
const streamlabs = new StreamLabs({ token: config.STREAMLABS_TOKEN || '' }, request);

src/schemas/gitHubWebhookHeaders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { object, Schema, string } from '@hapi/joi';
22

33
export function gitHubWebhookHeaders(): Schema {
4-
return object({ 'x-github-event': string().required() }).unknown();
4+
return object({ 'x-github-event': string().required(), 'x-hub-signature': string() }).unknown();
55
}

test/routes/github/index.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,83 @@ describe('POST /github', () => {
6060
expect(statusCode).toBe(200);
6161
expect(result).toEqual({ message: `Ignoring event: 'project'` });
6262
});
63+
64+
describe("with 'GITHUB_SECRET' configured", () => {
65+
it("rejects requests without 'X-Hub-Signature' header", async () => {
66+
const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' });
67+
const request = {
68+
method: 'POST',
69+
url: '/github',
70+
headers: {
71+
'Content-Type': 'application/json',
72+
'X-GitHub-Event': 'project',
73+
},
74+
payload: {
75+
hook: { events: ['created'] },
76+
sender: {
77+
login: 'user',
78+
},
79+
repository: {
80+
full_name: 'org/repo',
81+
},
82+
},
83+
};
84+
85+
const { statusCode } = await subject.inject(request);
86+
87+
expect(statusCode).toEqual(403);
88+
});
89+
90+
it("rejects requests with invalid 'X-Hub-Signature' header", async () => {
91+
const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' });
92+
const request = {
93+
method: 'POST',
94+
url: '/github',
95+
headers: {
96+
'Content-Type': 'application/json',
97+
'X-GitHub-Event': 'project',
98+
'X-Hub-Signature': 'patatas',
99+
},
100+
payload: {
101+
hook: { events: ['created'] },
102+
sender: {
103+
login: 'user',
104+
},
105+
repository: {
106+
full_name: 'org/repo',
107+
},
108+
},
109+
};
110+
111+
const { statusCode } = await subject.inject(request);
112+
113+
expect(statusCode).toEqual(403);
114+
});
115+
116+
it("accept requests with valid 'X-Hub-Signature' header", async () => {
117+
const subject = await initServer({ ...getConfig(), GITHUB_SECRET: 'patatas' });
118+
const request = {
119+
method: 'POST',
120+
url: '/github',
121+
headers: {
122+
'Content-Type': 'application/json',
123+
'X-GitHub-Event': 'project',
124+
'X-Hub-Signature': 'sha1=7027fb0d07cb42f7c273aa2258f54f6626ca3f3c',
125+
},
126+
payload: {
127+
hook: { events: ['created'] },
128+
sender: {
129+
login: 'user',
130+
},
131+
repository: {
132+
full_name: 'org/repo',
133+
},
134+
},
135+
};
136+
137+
const { statusCode } = await subject.inject(request);
138+
139+
expect(statusCode).toEqual(200);
140+
});
141+
});
63142
});

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@
245245
dependencies:
246246
"@hapi/hoek" "9.x.x"
247247

248-
"@hapi/[email protected]", "@hapi/boom@^9.0.0":
248+
"@hapi/[email protected]", "@hapi/boom@^9.0.0", "@hapi/boom@^9.1.0":
249249
version "9.1.0"
250250
resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.0.tgz#0d9517657a56ff1e0b42d0aca9da1b37706fec56"
251251
integrity sha512-4nZmpp4tXbm162LaZT45P7F7sgiem8dwAh2vHWT6XX24dozNjGMg6BvKCRvtCUcmcXqeMIUqWN8Rc5X8yKuROQ==

0 commit comments

Comments
 (0)