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:
@@ -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;
|
||||
```
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user