@bernierllc/contentful-webhook-handler 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ module.exports = {
10
+ preset: 'ts-jest',
11
+ testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
12
+ roots: ['<rootDir>/src'],
13
+ testMatch: ['**/__tests__/**/*.test.ts'],
14
+ collectCoverageFrom: [
15
+ 'src/**/*.ts',
16
+ '!src/**/*.test.ts',
17
+ '!src/**/__tests__/**',
18
+ '!src/index.ts'
19
+ ],
20
+ coverageThreshold: {
21
+ global: {
22
+ branches: 90,
23
+ functions: 90,
24
+ lines: 90,
25
+ statements: 90
26
+ }
27
+ },
28
+ coveragePathIgnorePatterns: [
29
+ '/node_modules/',
30
+ '/dist/',
31
+ '/__tests__/'
32
+ ]
33
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@bernierllc/contentful-webhook-handler",
3
+ "version": "1.0.3",
4
+ "description": "Validates Contentful webhook signatures using HMAC-SHA256, parses webhook payloads, and provides type-safe interfaces for all webhook event types",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "jest --no-watch",
10
+ "test:run": "jest --no-watch",
11
+ "test:watch": "jest --watch",
12
+ "test:coverage": "jest --coverage --no-watch",
13
+ "lint": "eslint src --ext .ts",
14
+ "lint:fix": "eslint src --ext .ts --fix",
15
+ "prepublishOnly": "npm run build && npm run lint && npm test"
16
+ },
17
+ "keywords": [
18
+ "contentful",
19
+ "webhook",
20
+ "signature-validation",
21
+ "hmac",
22
+ "sha256"
23
+ ],
24
+ "author": "Bernier LLC",
25
+ "license": "SEE LICENSE IN LICENSE.md",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/bernierllc/tools.git",
29
+ "directory": "packages/core/contentful-webhook-handler"
30
+ },
31
+ "dependencies": {
32
+ "@bernierllc/contentful-types": "workspace:*",
33
+ "@bernierllc/logger": "^1.0.1"
34
+ },
35
+ "devDependencies": {
36
+ "@types/jest": "^29.5.12",
37
+ "@types/node": "^20.11.30",
38
+ "@typescript-eslint/eslint-plugin": "^7.3.1",
39
+ "@typescript-eslint/parser": "^7.3.1",
40
+ "eslint": "^8.57.0",
41
+ "jest": "^29.7.0",
42
+ "ts-jest": "^29.1.2",
43
+ "typescript": "^5.4.2"
44
+ }
45
+ }
@@ -0,0 +1,260 @@
1
+ /*
2
+ Copyright (c) 2025 Bernier LLC
3
+
4
+ This file is licensed to the client under a limited-use license.
5
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
6
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import {
11
+ ContentfulWebhookPayload,
12
+ ContentfulWebhookTopic,
13
+ ContentfulWebhookHeaders
14
+ } from '@bernierllc/contentful-types';
15
+ import { Logger, createLogger } from '@bernierllc/logger';
16
+
17
+ export interface WebhookValidationResult {
18
+ valid: boolean;
19
+ error?: string;
20
+ payload?: ContentfulWebhookPayload;
21
+ topic?: ContentfulWebhookTopic;
22
+ }
23
+
24
+ export interface EventType {
25
+ resource: 'Entry' | 'Asset' | 'ContentType';
26
+ action: 'create' | 'save' | 'auto_save' | 'publish' | 'unpublish' | 'delete' | 'archive' | 'unarchive';
27
+ }
28
+
29
+ export interface ContentfulWebhookHandlerOptions {
30
+ webhookSecret: string;
31
+ logger?: Logger;
32
+ enableTimestampValidation?: boolean;
33
+ maxTimestampDeltaSeconds?: number;
34
+ }
35
+
36
+ /**
37
+ * Validates Contentful webhook signatures using HMAC-SHA256,
38
+ * parses webhook payloads, and provides type-safe interfaces
39
+ * for all webhook event types.
40
+ */
41
+ export class ContentfulWebhookHandler {
42
+ private webhookSecret: string;
43
+ private logger: Logger;
44
+ private enableTimestampValidation: boolean;
45
+ private maxTimestampDeltaSeconds: number;
46
+
47
+ /**
48
+ * Creates a new ContentfulWebhookHandler instance
49
+ *
50
+ * @param options Configuration options
51
+ */
52
+ constructor(options: ContentfulWebhookHandlerOptions) {
53
+ this.webhookSecret = options.webhookSecret;
54
+ this.logger = options.logger || createLogger({
55
+ context: {
56
+ service: 'ContentfulWebhookHandler',
57
+ version: '1.0.0',
58
+ environment: process.env.NODE_ENV || 'development'
59
+ }
60
+ });
61
+ this.enableTimestampValidation = options.enableTimestampValidation ?? false;
62
+ this.maxTimestampDeltaSeconds = options.maxTimestampDeltaSeconds ?? 300; // 5 minutes default
63
+ }
64
+
65
+ /**
66
+ * Validates a webhook signature using HMAC-SHA256
67
+ *
68
+ * @param body Raw webhook body as string
69
+ * @param signature Signature from x-contentful-signature header
70
+ * @returns True if signature is valid
71
+ */
72
+ validateSignature(body: string, signature: string): boolean {
73
+ try {
74
+ const hmac = crypto.createHmac('sha256', this.webhookSecret);
75
+ hmac.update(body);
76
+ const computedSignature = hmac.digest('base64');
77
+
78
+ // Use timing-safe comparison to prevent timing attacks
79
+ const signatureBuffer = Buffer.from(signature);
80
+ const computedBuffer = Buffer.from(computedSignature);
81
+
82
+ // Ensure buffers are the same length for timingSafeEqual
83
+ if (signatureBuffer.length !== computedBuffer.length) {
84
+ this.logger.warn('Invalid webhook signature (length mismatch)');
85
+ return false;
86
+ }
87
+
88
+ // TypeScript requires explicit cast for Buffer to ArrayBufferView
89
+ const isValid = crypto.timingSafeEqual(
90
+ signatureBuffer as unknown as Uint8Array,
91
+ computedBuffer as unknown as Uint8Array
92
+ );
93
+
94
+ if (!isValid) {
95
+ this.logger.warn('Invalid webhook signature');
96
+ }
97
+
98
+ return isValid;
99
+ } catch (err) {
100
+ // Handle buffer length mismatch or other crypto errors
101
+ if (err instanceof Error) {
102
+ this.logger.error('Signature validation error', err);
103
+ } else {
104
+ this.logger.error('Signature validation error', new Error(String(err)));
105
+ }
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Validates webhook timestamp to prevent replay attacks
112
+ *
113
+ * @param payload Parsed webhook payload
114
+ * @returns True if timestamp is within acceptable range
115
+ */
116
+ private validateTimestamp(payload: ContentfulWebhookPayload): boolean {
117
+ if (!this.enableTimestampValidation) {
118
+ return true;
119
+ }
120
+
121
+ const updatedAt = payload.sys.updatedAt;
122
+ if (!updatedAt) {
123
+ this.logger.warn('No timestamp in webhook payload');
124
+ return false;
125
+ }
126
+
127
+ const payloadTime = new Date(updatedAt).getTime();
128
+ const currentTime = Date.now();
129
+ const deltaSeconds = Math.abs(currentTime - payloadTime) / 1000;
130
+
131
+ if (deltaSeconds > this.maxTimestampDeltaSeconds) {
132
+ this.logger.warn('Webhook timestamp outside acceptable range', {
133
+ deltaSeconds,
134
+ maxAllowed: this.maxTimestampDeltaSeconds
135
+ });
136
+ return false;
137
+ }
138
+
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Parses and validates a Contentful webhook
144
+ *
145
+ * @param body Raw webhook body as string
146
+ * @param headers Webhook headers
147
+ * @returns Validation result with parsed payload if valid
148
+ */
149
+ parseWebhook(
150
+ body: string,
151
+ headers: Partial<ContentfulWebhookHeaders>
152
+ ): WebhookValidationResult {
153
+ try {
154
+ // Validate required headers
155
+ const signature = headers['x-contentful-signature'];
156
+ if (!signature) {
157
+ return {
158
+ valid: false,
159
+ error: 'Missing signature header (x-contentful-signature)'
160
+ };
161
+ }
162
+
163
+ const topic = headers['x-contentful-topic'];
164
+ if (!topic) {
165
+ return {
166
+ valid: false,
167
+ error: 'Missing topic header (x-contentful-topic)'
168
+ };
169
+ }
170
+
171
+ // Validate signature
172
+ if (!this.validateSignature(body, signature)) {
173
+ return {
174
+ valid: false,
175
+ error: 'Invalid signature'
176
+ };
177
+ }
178
+
179
+ // Parse payload
180
+ const payload: ContentfulWebhookPayload = JSON.parse(body) as ContentfulWebhookPayload;
181
+
182
+ // Validate timestamp for replay attack prevention
183
+ if (!this.validateTimestamp(payload)) {
184
+ return {
185
+ valid: false,
186
+ error: 'Invalid timestamp (possible replay attack)'
187
+ };
188
+ }
189
+
190
+ this.logger.info('Webhook validated successfully', {
191
+ topic,
192
+ id: payload.sys.id,
193
+ type: payload.sys.type
194
+ });
195
+
196
+ return {
197
+ valid: true,
198
+ payload,
199
+ topic
200
+ };
201
+ } catch (err) {
202
+ if (err instanceof Error) {
203
+ this.logger.error('Failed to parse webhook', err);
204
+ return {
205
+ valid: false,
206
+ error: err.message
207
+ };
208
+ } else {
209
+ const errorMessage = String(err);
210
+ this.logger.error('Failed to parse webhook', new Error(errorMessage));
211
+ return {
212
+ valid: false,
213
+ error: errorMessage
214
+ };
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Extracts resource type and action from a topic string
221
+ *
222
+ * @param topic Contentful webhook topic (e.g., "Entry.publish")
223
+ * @returns Object with resource type and action
224
+ */
225
+ getEventType(topic: ContentfulWebhookTopic): EventType {
226
+ const parts = topic.split('.');
227
+
228
+ if (parts.length !== 2) {
229
+ throw new Error(`Invalid topic format: ${topic}`);
230
+ }
231
+
232
+ const [resource, action] = parts;
233
+
234
+ // Validate resource type
235
+ if (!['Entry', 'Asset', 'ContentType'].includes(resource)) {
236
+ throw new Error(`Invalid resource type: ${resource}`);
237
+ }
238
+
239
+ // Validate action type
240
+ const validActions = ['create', 'save', 'auto_save', 'publish', 'unpublish', 'delete', 'archive', 'unarchive'];
241
+ if (!validActions.includes(action)) {
242
+ throw new Error(`Invalid action type: ${action}`);
243
+ }
244
+
245
+ return {
246
+ resource: resource as EventType['resource'],
247
+ action: action as EventType['action']
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Updates the webhook secret
253
+ *
254
+ * @param newSecret New webhook secret
255
+ */
256
+ updateSecret(newSecret: string): void {
257
+ this.webhookSecret = newSecret;
258
+ this.logger.info('Webhook secret updated');
259
+ }
260
+ }