feat(events/gitlab): add webhook token verification

Add `createGitlabTokenValidator(config)` which can be used
to create a validator used at an ingress for topic `gitlab`.

On top, there is a new `gitlabWebhookEventsModule` for the new backend plugin API
which auto-registers the `HttpPostIngress` for topic `gitlab` 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:58 +02:00
parent b1828da592
commit 31fe8f256a
11 changed files with 371 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-events-backend-module-gitlab': patch
---
Add `createGitlabTokenValidator(config)` which can be used
to create a validator used at an ingress for topic `gitlab`.
On top, there is a new `gitlabWebhookEventsModule` for the new backend plugin API
which auto-registers the `HttpPostIngress` for topic `gitlab` incl. the validator.
Please find more information at
https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-gitlab/README.md.
@@ -40,3 +40,31 @@ Add the event router to the `EventsBackend`:
+ .addSubscribers(gitlabEventRouter);
// [...]
```
### Token Validator
Add the token validator for the topic `gitlab`:
```diff
// at packages/backend/src/plugins/events.ts
+ import { createGitlabTokenValidator } from '@backstage/plugin-events-backend-module-gitlab';
// [...]
const http = HttpPostIngressEventPublisher.fromConfig({
config: env.config,
ingresses: {
+ gitlab: {
+ validator: createGitlabTokenValidator(env.config),
+ },
},
logger: env.logger,
});
```
Additionally, you need to add the configuration:
```yaml
events:
modules:
gitlab:
webhookSecret: your-secret-token
```
@@ -4,9 +4,14 @@
```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 createGitlabTokenValidator(config: Config): RequestValidator;
// @public
export class GitlabEventRouter extends SubTopicEventRouter {
constructor();
@@ -18,4 +23,7 @@ export class GitlabEventRouter extends SubTopicEventRouter {
export const gitlabEventRouterEventsModule: (
options?: undefined,
) => BackendFeature;
// @alpha
export const gitlabWebhookEventsModule: (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-gitlab plugin configuration.
*/
gitlab?: {
/**
* Secret token for webhook requests used to verify tokens.
*
* See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#validate-payloads-by-using-a-secret-token
* for more details.
*
* @visibility secret
*/
webhookSecret?: string;
};
};
};
}
@@ -24,6 +24,7 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"winston": "^3.2.1"
},
@@ -35,6 +36,8 @@
},
"files": [
"alpha",
"config.d.ts",
"dist"
]
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,98 @@
/*
* 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 { createGitlabTokenValidator } from './createGitlabTokenValidator';
class TestContext implements RequestValidationContext {
#details?: Partial<RequestRejectionDetails>;
reject(details?: Partial<RequestRejectionDetails>): void {
this.#details = details;
}
get details() {
return this.#details;
}
}
describe('createGitlabTokenValidator', () => {
const validToken = 'valid-token';
const configWithoutSecret = new ConfigReader({});
const configWithSecret = new ConfigReader({
events: {
modules: {
gitlab: {
webhookSecret: validToken,
},
},
},
});
const requestWithToken = (token: string | undefined) => {
return {
body: undefined,
headers: {
'x-gitlab-token': token,
},
} as RequestDetails;
};
it('no secret configured, throw error', async () => {
expect(() => createGitlabTokenValidator(configWithoutSecret)).toThrow(
"Missing required config value at 'events.modules.gitlab.webhookSecret'",
);
});
it('secret configured, reject request without token', async () => {
const request = requestWithToken(undefined);
const context = new TestContext();
const validator = createGitlabTokenValidator(configWithSecret);
await validator(request, context);
expect(context.details).not.toBeUndefined();
expect(context.details?.status).toBe(403);
expect(context.details?.payload).toEqual({ message: 'invalid token' });
});
it('secret configured, reject request with invalid token', async () => {
const request = requestWithToken('invalid-token');
const context = new TestContext();
const validator = createGitlabTokenValidator(configWithSecret);
await validator(request, context);
expect(context.details).not.toBeUndefined();
expect(context.details?.status).toBe(403);
expect(context.details?.payload).toEqual({ message: 'invalid token' });
});
it('secret configured, accept request with valid token', async () => {
const request = requestWithToken(validToken);
const context = new TestContext();
const validator = createGitlabTokenValidator(configWithSecret);
await validator(request, context);
expect(context.details).toBeUndefined();
});
});
@@ -0,0 +1,49 @@
/*
* 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';
/**
* Validates a configured secret token against the token received with the `x-gitlab-token` header.
*
* See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#validate-payloads-by-using-a-secret-token
* for more details.
*
* @param config - root config
* @public
*/
export function createGitlabTokenValidator(config: Config): RequestValidator {
const secret = config.getString('events.modules.gitlab.webhookSecret');
return async (
request: RequestDetails,
context: RequestValidationContext,
): Promise<void> => {
const token = request.headers['x-gitlab-token'] as string | undefined;
if (secret !== token) {
context.reject({
status: 403,
payload: { message: 'invalid token' },
});
}
};
}
@@ -16,10 +16,12 @@
/**
* The module "gitlab" for the Backstage backend plugin "events-backend"
* adding an event router for GitLab.
* adding an event router and token validator for GitLab.
*
* @packageDocumentation
*/
export { createGitlabTokenValidator } from './http/createGitlabTokenValidator';
export { GitlabEventRouter } from './router/GitlabEventRouter';
export { gitlabEventRouterEventsModule } from './service/GitlabEventRouterEventsModule';
export { gitlabWebhookEventsModule } from './service/GitlabWebhookEventsModule';
@@ -0,0 +1,82 @@
/*
* 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 { gitlabWebhookEventsModule } from './GitlabWebhookEventsModule';
describe('gitlabWebhookEventsModule', () => {
const requestWithToken = (token?: string) => {
return {
body: undefined,
headers: {
'x-gitlab-token': token,
},
} 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: {
gitlab: {
webhookSecret: 'test-secret',
},
},
},
});
await startTestBackend({
extensionPoints: [[eventsExtensionPoint, extensionPoint]],
services: [[configServiceRef, config]],
features: [gitlabWebhookEventsModule()],
});
expect(addedIngress).not.toBeUndefined();
expect(addedIngress?.topic).toEqual('gitlab');
expect(addedIngress?.validator).not.toBeUndefined();
const rejections: any[] = [];
const context = {
reject: (details: { status?: any; payload?: any }) => {
rejections.push(details);
},
};
await addedIngress!.validator!(requestWithToken(), context);
expect(rejections).toEqual([
{
status: 403,
payload: {
message: 'invalid token',
},
},
]);
await addedIngress!.validator!(requestWithToken('test-secret'), context);
expect(rejections.length).toEqual(1);
});
});
@@ -0,0 +1,50 @@
/*
* 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 { createGitlabTokenValidator } from '../http/createGitlabTokenValidator';
/**
* Module for the events-backend plugin,
* registering an HTTP POST ingress with request validator
* which verifies the webhook token based on a secret.
*
* Registers the {@link GitlabEventRouter}.
*
* @alpha
*/
export const gitlabWebhookEventsModule = createBackendModule({
pluginId: 'events',
moduleId: 'gitlabWebhook',
register(env) {
env.registerInit({
deps: {
config: configServiceRef,
events: eventsExtensionPoint,
},
async init({ config, events }) {
events.addHttpPostIngress({
topic: 'gitlab',
validator: createGitlabTokenValidator(config),
});
},
});
},
});
+1
View File
@@ -5264,6 +5264,7 @@ __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:^"
supertest: ^6.1.3