fix(events,github): fixes signature validation by using raw req body
Adds raw body information (body as buffer, encoding) to `RequestDetails` to support more request validation use cases. Additionally, uses the raw body to retrieve the transmitted JSON string unparsed/raw to correctly validate the signature. Previously, we re-stringified the parsed JSON payload which could lead to different JSON strings. Those differences can lead to the rejection of requests due to a mismatch in expected signature. Fixes: #26709 Relates-to: PR #26884 Co-authored-by: Christopher Diaz <cdiaz@rvohealth.com> Signed-off-by: Patrick Jungermann <Patrick.Jungermann@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend-module-github': patch
|
||||
---
|
||||
|
||||
Fix the event request validation for incoming requests for GitHub webhook events
|
||||
by using the raw body when verifying the signature.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-events-node': patch
|
||||
'@backstage/plugin-events-backend-module-github': patch
|
||||
'@backstage/plugin-events-backend': patch
|
||||
---
|
||||
|
||||
Add raw body information to `RequestDetails`
|
||||
and use the raw body when validating incoming event requests.
|
||||
+7
-2
@@ -47,8 +47,9 @@ describe('createGithubSignatureValidator', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const payload = { test: 'payload' };
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const payloadString = '{"test": "payload", "score": 5.0}';
|
||||
const payload = JSON.parse(payloadString);
|
||||
const payloadBuffer = Buffer.from(payloadString);
|
||||
const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString);
|
||||
|
||||
const requestWithSignature = async (signature: string | undefined) => {
|
||||
@@ -57,6 +58,10 @@ describe('createGithubSignatureValidator', () => {
|
||||
headers: {
|
||||
'x-hub-signature-256': signature,
|
||||
},
|
||||
raw: {
|
||||
body: payloadBuffer,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
} as RequestDetails;
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ export function createGithubSignatureValidator(
|
||||
|
||||
if (
|
||||
!signature ||
|
||||
!(await verify(secret, JSON.stringify(request.body), signature))
|
||||
!(await verify(
|
||||
secret,
|
||||
request.raw.body.toString(request.raw.encoding),
|
||||
signature,
|
||||
))
|
||||
) {
|
||||
context.reject({
|
||||
status: 403,
|
||||
|
||||
@@ -25,8 +25,9 @@ import { eventsModuleGithubWebhook } from './eventsModuleGithubWebhook';
|
||||
|
||||
describe('eventsModuleGithubWebhook', () => {
|
||||
const secret = 'valid-secret';
|
||||
const payload = { test: 'payload' };
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const payloadString = '{"test": "payload", "score": 5.0}';
|
||||
const payload = JSON.parse(payloadString);
|
||||
const payloadBuffer = Buffer.from(payloadString);
|
||||
const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString);
|
||||
const requestWithSignature = async (signature?: string) => {
|
||||
return {
|
||||
@@ -34,6 +35,10 @@ describe('eventsModuleGithubWebhook', () => {
|
||||
headers: {
|
||||
'x-hub-signature-256': signature,
|
||||
},
|
||||
raw: {
|
||||
body: payloadBuffer,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
} as RequestDetails;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,11 +49,10 @@ describe('createGitlabTokenValidator', () => {
|
||||
|
||||
const requestWithToken = (token: string | undefined) => {
|
||||
return {
|
||||
body: undefined,
|
||||
headers: {
|
||||
'x-gitlab-token': token,
|
||||
},
|
||||
} as RequestDetails;
|
||||
} as Partial<RequestDetails> as unknown as RequestDetails;
|
||||
};
|
||||
|
||||
it('no secret configured, throw error', async () => {
|
||||
|
||||
@@ -25,11 +25,10 @@ import { eventsModuleGitlabWebhook } from './eventsModuleGitlabWebhook';
|
||||
describe('gitlabWebhookEventsModule', () => {
|
||||
const requestWithToken = (token?: string) => {
|
||||
return {
|
||||
body: undefined,
|
||||
headers: {
|
||||
'x-gitlab-token': token,
|
||||
},
|
||||
} as RequestDetails;
|
||||
} as Partial<RequestDetails> as unknown as RequestDetails;
|
||||
};
|
||||
|
||||
it('should be correctly wired and set up', async () => {
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@backstage/plugin-events-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@types/express": "^4.17.6",
|
||||
"content-type": "^1.0.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"knex": "^3.0.0",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/plugin-events-backend-test-utils": "workspace:^",
|
||||
"@backstage/repo-tools": "workspace:^",
|
||||
"@types/content-type": "^1.1.8",
|
||||
"supertest": "^7.0.0"
|
||||
},
|
||||
"configSchema": "config.d.ts"
|
||||
|
||||
@@ -83,14 +83,16 @@ describe('eventsPlugin', () => {
|
||||
|
||||
const response1 = await request(server)
|
||||
.post('/api/events/http/fake')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'fake' });
|
||||
.send(JSON.stringify({ test: 'fake' }));
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(server)
|
||||
.post('/api/events/http/fake-ext')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'fake-ext' });
|
||||
.send(JSON.stringify({ test: 'fake-ext' }));
|
||||
expect(response2.status).toBe(202);
|
||||
|
||||
expect(eventsService.published).toHaveLength(2);
|
||||
|
||||
@@ -76,21 +76,21 @@ export const eventsPlugin = createBackendPlugin({
|
||||
config: coreServices.rootConfig,
|
||||
events: eventsServiceRef,
|
||||
database: coreServices.database,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
httpRouter: coreServices.httpRouter,
|
||||
lifecycle: coreServices.lifecycle,
|
||||
logger: coreServices.logger,
|
||||
scheduler: coreServices.scheduler,
|
||||
lifecycle: coreServices.lifecycle,
|
||||
httpAuth: coreServices.httpAuth,
|
||||
router: coreServices.httpRouter,
|
||||
},
|
||||
async init({
|
||||
config,
|
||||
events,
|
||||
database,
|
||||
httpAuth,
|
||||
httpRouter,
|
||||
lifecycle,
|
||||
logger,
|
||||
scheduler,
|
||||
lifecycle,
|
||||
httpAuth,
|
||||
router,
|
||||
}) {
|
||||
const ingresses = Object.fromEntries(
|
||||
extensionPoint.httpPostIngresses.map(ingress => [
|
||||
@@ -108,18 +108,22 @@ export const eventsPlugin = createBackendPlugin({
|
||||
const eventsRouter = Router();
|
||||
http.bind(eventsRouter);
|
||||
|
||||
router.use(
|
||||
// MUST be registered *before* the event bus router.
|
||||
// Otherwise, it would already make use of `express.json()`
|
||||
// that is used there as part of the middleware stack.
|
||||
httpRouter.use(eventsRouter);
|
||||
|
||||
httpRouter.use(
|
||||
await createEventBusRouter({
|
||||
database,
|
||||
lifecycle,
|
||||
logger,
|
||||
httpAuth,
|
||||
scheduler,
|
||||
lifecycle,
|
||||
}),
|
||||
);
|
||||
|
||||
router.use(eventsRouter);
|
||||
router.addAuthPolicy({
|
||||
httpRouter.addAuthPolicy({
|
||||
allow: 'unauthenticated',
|
||||
path: '/http',
|
||||
});
|
||||
|
||||
@@ -50,22 +50,25 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
|
||||
const notFoundResponse = await request(app)
|
||||
.post('/http/unknown')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(notFoundResponse.status).toBe(404);
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/http/testA')
|
||||
.type('application/json')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(1000)
|
||||
.send({ testA: 'data' });
|
||||
.send(JSON.stringify({ testA: 'data' }));
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/http/testB')
|
||||
.type('application/json')
|
||||
.set('X-Custom-Header', 'test-value')
|
||||
.timeout(1000)
|
||||
.send({ testB: 'data' });
|
||||
.send(JSON.stringify({ testB: 'data' }));
|
||||
expect(response2.status).toBe(202);
|
||||
|
||||
expect(events.published).toHaveLength(2);
|
||||
@@ -87,6 +90,124 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('no raw body', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['testA'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json()); // will prevent the raw body from being available
|
||||
const app = express().use(router);
|
||||
const events = new TestEventsService();
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
events,
|
||||
logger,
|
||||
});
|
||||
publisher.bind(router);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/http/testA')
|
||||
.type('application/json; charset=utf-8')
|
||||
.timeout(1000)
|
||||
.send(JSON.stringify({ testA: 'data' }));
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: {
|
||||
message:
|
||||
'Failed to retrieve raw body from incoming event for topic testA; not a buffer: object',
|
||||
name: 'Error',
|
||||
},
|
||||
request: { method: 'POST', url: '/testA' },
|
||||
response: { statusCode: 500 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid charset', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['testA'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
const app = express().use(router);
|
||||
const events = new TestEventsService();
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
events,
|
||||
logger,
|
||||
});
|
||||
publisher.bind(router);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/http/testA')
|
||||
.type('application/json; charset=invalid')
|
||||
.timeout(1000)
|
||||
.send(JSON.stringify({ testA: 'data' }));
|
||||
expect(response.status).toBe(415);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: {
|
||||
message: 'Unsupported charset: invalid',
|
||||
name: 'UnsupportedCharsetError',
|
||||
statusCode: 415,
|
||||
},
|
||||
request: { method: 'POST', url: '/testA' },
|
||||
response: { statusCode: 415 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('non-JSON media type', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
http: {
|
||||
topics: ['testA'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
const app = express().use(router);
|
||||
const events = new TestEventsService();
|
||||
|
||||
const publisher = HttpPostIngressEventPublisher.fromConfig({
|
||||
config,
|
||||
events,
|
||||
logger,
|
||||
});
|
||||
publisher.bind(router);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/http/testA')
|
||||
.type('text/plain')
|
||||
.timeout(1000)
|
||||
.send('Textual information');
|
||||
expect(response.status).toBe(415);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: {
|
||||
message: 'Unsupported media type: text/plain',
|
||||
name: 'UnsupportedMediaTypeError',
|
||||
statusCode: 415,
|
||||
},
|
||||
request: { method: 'POST', url: '/testA' },
|
||||
response: { statusCode: 415 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with validator', async () => {
|
||||
const config = new ConfigReader({
|
||||
events: {
|
||||
@@ -149,43 +270,49 @@ describe('HttpPostIngressEventPublisher', () => {
|
||||
|
||||
const response1 = await request(app)
|
||||
.post('/http/testA')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response1.status).toBe(202);
|
||||
|
||||
const response2 = await request(app)
|
||||
.post('/http/testB')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response3 = await request(app)
|
||||
.post('/http/testB')
|
||||
.type('application/json')
|
||||
.set('X-Test-Signature', 'wrong')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response3.status).toBe(400);
|
||||
expect(response3.body).toEqual({ message: 'wrong signature' });
|
||||
|
||||
const response4 = await request(app)
|
||||
.post('/http/testB')
|
||||
.type('application/json')
|
||||
.set('X-Test-Signature', 'testB-signature')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response4.status).toBe(202);
|
||||
|
||||
const response5 = await request(app)
|
||||
.post('/http/testC')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response5.status).toBe(404);
|
||||
expect(response5.body).toEqual({});
|
||||
|
||||
const response6 = await request(app)
|
||||
.post('/http/testD')
|
||||
.type('application/json')
|
||||
.timeout(1000)
|
||||
.send({ test: 'data' });
|
||||
.send(JSON.stringify({ test: 'data' }));
|
||||
expect(response6.status).toBe(403);
|
||||
expect(response6.body).toEqual({});
|
||||
|
||||
|
||||
@@ -17,15 +17,35 @@
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CustomErrorBase } from '@backstage/errors';
|
||||
import {
|
||||
EventsService,
|
||||
HttpPostIngressOptions,
|
||||
RequestValidator,
|
||||
} from '@backstage/plugin-events-node';
|
||||
import contentType from 'content-type';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { RequestValidationContextImpl } from './validation';
|
||||
|
||||
class UnsupportedCharsetError extends CustomErrorBase {
|
||||
name = 'UnsupportedCharsetError' as const;
|
||||
statusCode = 415 as const;
|
||||
|
||||
constructor(charset: string) {
|
||||
super(`Unsupported charset: ${charset}`);
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedMediaTypeError extends CustomErrorBase {
|
||||
name = 'UnsupportedMediaTypeError' as const;
|
||||
statusCode = 415 as const;
|
||||
|
||||
constructor(mediaType?: string) {
|
||||
super(`Unsupported media type: ${mediaType ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes events received from their origin (e.g., webhook events from an SCM system)
|
||||
* via HTTP POST endpoint and passes the request body as event payload to the registered subscribers.
|
||||
@@ -71,7 +91,7 @@ export class HttpPostIngressEventPublisher {
|
||||
[topic: string]: Omit<HttpPostIngressOptions, 'topic'>;
|
||||
}): express.Router {
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
router.use(express.raw({ type: '*/*' }));
|
||||
|
||||
Object.keys(ingresses).forEach(topic =>
|
||||
this.addRouteForTopic(router, topic, ingresses[topic].validator),
|
||||
@@ -87,25 +107,60 @@ export class HttpPostIngressEventPublisher {
|
||||
validator?: RequestValidator,
|
||||
): void {
|
||||
const path = `/${topic}`;
|
||||
const logger = this.logger;
|
||||
|
||||
router.post(path, async (request, response) => {
|
||||
const requestDetails = {
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
};
|
||||
const context = new RequestValidationContextImpl();
|
||||
await validator?.(requestDetails, context);
|
||||
if (context.wasRejected()) {
|
||||
response
|
||||
.status(context.rejectionDetails!.status)
|
||||
.json(context.rejectionDetails!.payload);
|
||||
return;
|
||||
const requestBody = request.body;
|
||||
if (!Buffer.isBuffer(requestBody)) {
|
||||
throw new Error(
|
||||
`Failed to retrieve raw body from incoming event for topic ${topic}; not a buffer: ${typeof requestBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const bodyBuffer: Buffer = requestBody;
|
||||
const parsedContentType = contentType.parse(request);
|
||||
if (
|
||||
!parsedContentType.type ||
|
||||
parsedContentType.type !== 'application/json'
|
||||
) {
|
||||
throw new UnsupportedMediaTypeError(parsedContentType.type);
|
||||
}
|
||||
|
||||
const encoding = parsedContentType.parameters.charset ?? 'utf-8';
|
||||
if (!Buffer.isEncoding(encoding)) {
|
||||
throw new UnsupportedCharsetError(encoding);
|
||||
}
|
||||
|
||||
const bodyString = bodyBuffer.toString(encoding);
|
||||
const bodyParsed =
|
||||
parsedContentType.type === 'application/json'
|
||||
? JSON.parse(bodyString)
|
||||
: bodyString;
|
||||
|
||||
if (validator) {
|
||||
const requestDetails = {
|
||||
body: bodyParsed,
|
||||
headers: request.headers,
|
||||
raw: {
|
||||
body: bodyBuffer,
|
||||
encoding: encoding as BufferEncoding,
|
||||
},
|
||||
};
|
||||
|
||||
const context = new RequestValidationContextImpl();
|
||||
await validator(requestDetails, context);
|
||||
|
||||
if (context.wasRejected()) {
|
||||
response
|
||||
.status(context.rejectionDetails!.status)
|
||||
.json(context.rejectionDetails!.payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const eventPayload = request.body;
|
||||
await this.events.publish({
|
||||
topic,
|
||||
eventPayload,
|
||||
eventPayload: bodyParsed,
|
||||
metadata: request.headers,
|
||||
});
|
||||
|
||||
@@ -114,6 +169,6 @@ export class HttpPostIngressEventPublisher {
|
||||
|
||||
// TODO(pjungermann): We don't really know the externally defined path prefix here,
|
||||
// however it is more useful for users to have it. Is there a better way?
|
||||
this.logger.info(`Registered /api/events/http${path} to receive events`);
|
||||
logger.info(`Registered /api/events/http${path} to receive events`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { DiscoveryService } from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
@@ -114,10 +116,14 @@ export interface HttpPostIngressOptions {
|
||||
validator?: RequestValidator;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export interface RequestDetails {
|
||||
body: unknown;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
raw: {
|
||||
body: Buffer;
|
||||
encoding: BufferEncoding;
|
||||
};
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* View on an incoming request that has to be validated.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RequestDetails {
|
||||
@@ -26,4 +28,18 @@ export interface RequestDetails {
|
||||
* Key-value pairs of header names and values. Header names are lower-cased.
|
||||
*/
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/**
|
||||
* Raw request details.
|
||||
*/
|
||||
raw: {
|
||||
/**
|
||||
* Raw request body (buffer).
|
||||
*/
|
||||
body: Buffer;
|
||||
/**
|
||||
* Encoding of the raw request body.
|
||||
* Can be used to decode the raw request body like `raw.body.toString(raw.encoding)`.
|
||||
*/
|
||||
encoding: BufferEncoding;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6599,7 +6599,9 @@ __metadata:
|
||||
"@backstage/plugin-events-node": "workspace:^"
|
||||
"@backstage/repo-tools": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@types/content-type": ^1.1.8
|
||||
"@types/express": ^4.17.6
|
||||
content-type: ^1.0.5
|
||||
express: ^4.17.1
|
||||
express-promise-router: ^4.1.0
|
||||
knex: ^3.0.0
|
||||
@@ -17843,6 +17845,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/content-type@npm:^1.1.8":
|
||||
version: 1.1.8
|
||||
resolution: "@types/content-type@npm:1.1.8"
|
||||
checksum: 2dd15e51925db7208b0d989c3a93d805a0e5e0942aa9edd70a1c3520896b772526d8280e344a674ae68a96a24aa8fce290843a07512460176f36a3020d99c792
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/cookie-parser@npm:^1.4.2":
|
||||
version: 1.4.7
|
||||
resolution: "@types/cookie-parser@npm:1.4.7"
|
||||
|
||||
Reference in New Issue
Block a user