From 0f46ec304cd2b4c741fe8df5848114fd3988244a Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Mon, 24 Oct 2022 16:42:21 +0200 Subject: [PATCH] feat(events/github): add signature verification Add `createGithubSignatureValidator(config)` which can be used to create a validator used at an ingress for topic `github`. On top, there is a new `githubWebhookEventsModule` for the new backend plugin API which auto-registers the `HttpPostIngress` for topic `github` incl. the validator. Relates-to: PR #13931 Signed-off-by: Patrick Jungermann --- .changeset/nasty-melons-build.md | 12 +++ .../events-backend-module-github/README.md | 31 ++++++ .../api-report.md | 10 ++ .../events-backend-module-github/config.d.ts | 36 +++++++ .../events-backend-module-github/package.json | 6 +- .../createGithubSignatureValidator.test.ts | 102 ++++++++++++++++++ .../http/createGithubSignatureValidator.ts | 59 ++++++++++ .../events-backend-module-github/src/index.ts | 4 +- .../service/GithubWebhookEventsModule.test.ts | 90 ++++++++++++++++ .../src/service/GithubWebhookEventsModule.ts | 48 +++++++++ yarn.lock | 2 + 11 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 .changeset/nasty-melons-build.md create mode 100644 plugins/events-backend-module-github/config.d.ts create mode 100644 plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts create mode 100644 plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts create mode 100644 plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.test.ts create mode 100644 plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.ts diff --git a/.changeset/nasty-melons-build.md b/.changeset/nasty-melons-build.md new file mode 100644 index 0000000000..35fb520a5e --- /dev/null +++ b/.changeset/nasty-melons-build.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-events-backend-module-github': patch +--- + +Add `createGithubSignatureValidator(config)` which can be used +to create a validator used at an ingress for topic `github`. + +On top, there is a new `githubWebhookEventsModule` for the new backend plugin API +which auto-registers the `HttpPostIngress` for topic `github` incl. the validator. + +Please find more information at +https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-github/README.md. diff --git a/plugins/events-backend-module-github/README.md b/plugins/events-backend-module-github/README.md index b111b79dd0..fb0a0d1c35 100644 --- a/plugins/events-backend-module-github/README.md +++ b/plugins/events-backend-module-github/README.md @@ -41,3 +41,34 @@ Add the event router to the `EventsBackend`: + .addSubscribers(githubEventRouter); // [...] ``` + +### Signature Validator + +Add the signature validator for the topic `github`: + +```diff +// at packages/backend/src/plugins/events.ts ++ import { createGithubSignatureValidator } from '@backstage/plugin-events-backend-module-github'; +// [...] + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + ingresses: { ++ github: { ++ validator: createGithubSignatureValidator(env.config), ++ }, + }, + logger: env.logger, + }); +``` + +Additionally, you need to add the configuration: + +```yaml +events: + modules: + github: + webhookSecret: your-secret-token +``` + +Configuration at GitHub: +https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks diff --git a/plugins/events-backend-module-github/api-report.md b/plugins/events-backend-module-github/api-report.md index 25ce30523a..b2a4425ae6 100644 --- a/plugins/events-backend-module-github/api-report.md +++ b/plugins/events-backend-module-github/api-report.md @@ -4,9 +4,16 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; import { EventParams } from '@backstage/plugin-events-node'; +import { RequestValidator } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; +// @public +export function createGithubSignatureValidator( + config: Config, +): RequestValidator; + // @public export class GithubEventRouter extends SubTopicEventRouter { constructor(); @@ -18,4 +25,7 @@ export class GithubEventRouter extends SubTopicEventRouter { export const githubEventRouterEventsModule: ( options?: undefined, ) => BackendFeature; + +// @alpha +export const githubWebhookEventsModule: (options?: undefined) => BackendFeature; ``` diff --git a/plugins/events-backend-module-github/config.d.ts b/plugins/events-backend-module-github/config.d.ts new file mode 100644 index 0000000000..692d8d4fb5 --- /dev/null +++ b/plugins/events-backend-module-github/config.d.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + events?: { + modules?: { + /** + * events-backend-module-github plugin configuration. + */ + github?: { + /** + * Secret token for webhook requests used to verify signatures. + * + * See https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks + * for more details. + * + * @visibility secret + */ + webhookSecret?: string; + }; + }; + }; +} diff --git a/plugins/events-backend-module-github/package.json b/plugins/events-backend-module-github/package.json index 8816adcf79..737121f2e1 100644 --- a/plugins/events-backend-module-github/package.json +++ b/plugins/events-backend-module-github/package.json @@ -24,7 +24,9 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/plugin-events-node": "workspace:^", + "@octokit/webhooks-methods": "^3.0.0", "winston": "^3.2.1" }, "devDependencies": { @@ -35,6 +37,8 @@ }, "files": [ "alpha", + "config.d.ts", "dist" - ] + ], + "configSchema": "config.d.ts" } diff --git a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts new file mode 100644 index 0000000000..32d67437a8 --- /dev/null +++ b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { + RequestDetails, + RequestRejectionDetails, + RequestValidationContext, +} from '@backstage/plugin-events-node'; +import { sign } from '@octokit/webhooks-methods'; +import { createGithubSignatureValidator } from './createGithubSignatureValidator'; + +class TestContext implements RequestValidationContext { + #details?: Partial; + + reject(details?: Partial): void { + this.#details = details; + } + + get details() { + return this.#details; + } +} + +describe('createGithubSignatureValidator', () => { + const secret = 'valid-secret'; + const configWithoutSecret = new ConfigReader({}); + const configWithSecret = new ConfigReader({ + events: { + modules: { + github: { + webhookSecret: secret, + }, + }, + }, + }); + const payload = { test: 'payload' }; + const payloadString = JSON.stringify(payload); + const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString); + + const requestWithSignature = async (signature: string | undefined) => { + return { + body: payload, + headers: { + 'x-hub-signature-256': signature, + }, + } as RequestDetails; + }; + + it('no secret configured, throw error', async () => { + expect(() => createGithubSignatureValidator(configWithoutSecret)).toThrow( + "Missing required config value at 'events.modules.github.webhookSecret'", + ); + }); + + it('secret configured, reject request without signature', async () => { + const request = await requestWithSignature(undefined); + const context = new TestContext(); + + const validator = createGithubSignatureValidator(configWithSecret); + await validator(request, context); + + expect(context.details).not.toBeUndefined(); + expect(context.details?.status).toBe(403); + expect(context.details?.payload).toEqual({ message: 'invalid signature' }); + }); + + it('secret configured, reject request with invalid signature', async () => { + const request = await requestWithSignature('invalid signature'); + const context = new TestContext(); + + const validator = createGithubSignatureValidator(configWithSecret); + await validator(request, context); + + expect(context.details).not.toBeUndefined(); + expect(context.details?.status).toBe(403); + expect(context.details?.payload).toEqual({ message: 'invalid signature' }); + }); + + it('secret configured, accept request with valid signature', async () => { + const request = await requestWithSignature(await validSignature); + const context = new TestContext(); + + const validator = createGithubSignatureValidator(configWithSecret); + await validator(request, context); + + expect(context.details).toBeUndefined(); + }); +}); diff --git a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts new file mode 100644 index 0000000000..e977640ce8 --- /dev/null +++ b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { + RequestDetails, + RequestValidationContext, + RequestValidator, +} from '@backstage/plugin-events-node'; +import { verify } from '@octokit/webhooks-methods'; + +/** + * Validates that the request received is the expected GitHub request + * using the signature received with the `x-hub-signature-256` header + * which is based on a secret token configured at GitHub and here. + * + * See https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks + * for more details. + * + * @param config - root config + * @public + */ +export function createGithubSignatureValidator( + config: Config, +): RequestValidator { + const secret = config.getString('events.modules.github.webhookSecret'); + + return async ( + request: RequestDetails, + context: RequestValidationContext, + ): Promise => { + const signature = request.headers['x-hub-signature-256'] as + | string + | undefined; + + if ( + !signature || + !(await verify(secret, JSON.stringify(request.body), signature)) + ) { + context.reject({ + status: 403, + payload: { message: 'invalid signature' }, + }); + } + }; +} diff --git a/plugins/events-backend-module-github/src/index.ts b/plugins/events-backend-module-github/src/index.ts index 672d82e6dc..8e5671d9d8 100644 --- a/plugins/events-backend-module-github/src/index.ts +++ b/plugins/events-backend-module-github/src/index.ts @@ -16,10 +16,12 @@ /** * The module `github` for the Backstage backend plugin "events-backend" - * adding an event router for GitHub. + * adding an event router and signature validator for GitHub. * * @packageDocumentation */ +export { createGithubSignatureValidator } from './http/createGithubSignatureValidator'; export { GithubEventRouter } from './router/GithubEventRouter'; export { githubEventRouterEventsModule } from './service/GithubEventRouterEventsModule'; +export { githubWebhookEventsModule } from './service/GithubWebhookEventsModule'; diff --git a/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.test.ts b/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.test.ts new file mode 100644 index 0000000000..d02afb125d --- /dev/null +++ b/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configServiceRef } from '@backstage/backend-plugin-api'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { + eventsExtensionPoint, + HttpPostIngressOptions, + RequestDetails, +} from '@backstage/plugin-events-node'; +import { sign } from '@octokit/webhooks-methods'; +import { githubWebhookEventsModule } from './GithubWebhookEventsModule'; + +describe('githubWebhookEventsModule', () => { + const secret = 'valid-secret'; + const payload = { test: 'payload' }; + const payloadString = JSON.stringify(payload); + const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString); + const requestWithSignature = async (signature?: string) => { + return { + body: payload, + headers: { + 'x-hub-signature-256': signature, + }, + } as RequestDetails; + }; + + it('should be correctly wired and set up', async () => { + let addedIngress: HttpPostIngressOptions | undefined; + const extensionPoint = { + addHttpPostIngress: (ingress: any) => { + addedIngress = ingress; + }, + }; + + const config = new ConfigReader({ + events: { + modules: { + github: { + webhookSecret: secret, + }, + }, + }, + }); + + await startTestBackend({ + extensionPoints: [[eventsExtensionPoint, extensionPoint]], + services: [[configServiceRef, config]], + features: [githubWebhookEventsModule()], + }); + + expect(addedIngress).not.toBeUndefined(); + expect(addedIngress?.topic).toEqual('github'); + expect(addedIngress?.validator).not.toBeUndefined(); + const rejections: any[] = []; + const context = { + reject: (details: { status?: any; payload?: any }) => { + rejections.push(details); + }, + }; + await addedIngress!.validator!(await requestWithSignature(), context); + expect(rejections).toEqual([ + { + status: 403, + payload: { + message: 'invalid signature', + }, + }, + ]); + await addedIngress!.validator!( + await requestWithSignature(await validSignature), + context, + ); + expect(rejections.length).toEqual(1); + }); +}); diff --git a/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.ts b/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.ts new file mode 100644 index 0000000000..d9f4046a5e --- /dev/null +++ b/plugins/events-backend-module-github/src/service/GithubWebhookEventsModule.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + configServiceRef, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { eventsExtensionPoint } from '@backstage/plugin-events-node'; +import { createGithubSignatureValidator } from '../http/createGithubSignatureValidator'; + +/** + * Module for the events-backend plugin, + * registering an HTTP POST ingress with request validator + * which verifies the webhook signature based on a secret. + * + * @alpha + */ +export const githubWebhookEventsModule = createBackendModule({ + pluginId: 'events', + moduleId: 'githubWebhook', + register(env) { + env.registerInit({ + deps: { + config: configServiceRef, + events: eventsExtensionPoint, + }, + async init({ config, events }) { + events.addHttpPostIngress({ + topic: 'github', + validator: createGithubSignatureValidator(config), + }); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index c19abc78d8..e0d9dd4b7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5416,8 +5416,10 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" + "@octokit/webhooks-methods": ^3.0.0 supertest: ^6.1.3 winston: ^3.2.1 languageName: unknown