-
Notifications
You must be signed in to change notification settings - Fork 144
Await the webhook signature verification so it can actually reject #8684
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import { createHmac } from 'crypto'; | ||
|
|
||
| import { handle } from './handler'; | ||
| import check_run_event from '../../test/resources/github_check_run_event.json'; | ||
|
|
||
|
|
@@ -10,11 +12,34 @@ jest.mock('../kms', () => ({ | |
| }), | ||
| })); | ||
|
|
||
| const TEST_SECRET = 'TEST_SECRET'; | ||
|
|
||
| const sign = (payload: string, algorithm: 'sha256' | 'sha1' = 'sha256'): string => | ||
| `${algorithm}=${createHmac(algorithm, TEST_SECRET).update(payload).digest('hex')}`; | ||
|
|
||
| const signedHeaders = (payload: string, event = 'push') => ({ | ||
| 'X-Hub-Signature-256': sign(payload), | ||
| 'X-GitHub-Event': event, | ||
| }); | ||
|
|
||
| // A workflow_job/queued event IS actionable, so `sendActionRequest` assertions below are | ||
| // only meaningful when the payload is this one — see the positive control test. | ||
| const queuedWorkflowJob = JSON.stringify({ | ||
| action: 'queued', | ||
| installation: { id: 42 }, | ||
| repository: { name: 'pytorch', owner: { login: 'pytorch' } }, | ||
| workflow_job: { | ||
| id: 1234, | ||
| labels: ['linux.2xlarge'], | ||
| html_url: 'https://github.com/pytorch/pytorch/actions/runs/1', | ||
| }, | ||
| }); | ||
|
|
||
| describe('handler', () => { | ||
| let originalError: Console['error']; | ||
|
|
||
| beforeEach(() => { | ||
| process.env.GITHUB_APP_WEBHOOK_SECRET = 'TEST_SECRET'; | ||
| process.env.GITHUB_APP_WEBHOOK_SECRET = TEST_SECRET; | ||
| originalError = console.error; | ||
| console.error = jest.fn(); | ||
| jest.clearAllMocks(); | ||
|
|
@@ -29,31 +54,92 @@ describe('handler', () => { | |
| expect(resp).toBe(500); | ||
| }); | ||
|
|
||
| it('does not handle other events', async () => { | ||
| // Positive control: proves the actionable path really does fire when the signature is valid, | ||
| // which is what makes every `not.toBeCalled()` assertion below non-vacuous. | ||
| it('enqueues a correctly signed queued workflow_job', async () => { | ||
| const resp = await handle(signedHeaders(queuedWorkflowJob, 'workflow_job'), queuedWorkflowJob); | ||
| expect(resp).toBe(200); | ||
| expect(sendActionRequest).toBeCalledTimes(1); | ||
| }); | ||
|
|
||
| it('returns 401 and does not enqueue when the signature does not match the payload', async () => { | ||
| const resp = await handle( | ||
| { 'X-Hub-Signature': 'sha1=4a82d2f60346e16dab3546eb3b56d8dde4d5b659', 'X-GitHub-Event': 'push' }, | ||
| JSON.stringify(check_run_event), | ||
| { 'X-Hub-Signature-256': `sha256=${'0'.repeat(64)}`, 'X-GitHub-Event': 'workflow_job' }, | ||
| queuedWorkflowJob, | ||
| ); | ||
| expect(resp).toBe(200); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('does not handle check_run events with actions other than created', async () => { | ||
| const event = { ...check_run_event, action: 'completed' }; | ||
| it('returns 401 and does not enqueue when the payload was tampered with after signing', async () => { | ||
| const headers = signedHeaders(queuedWorkflowJob, 'workflow_job'); | ||
| const tampered = JSON.stringify({ ...JSON.parse(queuedWorkflowJob), workflow_job: { id: 9999, labels: ['huge'] } }); | ||
| const resp = await handle(headers, tampered); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('returns 401 and does not enqueue when the signature was made with a different secret', async () => { | ||
| const foreign = `sha256=${createHmac('sha256', 'NOT_THE_SECRET').update(queuedWorkflowJob).digest('hex')}`; | ||
| const resp = await handle({ 'X-Hub-Signature-256': foreign, 'X-GitHub-Event': 'workflow_job' }, queuedWorkflowJob); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('returns 401 when the signature header is not a recognisable digest', async () => { | ||
| const resp = await handle( | ||
| { 'X-Hub-Signature': 'sha1=891749859807857017f7ee56a429e8fcead6f3e1', 'X-GitHub-Event': 'push' }, | ||
| JSON.stringify(event), | ||
| { 'X-Hub-Signature-256': 'not-a-signature', 'X-GitHub-Event': 'workflow_job' }, | ||
| queuedWorkflowJob, | ||
| ); | ||
| expect(resp).toBe(200); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('does not handle check_run events with status other than queued', async () => { | ||
| const event = { ...check_run_event, check_run: { id: 1234, status: 'completed' } }; | ||
| // The verifier throws (rather than returning false) on an empty payload; the handler must | ||
| // turn that into a 401 instead of an unhandled rejection. | ||
| it('returns 401 rather than throwing when the verifier rejects', async () => { | ||
| const resp = await handle({ 'X-Hub-Signature-256': sign(''), 'X-GitHub-Event': 'workflow_job' }, ''); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| // sha256 is preferred, so a bad sha256 must NOT be rescued by a valid legacy sha1 header. | ||
| it('rejects an invalid sha256 signature even when a valid sha1 header is present', async () => { | ||
| const resp = await handle( | ||
| { 'X-Hub-Signature': 'sha1=73dfae4aa56de5b038af8921b40d7a412ce7ca19', 'X-GitHub-Event': 'push' }, | ||
| JSON.stringify(event), | ||
| { | ||
| 'X-Hub-Signature-256': `sha256=${'0'.repeat(64)}`, | ||
| 'X-Hub-Signature': sign(queuedWorkflowJob, 'sha1'), | ||
| 'X-GitHub-Event': 'workflow_job', | ||
| }, | ||
| queuedWorkflowJob, | ||
| ); | ||
| expect(resp).toBe(401); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('accepts the legacy sha1 signature header when no sha256 header is sent', async () => { | ||
| const payload = JSON.stringify(check_run_event); | ||
| const resp = await handle({ 'X-Hub-Signature': sign(payload, 'sha1'), 'X-GitHub-Event': 'push' }, payload); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My agent is unsure about this one, worth flagging to have a look 🟠 The claimed sha1 back-compat does not exist: @octokit/webhooks 12.2.0 verifies only sha256, so the new sha1-only test asserts 200 where the handler returns 401 (ai-generated section)The description promises "valid sha1 alone, with no sha256 header → 200 (back-compat)" and this test encodes it. But Reviewed by claude-opus-5[1m] at max effort, against 4a50ae2. |
||
| expect(resp).toBe(200); | ||
| }); | ||
|
|
||
| it('does not handle other events', async () => { | ||
| const payload = JSON.stringify(check_run_event); | ||
| const resp = await handle(signedHeaders(payload), payload); | ||
| expect(resp).toBe(200); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('does not handle check_run events with actions other than created', async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit, should be an easy fix ⚪ Two re-added test names describe check_run action/status filtering that handler.ts does not implement and the tests do not exercise (ai-generated section)The test titles at handler.test.ts:133 and handler.test.ts:140 state as behaviour that the handler filters check_run events by action ('other than created') and by status ('other than queued'). handler.ts has no check_run handling at all: line 53 branches only on Reviewed by claude-opus-5[1m] at max effort, against 4a50ae2. |
||
| const payload = JSON.stringify({ ...check_run_event, action: 'completed' }); | ||
| const resp = await handle(signedHeaders(payload), payload); | ||
| expect(resp).toBe(200); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| it('does not handle check_run events with status other than queued', async () => { | ||
| const payload = JSON.stringify({ ...check_run_event, check_run: { id: 1234, status: 'completed' } }); | ||
| const resp = await handle(signedHeaders(payload), payload); | ||
| expect(resp).toBe(200); | ||
| expect(sendActionRequest).not.toBeCalled(); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,8 @@ export const handle = async (headers: IncomingHttpHeaders, payload: any): Promis | |
| headers[key.toLowerCase()] = headers[key]; | ||
| } | ||
|
|
||
| const signature = headers['x-hub-signature'] as string; | ||
| // Prefer the SHA-256 signature; GitHub still sends the legacy SHA-1 header alongside it. | ||
| const signature = (headers['x-hub-signature-256'] ?? headers['x-hub-signature']) as string; | ||
| if (!signature) { | ||
| console.error("Github event doesn't have signature. This webhook requires a secret to be configured."); | ||
| return 500; | ||
|
|
@@ -32,7 +33,15 @@ export const handle = async (headers: IncomingHttpHeaders, payload: any): Promis | |
| const webhooks = new Webhooks({ | ||
| secret: secret, | ||
| }); | ||
| if (!webhooks.verify(payload, signature)) { | ||
| // `verify` is async — without the await this is an always-truthy Promise and the check never rejects. | ||
| let verified: boolean; | ||
| try { | ||
| verified = await webhooks.verify(payload, signature); | ||
| } catch (e) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be better to fail loudly on cases like this, instead of making people seeking where the keys are diverging 🟡 The new catch around webhooks.verify maps a server-side misconfiguration (blank secret) to 401, contradicting the 500 returned four lines above (ai-generated section)The catch added at handler.ts:40 is unconditional: every throw out of Reviewed by claude-opus-5[1m] at max effort, against 4a50ae2. |
||
| console.error(`Unable to verify signature: ${e}`); | ||
| return 401; | ||
| } | ||
| if (!verified) { | ||
| console.error('Unable to verify signature!'); | ||
| return 401; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably not intentional?
🟡 The only test covering the new catch branch asserts a 401 that the non-throwing path also produces, so it cannot detect that it stopped covering the catch (ai-generated section)
This test reaches the
catchat handler.ts:40 only because@octokit/webhooks-methodshappens to throw on a falsyeventPayloadrather than returningfalse. Its assertions areexpect(resp).toBe(401)andexpect(sendActionRequest).not.toBeCalled()— identical to the assertions of the plain invalid-signature test at handler.test.ts:65-72, which exercises theif (!verified)branch at handler.ts:44. Nothing in the test distinguishes which of the two 401 paths ran. handler.ts has two 401 returns with different messages:Unable to verify signature: ${e}at line 41 (catch) andUnable to verify signature!at line 45 (falsy result). The test at handler.test.ts:100-104 asserts neither, only the status.console.erroris already replaced withjest.fn()inbeforeEachat handler.test.ts:44 and restored at line 49, so the distinguishing message is available to assert on at zero cost. The premise is a library internal, stated in the comment at handler.test.ts:98 and pinned only by@octokit/webhooks@^12.2.0in package.json:40. If an octokit bump changesverifyto returnfalsefor an empty payload instead of throwing, this test stays green while the catch at handler.ts:38-43 becomes uncovered, and the suite silently reports coverage it no longer has. The next person to touch the verification block gets no signal that the throw path is unprotected. Addexpect(console.error).toHaveBeenCalledWith(expect.stringContaining('Unable to verify signature:'));after line 102 — the colon distinguishes it from theUnable to verify signature!message on the other 401 path, so the test fails the moment it stops reaching the catch. A heavier alternative is tojest.mock('@octokit/webhooks')with averifythat rejects, which removes the dependence on library internals entirely; I would take the one-line assertion.Reviewed by claude-opus-5[1m] at max effort, against 4a50ae2.