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 <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2022-10-24 16:42:21 +02:00
parent 6b33267ada
commit 0f46ec304c
11 changed files with 398 additions and 2 deletions
+12
View File
@@ -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.
@@ -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
@@ -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;
```
+36
View File
@@ -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;
};
};
};
}
@@ -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"
}
@@ -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<RequestRejectionDetails>;
reject(details?: Partial<RequestRejectionDetails>): 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();
});
});
@@ -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<void> => {
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' },
});
}
};
}
@@ -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';
@@ -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);
});
});
@@ -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),
});
},
});
},
});
+2
View File
@@ -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