Skip to content

Commit 802d4d2

Browse files
committed
fix: Validate github signature
1 parent 77bd5fb commit 802d4d2

File tree

4 files changed

+69
-33
lines changed

4 files changed

+69
-33
lines changed

.env.dev-sample

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ YOUTUBE_API_KEY=
66
SPOTIFY_CLIENT_ID=
77
SPOTIFY_SECRET=
88

9-
DISCORD_RECEIVE_GITHUB_WEBHOOK_URL=
9+
GITHUB_WEBHOOK_SECRET=
10+
GITHUB_WEBHOOK_DISCORD_URL=

src/handle-github-webhook.spec.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,46 +14,52 @@ import handleGithubWebhook from './handle-github-webhook';
1414
import * as handleGithubWebhookModule from './handle-github-webhook';
1515
import * as Config from './config';
1616
import createHttpServer from './http-server';
17+
import * as crypto from 'crypto';
1718

18-
const MOCK_DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/12345/s3creTk3Y';
19+
const GITHUB_WEBHOOK_SECRET = 's3creTk3Y';
20+
const GITHUB_WEBHOOK_DISCORD_URL = 'https://discord.com/api/webhooks/12345/key';
1921

2022
describe('handleGithubWebhook - unit tests', () => {
2123
beforeEach(() => {
22-
Sinon.stub(Config, 'getConfig').returns(MOCK_DISCORD_WEBHOOK_URL);
24+
const getConfigStub = Sinon.stub(Config, 'getConfig');
25+
getConfigStub.withArgs('GITHUB_WEBHOOK_SECRET').returns(GITHUB_WEBHOOK_SECRET);
26+
getConfigStub.withArgs('GITHUB_WEBHOOK_DISCORD_URL').returns(GITHUB_WEBHOOK_DISCORD_URL);
2327
});
2428

2529
afterEach(() => {
2630
Sinon.restore();
2731
});
2832

29-
it('should return 401 if webhook secret is not provided', async () => {
30-
const result = await handleGithubWebhook('/githubWebhook/wrong-secret', {});
33+
it('should return 401 if webhook signature is invalid', async () => {
34+
const result = await handleGithubWebhook({}, Buffer.from(''), {});
3135

3236
expect(result).to.eql({ statusCode: 401 });
3337
});
3438

3539
it('should return 200 if webhook was filtered out', async () => {
36-
const result = await handleGithubWebhook('/githubWebhook/s3creTk3Y', {
37-
sender: {
38-
login: 'dependabot',
39-
},
40-
});
40+
const result = await handleGithubWebhook(
41+
...makeHandleGithubWebhookParams({
42+
sender: {
43+
login: 'dependabot',
44+
},
45+
})
46+
);
4147

4248
expect(result).to.eql({ statusCode: 200 });
4349
});
4450

4551
it('should return status code from discord if the webhook was forwarded', async () => {
4652
const payload = { sender: { login: 'kbkk' } };
4753

48-
nock('https://discord.com').post('/api/webhooks/12345/s3creTk3Y', payload).reply(599);
54+
nock('https://discord.com').post('/api/webhooks/12345/key', payload).reply(599);
4955

50-
const result = await handleGithubWebhook('/githubWebhook/s3creTk3Y', payload);
56+
const result = await handleGithubWebhook(...makeHandleGithubWebhookParams(payload));
5157

5258
expect(result).to.eql({ statusCode: 599 });
5359
});
5460
});
5561

56-
describe('handleGithubWebhook - integration tests', () => {
62+
describe.only('handleGithubWebhook - integration tests', () => {
5763
let httpServer: Http.Server;
5864
let baseUrl: string;
5965

@@ -79,7 +85,7 @@ describe('handleGithubWebhook - integration tests', () => {
7985
it('should respond with 400 on invalid request', async () => {
8086
const jsonParseSpy = Sinon.spy(JSON, 'parse');
8187

82-
const { status } = await fetch(`${baseUrl}/githubWebhook/test`, {
88+
const { status } = await fetch(`${baseUrl}/githubWebhook`, {
8389
method: 'POST',
8490
body: 'invalid json',
8591
});
@@ -88,18 +94,23 @@ describe('handleGithubWebhook - integration tests', () => {
8894
expect(status).to.eql(400);
8995
});
9096

91-
it('should call handleGithubWebhook with url and body', async () => {
97+
it('should call handleGithubWebhook with headers, rawBody and body', async () => {
9298
const handleGithubWebhookStub = Sinon.stub(handleGithubWebhookModule, 'default');
9399
handleGithubWebhookStub.resolves({ statusCode: 200 });
94100

95-
await fetch(`${baseUrl}/githubWebhook/test`, {
101+
await fetch(`${baseUrl}/githubWebhook`, {
96102
method: 'POST',
97103
body: '{"a":1}',
104+
headers: {
105+
'x-hub-signature': 'test-signature',
106+
},
98107
});
99108

100-
expect(handleGithubWebhookStub).to.have.been.calledOnceWithExactly('/githubWebhook/test', {
101-
a: 1,
102-
});
109+
expect(handleGithubWebhookStub).to.have.been.calledOnceWithExactly(
110+
Sinon.match({ 'x-hub-signature': 'test-signature' }),
111+
Buffer.from('{"a":1}'),
112+
{ a: 1 }
113+
);
103114
});
104115

105116
it('should respond with status code from handleGithubWebhook', async () => {
@@ -114,3 +125,23 @@ describe('handleGithubWebhook - integration tests', () => {
114125
expect(status).eql(599);
115126
});
116127
});
128+
129+
function forgeSignature(rawBody: Buffer): string {
130+
const hmacString = crypto.createHmac('sha1', GITHUB_WEBHOOK_SECRET).update(rawBody).digest('hex');
131+
const signature = `sha1=${hmacString}`;
132+
133+
return signature;
134+
}
135+
136+
function makeHandleGithubWebhookParams(body: object): Parameters<typeof handleGithubWebhook> {
137+
const rawBody = Buffer.from(JSON.stringify(body));
138+
const signature = forgeSignature(rawBody);
139+
140+
return [
141+
{
142+
'x-hub-signature': signature,
143+
},
144+
rawBody,
145+
body,
146+
];
147+
}

src/handle-github-webhook.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as crypto from 'crypto';
2+
import { IncomingHttpHeaders } from 'http';
13
import fetch from 'node-fetch';
24
import { getConfig } from './config';
35

@@ -14,18 +16,19 @@ interface GithubWebhookPullRequest {
1416
}
1517

1618
async function handleGithubWebhook(
17-
requestPath: string,
19+
headers: IncomingHttpHeaders,
20+
rawBody: Buffer,
1821
body: object
1922
): Promise<{ statusCode: number }> {
20-
if (!checkWebhookSecret(requestPath)) {
23+
if (!validateGithubSignature((headers['x-hub-signature'] ?? '') as string, rawBody)) {
2124
return { statusCode: 401 };
2225
}
2326

2427
if (!shouldSendWebhook(body)) {
2528
return { statusCode: 200 };
2629
}
2730

28-
const discordWebhookUrl = getConfig('DISCORD_RECEIVE_GITHUB_WEBHOOK_URL');
31+
const discordWebhookUrl = getConfig('GITHUB_WEBHOOK_DISCORD_URL');
2932

3033
const { status } = await fetch(discordWebhookUrl, {
3134
method: 'POST',
@@ -43,16 +46,15 @@ function shouldSendWebhook(body: object | GithubWebhookPullRequest) {
4346
return true;
4447
}
4548

46-
/**
47-
* Given the configured webhook url is https://discord.com/api/webhooks/12345/s3creTk3Y
48-
* then the bot should be called with https://tow-bot/githubWebhook/s3creTk3Y
49-
*/
50-
function checkWebhookSecret(requestPath: string): boolean {
51-
const discordWebhookUrl = getConfig('DISCORD_RECEIVE_GITHUB_WEBHOOK_URL');
49+
function validateGithubSignature(receivedSignature: string, rawBody: Buffer) {
50+
const githubSecret = getConfig('GITHUB_WEBHOOK_SECRET');
51+
const hmacString = crypto.createHmac('sha1', githubSecret).update(rawBody).digest('hex');
52+
const expectedSignature = `sha1=${hmacString}`;
5253

53-
const secret = discordWebhookUrl.split('/').pop() ?? '';
54-
55-
return requestPath.includes(secret);
54+
return (
55+
receivedSignature.length === expectedSignature.length &&
56+
crypto.timingSafeEqual(Buffer.from(receivedSignature), Buffer.from(expectedSignature))
57+
);
5658
}
5759

5860
export default handleGithubWebhook;

src/http-server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ function createHttpServer(
1919
}
2020

2121
try {
22-
const body = JSON.parse(Buffer.concat(chunks).toString());
22+
const rawBody = Buffer.concat(chunks);
23+
const body = JSON.parse(rawBody.toString());
24+
const headers = req.headers;
2325

24-
const { statusCode } = await handleGithubWebhook(req.url, body);
26+
const { statusCode } = await handleGithubWebhook(headers, rawBody, body);
2527

2628
res.statusCode = statusCode;
2729
res.end();

0 commit comments

Comments
 (0)