diff --git a/packages/destination-actions/src/destinations/webhook-extensible/__test__/webhook.test.ts b/packages/destination-actions/src/destinations/webhook-extensible/__test__/webhook.test.ts index 359100ef205..6db2a70fea9 100644 --- a/packages/destination-actions/src/destinations/webhook-extensible/__test__/webhook.test.ts +++ b/packages/destination-actions/src/destinations/webhook-extensible/__test__/webhook.test.ts @@ -6,6 +6,7 @@ import { DestinationDefinition } from '@segment/actions-core' import Webhook from '../index' +import { createHmac, timingSafeEqual } from 'crypto' const settings = { oauth: {}, @@ -261,6 +262,99 @@ export const baseWebhookTests = (def: DestinationDefinition) => { }) ).rejects.toThrow(PayloadValidationError) }) + + it('supports request signing for no auth', async () => { + const url = 'https://example.com' + const event = createTestEvent({ + properties: { cool: true } + }) + const payload = JSON.stringify(event.properties) + const sharedSecret = 'sharedSecret123' + + nock(url) + .post('/', payload) + .reply(async function (_uri, body) { + // Normally you should use the raw body but nock automatically + // deserializes it (and doesn't allow us to access the raw request + // body) so we re-serialize the body here so that we can demonstrate + // signture validation. + const bodyString = JSON.stringify(body) + + // Validate the signature + const expectSignature = this.req.headers['x-signature'][0] + const actualSignature = createHmac('sha1', sharedSecret).update(bodyString).digest('hex') + + // Use constant-time comparison to avoid timing attacks + if ( + expectSignature.length !== actualSignature.length || + !timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex')) + ) { + return [400, 'Invalid signature'] + } + + return [200, 'OK'] + }) + + const responses = await testDestination.testAction('send', { + event, + mapping: { + url, + data: { '@path': '$.properties' } + }, + settings: { sharedSecret, ...noAuthSettings }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('supports request signing for bearer token', async () => { + const url = 'https://example.com' + const event = createTestEvent({ + properties: { cool: true } + }) + const payload = JSON.stringify(event.properties) + const sharedSecret = 'sharedSecret123' + + nock(url) + .post('/', payload) + .matchHeader('authorization', 'Bearer BearerToken1') + .reply(async function (_uri, body) { + // Normally you should use the raw body but nock automatically + // deserializes it (and doesn't allow us to access the raw request + // body) so we re-serialize the body here so that we can demonstrate + // signture validation. + const bodyString = JSON.stringify(body) + + // Validate the signature + const expectSignature = this.req.headers['x-signature'][0] + const actualSignature = createHmac('sha1', sharedSecret).update(bodyString).digest('hex') + + // Use constant-time comparison to avoid timing attacks + if ( + expectSignature.length !== actualSignature.length || + !timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex')) + ) { + return [400, 'Invalid signature'] + } + + return [200, 'OK'] + }) + + const responses = await testDestination.testAction('send', { + event, + mapping: { + url, + data: { '@path': '$.properties' } + }, + settings: { sharedSecret, ...bearerTypeSettings }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) }) describe('refreshAccessToken', () => { diff --git a/packages/destination-actions/src/destinations/webhook-extensible/index.ts b/packages/destination-actions/src/destinations/webhook-extensible/index.ts index ff36d2cd062..81c9268eed6 100644 --- a/packages/destination-actions/src/destinations/webhook-extensible/index.ts +++ b/packages/destination-actions/src/destinations/webhook-extensible/index.ts @@ -1,6 +1,7 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import { sendRefreshTokenReq } from './auth-utils' +import { createHmac } from 'crypto' import send from './send' @@ -26,12 +27,24 @@ const destination: DestinationDefinition = { return res } }, - extendRequest: ({ settings, auth }) => { + extendRequest: ({ settings, auth, payload }) => { const { dynamicAuthSettings } = settings + let xSignatureHeader + + if (payload) { + const payloadData = payload.length ? payload[0]['data'] : payload['data'] + if (settings.sharedSecret && payloadData) { + const digest = createHmac('sha1', settings.sharedSecret) + .update(JSON.stringify(payloadData), 'utf8') + .digest('hex') + xSignatureHeader = { 'X-Signature': digest } + } + } + let accessToken let tokenPrefix = 'Bearer' if (dynamicAuthSettings?.oauth?.type === 'noAuth') { - return {} + return xSignatureHeader ? { headers: xSignatureHeader } : {} } if (dynamicAuthSettings?.bearer) { accessToken = dynamicAuthSettings?.bearer?.bearerToken @@ -41,7 +54,8 @@ const destination: DestinationDefinition = { } return { headers: { - authorization: `${tokenPrefix} ${accessToken}` + authorization: `${tokenPrefix} ${accessToken}`, + ...(xSignatureHeader || {}) } } },