Skip to content

Commit

Permalink
Webhook Secret Field Implementation and Security Enhancements (#9187) (
Browse files Browse the repository at this point in the history
…#9219)

Closes #9187

This pull request introduces a new feature and several enhancements for
managing webhook security by adding a secret field and enabling HMAC
signature-based authentication. Below is a detailed breakdown of the
changes made:

## Frontend Updates
### Secret Field on Webhook Edit Page
- Added a new **Secret** section on the webhook edit page.
  - Includes a text input field for entering a webhook secret.
- Added a descriptive note explaining the purpose of the secret for
webhook authentication.

### State Management and Persistence
- Integrated the secret field into the Webhook type definition and state
management.
- Connected the secret field UI to the data layer, ensuring seamless
persistence of the secret field.

### Validation Improvement
- Trims leading and trailing whitespace from webhook secret inputs to
avoid potential validation issues.

## Backend Updates
### Database and Entity Changes
- Introduced a nullable `secret` field to the `WebhookWorkspaceEntity`
for securely storing webhook signing secrets.
- Field uses a standard field ID:
`20202020-97ce-410f-bff9-e9ccb038fb67`.

### Signature Generation
- Implemented HMAC-SHA256 signature generation for webhook payloads when
a secret is present:
- Signatures are added as a custom `X-Twenty-Webhook-Signature` header.
  - Secret is excluded from the payload to maintain security.

### Enhanced Security Measures
- Added additional headers for enhanced security:
  - **Timestamp Header**: Prevents replay attacks.
  - **Nonce Header**: Mitigates duplicate requests.
- Updated the OpenAPI specification to include documentation on these
security-related headers and signature verification.

## Documentation Updates
- Updated OpenAPI documentation for webhook endpoints:
  - Described security-related headers (signature, timestamp, nonce).
- Included detailed instructions for verifying HMAC signatures to assist
consumers.

## Testing and Demonstration
- [Loom Video
Link](https://www.loom.com/share/bd827e4d045f46d99f3c8186e5e5676a?sid=a5e61904-0536-4e82-8055-3d05e4598393):
Demonstrating the functionality of the secret field and webhook security
features.
- [Script Example
Link](https://runkit.com/samyakpiya/676af044040c0400086d400a): A script
showing how consumers can verify webhook authenticity using the HMAC
signature.
- [Testing Site
Instance](https://webhook.site/#!/view/3472468b-ebcd-4b7f-a083-c4ba20825bb4/6885fdce-8843-4d3f-8fe0-1d8abdd53f68/1):
Contains the logged requests sent during testing and is available for
review.

## Steps for Review
1. Verify the secret field functionality on the webhook edit page,
including state persistence and UI updates.
2. Review the security enhancements, including header additions and HMAC
signature generation.
3. Validate OpenAPI documentation changes for completeness and clarity.

---------

Co-authored-by: Félix Malfait <[email protected]>
  • Loading branch information
samyakpiya and FelixMalfait authored Dec 28, 2024
1 parent 36bec4e commit df12ba6
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export type Webhook = {
id: string;
targetUrl: string;
description?: string;
operation: string;
operations: string[];
secret?: string;
__typename: 'Webhook';
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
Expand All @@ -14,7 +15,6 @@ import {
Section,
useIcons,
} from 'twenty-ui';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';

import { AnalyticsActivityGraph } from '@/analytics/components/AnalyticsActivityGraph';
import { AnalyticsGraphEffect } from '@/analytics/components/AnalyticsGraphEffect';
Expand Down Expand Up @@ -74,6 +74,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
const [operations, setOperations] = useState<WebhookOperationType[]>([
WEBHOOK_EMPTY_OPERATION,
]);
const [secret, setSecret] = useState<string>('');
const [isDirty, setIsDirty] = useState<boolean>(false);
const { getIcon } = useIcons();

Expand All @@ -97,6 +98,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
: [];

setOperations(addEmptyOperationIfNecessary(baseOperations));
setSecret(data?.secret ?? '');
setIsDirty(false);
},
});
Expand Down Expand Up @@ -153,9 +155,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: {
operation: cleanedOperations?.[0],
operations: cleanedOperations,
description: description,
secret: secret,
},
});
navigate(developerPath);
Expand Down Expand Up @@ -291,6 +293,22 @@ export const SettingsDevelopersWebhooksDetail = () => {
</StyledFilterRow>
))}
</Section>
<Section>
<H2Title
title="Secret"
description="Optional: Define a secret string that we will include in every webhook. Use this to authenticate and verify the webhook upon receipt."
/>
<TextInput
type="password"
placeholder="Write a secret"
value={secret}
onChange={(secret: string) => {
setSecret(secret.trim());
setIsDirty(true);
}}
fullWidth
/>
</Section>
{isAnalyticsEnabled && isAnalyticsV2Enabled && (
<AnalyticsGraphDataInstanceContext.Provider
value={{ instanceId: `webhook-${webhookId}-analytics` }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@ export const SettingsDevelopersWebhooksNew = () => {
const navigate = useNavigate();
const [formValues, setFormValues] = useState<{
targetUrl: string;
operation: string;
operations: string[];
}>({
targetUrl: '',
operation: '*.*',
operations: ['*.*'],
});
const [isTargetUrlValid, setIsTargetUrlValid] = useState(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,48 @@ export const computeWebhooks = (
post: {
tags: [item.nameSingular],
security: [],
parameters: [
{
in: 'header',
name: 'X-Twenty-Webhook-Signature',
schema: {
type: 'string',
},
description:
'HMAC SHA256 signature of the request payload using the webhook secret. To compute the signature:\n' +
'1. Concatenate `X-Twenty-Webhook-Timestamp`, a colon (:), and the JSON string of the request payload.\n' +
'2. Compute the HMAC SHA256 hash using the shared secret as the key.\n' +
'3. Send the resulting hex digest as this header value.\n' +
'Example (Node.js):\n```javascript\n' +
'const crypto = require("crypto");\n' +
'const timestamp = "1735066639761";\n' +
'const payload = JSON.stringify({...});\n' +
'const secret = "your-secret";\n' +
'const stringToSign = `${timestamp}:${JSON.stringify(payload)}`;\n' +
'const signature = crypto.createHmac("sha256", secret)\n .update(stringToSign)\n .digest("hex");\n```',
required: false,
},
{
in: 'header',
name: 'X-Twenty-Webhook-Timestamp',
schema: {
type: 'string',
},
description:
'Unix timestamp of when the webhook was sent. This timestamp is included in the HMAC signature generation to prevent replay attacks.',
required: false,
},
{
in: 'header',
name: 'X-Twenty-Webhook-Nonce',
schema: {
type: 'string',
},
description:
'Unique identifier for this webhook request to prevent replay attacks. Consumers should ensure this nonce is not reused.',
required: false,
},
],
requestBody: {
content: {
'application/json': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export const WEBHOOK_STANDARD_FIELD_IDS = {
operation: '20202020-15b7-458e-bf30-74770a54410c',
operations: '20202020-15b7-458e-bf30-74770a54411c',
description: '20202020-15b7-458e-bf30-74770a54410d',
secret: '20202020-97ce-410f-bff9-e9ccb038fb67',
};

export const WORKFLOW_EVENT_LISTENER_STANDARD_FIELD_IDS = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CallWebhookJob,
CallWebhookJobData,
} from 'src/modules/webhook/jobs/call-webhook.job';
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';

@Processor(MessageQueue.webhookQueue)
export class CallWebhookJobsJob {
Expand Down Expand Up @@ -62,15 +63,22 @@ export class CallWebhookJobsJob {
const record = eventData.properties.after || eventData.properties.before;
const updatedFields = eventData.properties.updatedFields;

const isWebhookEvent = nameSingular === 'webhook';
const sanitizedRecord = removeSecretFromWebhookRecord(
record,
isWebhookEvent,
);

webhooks.forEach((webhook) => {
const webhookData = {
targetUrl: webhook.targetUrl,
secret: webhook.secret,
eventName,
objectMetadata,
workspaceId,
webhookId: webhook.id,
eventDate: new Date(),
record,
record: sanitizedRecord,
...(updatedFields && { updatedFields }),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { HttpService } from '@nestjs/axios';
import { Logger } from '@nestjs/common';

import crypto from 'crypto';

import { AnalyticsService } from 'src/engine/core-modules/analytics/analytics.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
Expand All @@ -15,6 +17,7 @@ export type CallWebhookJobData = {
eventDate: Date;
record: any;
updatedFields?: string[];
secret?: string;
};

@Processor(MessageQueue.webhookQueue)
Expand All @@ -25,6 +28,17 @@ export class CallWebhookJob {
private readonly analyticsService: AnalyticsService,
) {}

private generateSignature(
payload: CallWebhookJobData,
secret: string,
timestamp: string,
): string {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}:${JSON.stringify(payload)}`)
.digest('hex');
}

@Process(CallWebhookJob.name)
async handle(data: CallWebhookJobData): Promise<void> {
const commonPayload = {
Expand All @@ -34,10 +48,30 @@ export class CallWebhookJob {
};

try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};

const { secret, ...payloadWithoutSecret } = data;

if (secret) {
headers['X-Twenty-Webhook-Timestamp'] = Date.now().toString();
headers['X-Twenty-Webhook-Signature'] = this.generateSignature(
payloadWithoutSecret,
secret,
headers['X-Twenty-Webhook-Timestamp'],
);
headers['X-Twenty-Webhook-Nonce'] = crypto
.randomBytes(16)
.toString('hex');
}

const response = await this.httpService.axiosRef.post(
data.targetUrl,
data,
payloadWithoutSecret,
{ headers },
);

const success = response.status >= 200 && response.status < 300;
const eventInput = {
action: 'webhook.response',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,14 @@ export class WebhookWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsNullable()
description: string;

@WorkspaceField({
standardId: WEBHOOK_STANDARD_FIELD_IDS.secret,
type: FieldMetadataType.TEXT,
label: 'Secret',
description:
'Optional secret used to compute the HMAC signature for webhook payloads. This secret is shared between Twenty and the webhook consumer to authenticate webhook requests.',
icon: 'IconLock',
})
secret: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const removeSecretFromWebhookRecord = (
record: Record<string, any> | undefined,
isWebhookEvent: boolean,
): Record<string, any> | undefined => {
if (!isWebhookEvent || !record) return record;

const { secret: _secret, ...sanitizedRecord } = record;

return sanitizedRecord;
};

0 comments on commit df12ba6

Please sign in to comment.