@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,213 @@
1
+ "use strict";
2
+ /*
3
+ Copyright (c) 2025 Bernier LLC
4
+
5
+ This file is licensed to the client under a limited-use license.
6
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
7
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ContentfulWebhookHandler = void 0;
14
+ const crypto_1 = __importDefault(require("crypto"));
15
+ const logger_1 = require("@bernierllc/logger");
16
+ /**
17
+ * Validates Contentful webhook signatures using HMAC-SHA256,
18
+ * parses webhook payloads, and provides type-safe interfaces
19
+ * for all webhook event types.
20
+ */
21
+ class ContentfulWebhookHandler {
22
+ webhookSecret;
23
+ logger;
24
+ enableTimestampValidation;
25
+ maxTimestampDeltaSeconds;
26
+ /**
27
+ * Creates a new ContentfulWebhookHandler instance
28
+ *
29
+ * @param options Configuration options
30
+ */
31
+ constructor(options) {
32
+ this.webhookSecret = options.webhookSecret;
33
+ this.logger = options.logger || (0, logger_1.createLogger)({
34
+ context: {
35
+ service: 'ContentfulWebhookHandler',
36
+ version: '1.0.0',
37
+ environment: process.env.NODE_ENV || 'development'
38
+ }
39
+ });
40
+ this.enableTimestampValidation = options.enableTimestampValidation ?? false;
41
+ this.maxTimestampDeltaSeconds = options.maxTimestampDeltaSeconds ?? 300; // 5 minutes default
42
+ }
43
+ /**
44
+ * Validates a webhook signature using HMAC-SHA256
45
+ *
46
+ * @param body Raw webhook body as string
47
+ * @param signature Signature from x-contentful-signature header
48
+ * @returns True if signature is valid
49
+ */
50
+ validateSignature(body, signature) {
51
+ try {
52
+ const hmac = crypto_1.default.createHmac('sha256', this.webhookSecret);
53
+ hmac.update(body);
54
+ const computedSignature = hmac.digest('base64');
55
+ // Use timing-safe comparison to prevent timing attacks
56
+ const signatureBuffer = Buffer.from(signature);
57
+ const computedBuffer = Buffer.from(computedSignature);
58
+ // Ensure buffers are the same length for timingSafeEqual
59
+ if (signatureBuffer.length !== computedBuffer.length) {
60
+ this.logger.warn('Invalid webhook signature (length mismatch)');
61
+ return false;
62
+ }
63
+ // TypeScript requires explicit cast for Buffer to ArrayBufferView
64
+ const isValid = crypto_1.default.timingSafeEqual(signatureBuffer, computedBuffer);
65
+ if (!isValid) {
66
+ this.logger.warn('Invalid webhook signature');
67
+ }
68
+ return isValid;
69
+ }
70
+ catch (err) {
71
+ // Handle buffer length mismatch or other crypto errors
72
+ if (err instanceof Error) {
73
+ this.logger.error('Signature validation error', err);
74
+ }
75
+ else {
76
+ this.logger.error('Signature validation error', new Error(String(err)));
77
+ }
78
+ return false;
79
+ }
80
+ }
81
+ /**
82
+ * Validates webhook timestamp to prevent replay attacks
83
+ *
84
+ * @param payload Parsed webhook payload
85
+ * @returns True if timestamp is within acceptable range
86
+ */
87
+ validateTimestamp(payload) {
88
+ if (!this.enableTimestampValidation) {
89
+ return true;
90
+ }
91
+ const updatedAt = payload.sys.updatedAt;
92
+ if (!updatedAt) {
93
+ this.logger.warn('No timestamp in webhook payload');
94
+ return false;
95
+ }
96
+ const payloadTime = new Date(updatedAt).getTime();
97
+ const currentTime = Date.now();
98
+ const deltaSeconds = Math.abs(currentTime - payloadTime) / 1000;
99
+ if (deltaSeconds > this.maxTimestampDeltaSeconds) {
100
+ this.logger.warn('Webhook timestamp outside acceptable range', {
101
+ deltaSeconds,
102
+ maxAllowed: this.maxTimestampDeltaSeconds
103
+ });
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+ /**
109
+ * Parses and validates a Contentful webhook
110
+ *
111
+ * @param body Raw webhook body as string
112
+ * @param headers Webhook headers
113
+ * @returns Validation result with parsed payload if valid
114
+ */
115
+ parseWebhook(body, headers) {
116
+ try {
117
+ // Validate required headers
118
+ const signature = headers['x-contentful-signature'];
119
+ if (!signature) {
120
+ return {
121
+ valid: false,
122
+ error: 'Missing signature header (x-contentful-signature)'
123
+ };
124
+ }
125
+ const topic = headers['x-contentful-topic'];
126
+ if (!topic) {
127
+ return {
128
+ valid: false,
129
+ error: 'Missing topic header (x-contentful-topic)'
130
+ };
131
+ }
132
+ // Validate signature
133
+ if (!this.validateSignature(body, signature)) {
134
+ return {
135
+ valid: false,
136
+ error: 'Invalid signature'
137
+ };
138
+ }
139
+ // Parse payload
140
+ const payload = JSON.parse(body);
141
+ // Validate timestamp for replay attack prevention
142
+ if (!this.validateTimestamp(payload)) {
143
+ return {
144
+ valid: false,
145
+ error: 'Invalid timestamp (possible replay attack)'
146
+ };
147
+ }
148
+ this.logger.info('Webhook validated successfully', {
149
+ topic,
150
+ id: payload.sys.id,
151
+ type: payload.sys.type
152
+ });
153
+ return {
154
+ valid: true,
155
+ payload,
156
+ topic
157
+ };
158
+ }
159
+ catch (err) {
160
+ if (err instanceof Error) {
161
+ this.logger.error('Failed to parse webhook', err);
162
+ return {
163
+ valid: false,
164
+ error: err.message
165
+ };
166
+ }
167
+ else {
168
+ const errorMessage = String(err);
169
+ this.logger.error('Failed to parse webhook', new Error(errorMessage));
170
+ return {
171
+ valid: false,
172
+ error: errorMessage
173
+ };
174
+ }
175
+ }
176
+ }
177
+ /**
178
+ * Extracts resource type and action from a topic string
179
+ *
180
+ * @param topic Contentful webhook topic (e.g., "Entry.publish")
181
+ * @returns Object with resource type and action
182
+ */
183
+ getEventType(topic) {
184
+ const parts = topic.split('.');
185
+ if (parts.length !== 2) {
186
+ throw new Error(`Invalid topic format: ${topic}`);
187
+ }
188
+ const [resource, action] = parts;
189
+ // Validate resource type
190
+ if (!['Entry', 'Asset', 'ContentType'].includes(resource)) {
191
+ throw new Error(`Invalid resource type: ${resource}`);
192
+ }
193
+ // Validate action type
194
+ const validActions = ['create', 'save', 'auto_save', 'publish', 'unpublish', 'delete', 'archive', 'unarchive'];
195
+ if (!validActions.includes(action)) {
196
+ throw new Error(`Invalid action type: ${action}`);
197
+ }
198
+ return {
199
+ resource: resource,
200
+ action: action
201
+ };
202
+ }
203
+ /**
204
+ * Updates the webhook secret
205
+ *
206
+ * @param newSecret New webhook secret
207
+ */
208
+ updateSecret(newSecret) {
209
+ this.webhookSecret = newSecret;
210
+ this.logger.info('Webhook secret updated');
211
+ }
212
+ }
213
+ exports.ContentfulWebhookHandler = ContentfulWebhookHandler;
@@ -0,0 +1 @@
1
+ export {};