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