Merge pull request #29721 from backstage/freben/all-these-secrets

leverage webhook secrets from github integrations too
This commit is contained in:
Fredrik Adelöw
2025-04-28 17:13:49 +02:00
committed by GitHub
10 changed files with 318 additions and 33 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-events-backend-module-github': minor
---
**BREAKING**: Removed the `createGithubSignatureValidator` export.
Added support webhook validation based on `integrations.github.[].apps.[].webhookSecret`.
@@ -45,8 +45,13 @@
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@octokit/webhooks-methods": "^3.0.0"
"@backstage/types": "workspace:^",
"@octokit/auth-callback": "^5.0.0",
"@octokit/webhooks-methods": "^3.0.0",
"lodash": "^4.17.21",
"octokit": "^3.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
@@ -4,17 +4,10 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { EventParams } from '@backstage/plugin-events-node';
import { EventsService } 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 | undefined;
// @public (undocumented)
const _default: BackendFeature;
export default _default;
@@ -22,6 +22,7 @@ import {
} from '@backstage/plugin-events-node';
import { sign } from '@octokit/webhooks-methods';
import { createGithubSignatureValidator } from './createGithubSignatureValidator';
import { OctokitProviderService } from '../util/octokitProviderService';
class TestContext implements RequestValidationContext {
#details?: Partial<RequestRejectionDetails>;
@@ -35,6 +36,10 @@ class TestContext implements RequestValidationContext {
}
}
const octokitProvider = {
getOctokit: jest.fn(),
} satisfies OctokitProviderService;
describe('createGithubSignatureValidator', () => {
const secret = 'valid-secret';
const configWithoutSecret = new ConfigReader({});
@@ -47,8 +52,31 @@ describe('createGithubSignatureValidator', () => {
},
},
});
const payloadString = '{"test": "payload", "score": 5.0}';
const payload = JSON.parse(payloadString);
const configWithAppSecret = new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
apps: [
{
appId: 7,
privateKey: 'a',
clientId: 'b',
clientSecret: 'c',
webhookSecret: secret,
},
],
},
],
},
});
const payload = {
test: 'payload',
score: 5.0,
repository: { html_url: 'https://github.com/backstage/backstage' },
installation: { id: 70 },
};
const payloadString = JSON.stringify(payload);
const payloadBuffer = Buffer.from(payloadString);
const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString);
@@ -66,16 +94,19 @@ describe('createGithubSignatureValidator', () => {
};
it('should return undefined if no secret is configured', async () => {
expect(createGithubSignatureValidator(configWithoutSecret)).toEqual(
undefined,
);
expect(
createGithubSignatureValidator(configWithoutSecret, octokitProvider),
).toEqual(undefined);
});
it('secret configured, reject request without signature', async () => {
const request = await requestWithSignature(undefined);
const context = new TestContext();
const validator = createGithubSignatureValidator(configWithSecret);
const validator = createGithubSignatureValidator(
configWithSecret,
octokitProvider,
);
await validator!(request, context);
expect(context.details).not.toBeUndefined();
@@ -87,7 +118,10 @@ describe('createGithubSignatureValidator', () => {
const request = await requestWithSignature('invalid signature');
const context = new TestContext();
const validator = createGithubSignatureValidator(configWithSecret);
const validator = createGithubSignatureValidator(
configWithSecret,
octokitProvider,
);
await validator!(request, context);
expect(context.details).not.toBeUndefined();
@@ -99,7 +133,32 @@ describe('createGithubSignatureValidator', () => {
const request = await requestWithSignature(await validSignature);
const context = new TestContext();
const validator = createGithubSignatureValidator(configWithSecret);
const validator = createGithubSignatureValidator(
configWithSecret,
octokitProvider,
);
await validator!(request, context);
expect(context.details).toBeUndefined();
});
it('secret configured, accept request with valid signature defined in integrations', async () => {
const request = await requestWithSignature(await validSignature);
const context = new TestContext();
octokitProvider.getOctokit.mockResolvedValue({
rest: {
apps: {
getInstallation: async () => ({
data: { app_id: 7 },
}),
},
},
});
const validator = createGithubSignatureValidator(
configWithAppSecret,
octokitProvider,
);
await validator!(request, context);
expect(context.details).toBeUndefined();
@@ -15,12 +15,15 @@
*/
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import {
RequestDetails,
RequestValidationContext,
RequestValidator,
} from '@backstage/plugin-events-node';
import { verify } from '@octokit/webhooks-methods';
import { createAppIdResolver } from '../util/createAppIdResolver';
import { OctokitProviderService } from '../util/octokitProviderService';
/**
* Validates that the request received is the expected GitHub request
@@ -35,14 +38,31 @@ import { verify } from '@octokit/webhooks-methods';
*/
export function createGithubSignatureValidator(
config: Config,
octokitProvider: OctokitProviderService,
): RequestValidator | undefined {
const secret = config.getOptionalString(
const integrations = ScmIntegrations.fromConfig(config);
// GitHub App installation ID to secret
const githubAppSecrets = new Map<number, string>();
for (const integration of integrations.github.list()) {
for (const { appId, webhookSecret } of integration.config.apps ?? []) {
if (appId && webhookSecret) {
githubAppSecrets.set(appId, webhookSecret);
}
}
}
// A single optional secret for all GitHub events
const genericSecret = config.getOptionalString(
'events.modules.github.webhookSecret',
);
if (!secret) {
if (!genericSecret && githubAppSecrets.size === 0) {
return undefined;
}
const appIdResolver = createAppIdResolver(octokitProvider);
return async (
request: RequestDetails,
context: RequestValidationContext,
@@ -51,18 +71,28 @@ export function createGithubSignatureValidator(
| string
| undefined;
if (
!signature ||
!(await verify(
secret,
request.raw.body.toString(request.raw.encoding),
signature,
))
) {
context.reject({
status: 403,
payload: { message: 'invalid signature' },
});
if (signature) {
const body = request.raw.body.toString(request.raw.encoding);
if (githubAppSecrets.size) {
const appId = await appIdResolver(request);
if (appId && githubAppSecrets.has(appId)) {
if (await verify(githubAppSecrets.get(appId)!, body, signature)) {
return;
}
}
}
if (genericSecret) {
if (await verify(genericSecret, body, signature)) {
return;
}
}
}
context.reject({
status: 403,
payload: { message: 'invalid signature' },
});
};
}
@@ -20,6 +20,7 @@
*
* @packageDocumentation
*/
import { createBackendFeatureLoader } from '@backstage/backend-plugin-api';
export default createBackendFeatureLoader({
@@ -31,5 +32,8 @@ export default createBackendFeatureLoader({
},
});
export { createGithubSignatureValidator } from './http/createGithubSignatureValidator';
// TODO(freben): This is not exported at the moment since it depends on the octokit provider.
// Until we have made that a core thing in integrations, we can't export it
// export { createGithubSignatureValidator } from './http/createGithubSignatureValidator';
export { GithubEventRouter } from './router/GithubEventRouter';
@@ -20,6 +20,7 @@ import {
} from '@backstage/backend-plugin-api';
import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha';
import { createGithubSignatureValidator } from '../http/createGithubSignatureValidator';
import { octokitProviderServiceRef } from '../util/octokitProviderService';
/**
* Module for the events-backend plugin,
@@ -36,9 +37,13 @@ export default createBackendModule({
deps: {
config: coreServices.rootConfig,
events: eventsExtensionPoint,
octokitProvider: octokitProviderServiceRef,
},
async init({ config, events }) {
const validator = createGithubSignatureValidator(config);
async init({ config, events, octokitProvider }) {
const validator = createGithubSignatureValidator(
config,
octokitProvider,
);
if (validator) {
events.addHttpPostIngress({
topic: 'github',
@@ -0,0 +1,66 @@
/*
* Copyright 2025 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 { RequestDetails } from '@backstage/plugin-events-node';
import { OctokitProviderService } from './octokitProviderService';
import lodash from 'lodash';
export type AppIdResolver = (
request: RequestDetails,
) => Promise<number | undefined>;
/**
* Helps with resolving what app ID (if any) that sent a webhook event.
*/
export function createAppIdResolver(
octokitProvider: OctokitProviderService,
): AppIdResolver {
const installationIdToAppId = new Map<number, Promise<number | undefined>>();
return async (request: RequestDetails) => {
const installationId = lodash.get(
request.body,
'installation.id',
) as unknown;
if (!installationId || typeof installationId !== 'number') {
return undefined;
}
let appIdPromsie = installationIdToAppId.get(installationId);
if (appIdPromsie) {
return await appIdPromsie;
}
const repositoryUrl = lodash.get(
request.body,
'repository.html_url',
) as unknown;
if (!repositoryUrl || typeof repositoryUrl !== 'string') {
return undefined;
}
const octokit = await octokitProvider.getOctokit(repositoryUrl);
appIdPromsie = octokit.rest.apps
.getInstallation({ installation_id: installationId })
.then(response => Number(response.data.app_id))
.catch(() => undefined);
installationIdToAppId.set(installationId, appIdPromsie);
return await appIdPromsie;
};
}
@@ -0,0 +1,104 @@
/*
* Copyright 2025 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 {
coreServices,
createServiceFactory,
createServiceRef,
RootConfigService,
} from '@backstage/backend-plugin-api';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import { durationToMilliseconds, HumanDuration } from '@backstage/types';
import { Octokit } from 'octokit';
export interface OctokitProviderService {
getOctokit: (url: string) => Promise<Octokit>;
}
class OctokitProviderImpl implements OctokitProviderService {
readonly #integrations: ScmIntegrationRegistry;
readonly #githubCredentials: GithubCredentialsProvider;
readonly #octokitCache: Map<string, Octokit>;
readonly #octokitCacheTtl: HumanDuration;
constructor(config: RootConfigService) {
this.#integrations = ScmIntegrations.fromConfig(config);
this.#githubCredentials = DefaultGithubCredentialsProvider.fromIntegrations(
this.#integrations,
);
this.#octokitCache = new Map();
this.#octokitCacheTtl = { hours: 1 };
}
async getOctokit(url: string): Promise<Octokit> {
// TODO(freben): Be smart and cache these more granularly, e.g. by
// organization or even repo.
const integration = this.#integrations.github.byUrl(url);
if (!integration) {
throw new Error(`No integration found for url: ${url}`);
}
const key = integration.config.host;
if (this.#octokitCache.has(key)) {
return this.#octokitCache.get(key)!;
}
const { createCallbackAuth } = await import('@octokit/auth-callback');
const octokit = new Octokit({
baseUrl: integration.config.apiBaseUrl,
authStrategy: createCallbackAuth,
auth: {
callback: async () => {
try {
const credentials = await this.#githubCredentials.getCredentials({
url,
});
return credentials.token;
} catch {
return undefined;
}
},
},
});
this.#octokitCache.set(key, octokit);
setTimeout(() => {
this.#octokitCache.delete(key);
}, durationToMilliseconds(this.#octokitCacheTtl));
return octokit;
}
}
export const octokitProviderServiceRef =
createServiceRef<OctokitProviderService>({
id: 'octokitProvider',
scope: 'root',
defaultFactory: async service =>
createServiceFactory({
service,
deps: { config: coreServices.rootConfig },
async factory({ config }) {
return new OctokitProviderImpl(config);
},
}),
});
+12
View File
@@ -6577,9 +6577,14 @@ __metadata:
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-events-backend-test-utils": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/types": "workspace:^"
"@octokit/auth-callback": "npm:^5.0.0"
"@octokit/webhooks-methods": "npm:^3.0.0"
lodash: "npm:^4.17.21"
octokit: "npm:^3.0.0"
languageName: unknown
linkType: soft
@@ -12850,6 +12855,13 @@ __metadata:
languageName: node
linkType: hard
"@octokit/auth-callback@npm:^5.0.0":
version: 5.0.1
resolution: "@octokit/auth-callback@npm:5.0.1"
checksum: 10/afa2fd71e1cc238c4fc09a1a8cc3b5c8d2f231aecdb8f8be2384857cde893aa4ac697f7c6aa0b61e4d5fa1f00d41fb349ceffb938977c02ee317d5432247f6ef
languageName: node
linkType: hard
"@octokit/auth-oauth-app@npm:^5.0.0":
version: 5.0.1
resolution: "@octokit/auth-oauth-app@npm:5.0.1"