hookarmor 1.0.3 → 1.1.1
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.
- package/LICENSE +21 -0
- package/README.md +305 -260
- package/bin/hookarmor.js +175 -133
- package/package.json +52 -52
- package/src/alerter.js +72 -63
- package/src/dispatcher.js +429 -356
- package/src/netguard.js +117 -0
- package/src/public/index.html +96 -40
- package/src/public/landing.html +2 -2
- package/src/server.js +593 -393
- package/src/storage.js +355 -266
- package/src/worker.js +72 -62
package/src/dispatcher.js
CHANGED
|
@@ -1,356 +1,429 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const https = require('https');
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const EventEmitter = require('events');
|
|
5
|
-
const Alerter = require('./alerter');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
return { valid: false, reason:
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
eventId: event.id,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
latencyMs
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
const Alerter = require('./alerter');
|
|
6
|
+
const { checkUrl, guardedLookup } = require('./netguard');
|
|
7
|
+
|
|
8
|
+
function safeEqual(a, b) {
|
|
9
|
+
const bufA = Buffer.from(String(a));
|
|
10
|
+
const bufB = Buffer.from(String(b));
|
|
11
|
+
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Svix (used by Clerk) secrets are "whsec_" + base64 key bytes
|
|
15
|
+
function svixKey(secret) {
|
|
16
|
+
return Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function svixSignature(secret, msgId, timestamp, rawBody) {
|
|
20
|
+
const hmac = crypto.createHmac('sha256', svixKey(secret));
|
|
21
|
+
hmac.update(Buffer.from(`${msgId}.${timestamp}.`, 'utf8'));
|
|
22
|
+
hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
|
|
23
|
+
return hmac.digest('base64');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class Dispatcher extends EventEmitter {
|
|
27
|
+
constructor(storage, options = {}) {
|
|
28
|
+
super();
|
|
29
|
+
this.storage = storage;
|
|
30
|
+
this.defaultConcurrency = options.defaultConcurrency || 5;
|
|
31
|
+
this.inFlightCount = new Map();
|
|
32
|
+
this.waitQueues = new Map();
|
|
33
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
|
|
34
|
+
this.strictSSRF = Boolean(options.strictSSRF);
|
|
35
|
+
this.signingSecret = options.signingSecret || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Verify signature at ingress (preventing HookArmor from acting as an open signature oracle).
|
|
39
|
+
// When a secret is configured, events without a supported signature scheme are rejected.
|
|
40
|
+
static verifyIngressSignature(provider, rawBody, headers, secret, toleranceSec = 300) {
|
|
41
|
+
if (!secret) return { valid: true };
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (provider === 'stripe') {
|
|
45
|
+
const sigHeader = headers['stripe-signature'];
|
|
46
|
+
if (!sigHeader) return { valid: false, reason: 'Missing Stripe-Signature header' };
|
|
47
|
+
|
|
48
|
+
// Stripe may send several v1 signatures (e.g. while a secret is being rolled)
|
|
49
|
+
let timestampStr = null;
|
|
50
|
+
const signatures = [];
|
|
51
|
+
for (const item of sigHeader.split(',')) {
|
|
52
|
+
const idx = item.indexOf('=');
|
|
53
|
+
if (idx === -1) continue;
|
|
54
|
+
const k = item.slice(0, idx).trim();
|
|
55
|
+
const v = item.slice(idx + 1).trim();
|
|
56
|
+
if (k === 't') timestampStr = v;
|
|
57
|
+
if (k === 'v1' && v) signatures.push(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!timestampStr || signatures.length === 0) return { valid: false, reason: 'Malformed Stripe-Signature header' };
|
|
61
|
+
|
|
62
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
63
|
+
const now = Math.floor(Date.now() / 1000);
|
|
64
|
+
if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSec) {
|
|
65
|
+
return { valid: false, reason: `Timestamp outside tolerance (${toleranceSec}s)` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
69
|
+
hmac.update(Buffer.from(`${timestampStr}.`, 'utf8'));
|
|
70
|
+
hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
|
|
71
|
+
const expectedSig = hmac.digest('hex');
|
|
72
|
+
|
|
73
|
+
if (!signatures.some((sig) => safeEqual(expectedSig, sig))) {
|
|
74
|
+
return { valid: false, reason: 'Signature mismatch' };
|
|
75
|
+
}
|
|
76
|
+
return { valid: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (provider === 'shopify') {
|
|
80
|
+
const hmac = headers['x-shopify-hmac-sha256'];
|
|
81
|
+
if (!hmac) return { valid: false, reason: 'Missing X-Shopify-Hmac-Sha256 header' };
|
|
82
|
+
|
|
83
|
+
const expectedSig = crypto
|
|
84
|
+
.createHmac('sha256', secret)
|
|
85
|
+
.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
|
|
86
|
+
.digest('base64');
|
|
87
|
+
|
|
88
|
+
if (!safeEqual(expectedSig, hmac)) {
|
|
89
|
+
return { valid: false, reason: 'Shopify HMAC mismatch' };
|
|
90
|
+
}
|
|
91
|
+
return { valid: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (provider === 'github') {
|
|
95
|
+
const sig = headers['x-hub-signature-256'];
|
|
96
|
+
if (!sig) return { valid: false, reason: 'Missing X-Hub-Signature-256 header' };
|
|
97
|
+
|
|
98
|
+
const expectedSig = 'sha256=' + crypto
|
|
99
|
+
.createHmac('sha256', secret)
|
|
100
|
+
.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
|
|
101
|
+
.digest('hex');
|
|
102
|
+
if (!safeEqual(expectedSig, sig)) {
|
|
103
|
+
return { valid: false, reason: 'GitHub signature mismatch' };
|
|
104
|
+
}
|
|
105
|
+
return { valid: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (provider === 'clerk/svix') {
|
|
109
|
+
const msgId = headers['svix-id'];
|
|
110
|
+
const timestampStr = headers['svix-timestamp'];
|
|
111
|
+
const sigHeader = headers['svix-signature'];
|
|
112
|
+
if (!msgId || !timestampStr || !sigHeader) {
|
|
113
|
+
return { valid: false, reason: 'Missing svix-id, svix-timestamp or svix-signature header' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
117
|
+
const now = Math.floor(Date.now() / 1000);
|
|
118
|
+
if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSec) {
|
|
119
|
+
return { valid: false, reason: `Timestamp outside tolerance (${toleranceSec}s)` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const expectedSig = svixSignature(secret, msgId, timestampStr, rawBody);
|
|
123
|
+
// Header is a space-separated list of "v1,<base64>" entries
|
|
124
|
+
const matches = sigHeader.split(' ').some((entry) => {
|
|
125
|
+
const [version, sig] = entry.split(',');
|
|
126
|
+
return version === 'v1' && sig && safeEqual(expectedSig, sig);
|
|
127
|
+
});
|
|
128
|
+
if (!matches) return { valid: false, reason: 'Svix signature mismatch' };
|
|
129
|
+
return { valid: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { valid: false, reason: 'Endpoint has a secret but the request carries no supported signature (Stripe, Shopify, GitHub, Svix/Clerk)' };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { valid: false, reason: err.message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fresh re-signing on forward/replay to defeat the 5-minute Stripe expiration trap
|
|
139
|
+
signHeaders(provider, rawBody, headers, secret) {
|
|
140
|
+
if (!secret) return headers;
|
|
141
|
+
|
|
142
|
+
const modified = { ...headers };
|
|
143
|
+
try {
|
|
144
|
+
if (provider === 'stripe') {
|
|
145
|
+
const freshTimestamp = Math.floor(Date.now() / 1000);
|
|
146
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
147
|
+
hmac.update(Buffer.from(`${freshTimestamp}.`, 'utf8'));
|
|
148
|
+
hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
|
|
149
|
+
const freshSignature = hmac.digest('hex');
|
|
150
|
+
modified['stripe-signature'] = `t=${freshTimestamp},v1=${freshSignature}`;
|
|
151
|
+
} else if (provider === 'shopify') {
|
|
152
|
+
const freshHmac = crypto
|
|
153
|
+
.createHmac('sha256', secret)
|
|
154
|
+
.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
|
|
155
|
+
.digest('base64');
|
|
156
|
+
modified['x-shopify-hmac-sha256'] = freshHmac;
|
|
157
|
+
} else if (provider === 'github') {
|
|
158
|
+
const freshSig = 'sha256=' + crypto
|
|
159
|
+
.createHmac('sha256', secret)
|
|
160
|
+
.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
|
|
161
|
+
.digest('hex');
|
|
162
|
+
modified['x-hub-signature-256'] = freshSig;
|
|
163
|
+
} else if (provider === 'clerk/svix' && modified['svix-id']) {
|
|
164
|
+
const freshTimestamp = String(Math.floor(Date.now() / 1000));
|
|
165
|
+
modified['svix-timestamp'] = freshTimestamp;
|
|
166
|
+
modified['svix-signature'] = `v1,${svixSignature(secret, modified['svix-id'], freshTimestamp, rawBody)}`;
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {}
|
|
169
|
+
|
|
170
|
+
return modified;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async acquireSlot(endpointId, limit) {
|
|
174
|
+
const current = this.inFlightCount.get(endpointId) || 0;
|
|
175
|
+
if (current < limit) {
|
|
176
|
+
this.inFlightCount.set(endpointId, current + 1);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return new Promise(resolve => {
|
|
181
|
+
if (!this.waitQueues.has(endpointId)) this.waitQueues.set(endpointId, []);
|
|
182
|
+
this.waitQueues.get(endpointId).push(resolve);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
releaseSlot(endpointId, limit) {
|
|
187
|
+
const queue = this.waitQueues.get(endpointId);
|
|
188
|
+
if (queue && queue.length > 0) {
|
|
189
|
+
const next = queue.shift();
|
|
190
|
+
next();
|
|
191
|
+
} else {
|
|
192
|
+
const current = this.inFlightCount.get(endpointId) || 1;
|
|
193
|
+
this.inFlightCount.set(endpointId, Math.max(0, current - 1));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Calculate exponential backoff with jitter: 30s, 2m, 10m, 30m, 2h
|
|
198
|
+
calculateNextRetry(attempt) {
|
|
199
|
+
const intervals = [30, 120, 600, 1800, 7200]; // seconds
|
|
200
|
+
const base = intervals[Math.min(attempt, intervals.length - 1)];
|
|
201
|
+
const jitter = Math.floor(Math.random() * (base * 0.2));
|
|
202
|
+
const delaySec = base + jitter;
|
|
203
|
+
// SQLite datetime format, so the worker's comparison against datetime('now') is correct
|
|
204
|
+
return new Date(Date.now() + delaySec * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Callers must have claimed the event (status 'replaying') before dispatching
|
|
208
|
+
async dispatch(event, endpoint, { replay = false } = {}) {
|
|
209
|
+
const limit = endpoint.concurrency_limit || this.defaultConcurrency;
|
|
210
|
+
await this.acquireSlot(endpoint.id, limit);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return await this._executeDispatch(event, endpoint, replay);
|
|
214
|
+
} finally {
|
|
215
|
+
this.releaseSlot(endpoint.id, limit);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async _executeDispatch(event, endpoint, replay = false) {
|
|
220
|
+
const startTime = Date.now();
|
|
221
|
+
const targetUrl = endpoint.target_url;
|
|
222
|
+
|
|
223
|
+
let headers = { ...event.headers };
|
|
224
|
+
|
|
225
|
+
// Never forward sender-supplied HookArmor headers; ours are added below
|
|
226
|
+
for (const name of Object.keys(headers)) {
|
|
227
|
+
if (name.startsWith('x-hookarmor-')) delete headers[name];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Preserve original raw signatures before re-signing for audit and forensic trace
|
|
231
|
+
if (event.headers && event.headers['stripe-signature']) {
|
|
232
|
+
headers['x-hookarmor-original-stripe-signature'] = event.headers['stripe-signature'];
|
|
233
|
+
}
|
|
234
|
+
if (event.headers && event.headers['x-shopify-hmac-sha256']) {
|
|
235
|
+
headers['x-hookarmor-original-shopify-hmac'] = event.headers['x-shopify-hmac-sha256'];
|
|
236
|
+
}
|
|
237
|
+
if (event.headers && event.headers['svix-signature']) {
|
|
238
|
+
headers['x-hookarmor-original-svix-signature'] = event.headers['svix-signature'];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rawBuffer = Buffer.isBuffer(event.raw_body)
|
|
242
|
+
? event.raw_body
|
|
243
|
+
: Buffer.from(String(event.raw_body || ''), 'utf8');
|
|
244
|
+
|
|
245
|
+
// Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
|
|
246
|
+
if (endpoint.secret) {
|
|
247
|
+
headers = this.signHeaders(event.provider, rawBuffer, headers, endpoint.secret);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Isolated Trust Domain mode: vouch only for events whose signature was verified at ingress
|
|
251
|
+
if (this.signingSecret && event.verified) {
|
|
252
|
+
const freshTs = Math.floor(Date.now() / 1000);
|
|
253
|
+
const hmac = crypto.createHmac('sha256', this.signingSecret);
|
|
254
|
+
hmac.update(Buffer.from(`${freshTs}.`, 'utf8'));
|
|
255
|
+
hmac.update(rawBuffer);
|
|
256
|
+
const internalSig = hmac.digest('hex');
|
|
257
|
+
headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Remove hop-by-hop and encoding headers
|
|
261
|
+
delete headers['host'];
|
|
262
|
+
delete headers['connection'];
|
|
263
|
+
delete headers['content-length'];
|
|
264
|
+
delete headers['content-encoding'];
|
|
265
|
+
delete headers['transfer-encoding'];
|
|
266
|
+
delete headers['expect'];
|
|
267
|
+
|
|
268
|
+
headers['content-length'] = String(rawBuffer.length);
|
|
269
|
+
headers['x-hookarmor-delivery-id'] = event.id;
|
|
270
|
+
headers['x-hookarmor-attempt'] = String(event.attempts + 1);
|
|
271
|
+
// Strict ISO-8601 UTC timestamp with Z to prevent client-side local timezone parsing skew
|
|
272
|
+
const createdAtStr = String(event.created_at || '');
|
|
273
|
+
const originalTimestamp = createdAtStr.includes('T')
|
|
274
|
+
? (createdAtStr.endsWith('Z') ? createdAtStr : `${createdAtStr}Z`)
|
|
275
|
+
: (createdAtStr ? `${createdAtStr.replace(' ', 'T')}Z` : new Date().toISOString());
|
|
276
|
+
headers['x-hookarmor-original-timestamp'] = originalTimestamp;
|
|
277
|
+
const isReplay = replay || event.attempts > 0;
|
|
278
|
+
headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
|
|
279
|
+
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
let settled = false;
|
|
282
|
+
|
|
283
|
+
// Single exit point: every outcome records exactly one attempt and resolves exactly once
|
|
284
|
+
const finish = async ({ statusCode = 0, responseBody = '', errorMessage = '' }) => {
|
|
285
|
+
if (settled) return;
|
|
286
|
+
settled = true;
|
|
287
|
+
|
|
288
|
+
const latencyMs = Date.now() - startTime;
|
|
289
|
+
const isSuccess = statusCode >= 200 && statusCode < 300;
|
|
290
|
+
|
|
291
|
+
let nextRetryAt = null;
|
|
292
|
+
if (!isSuccess && endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
|
|
293
|
+
nextRetryAt = this.calculateNextRetry(event.attempts);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const message = isSuccess ? '' : (errorMessage || `Destination returned HTTP ${statusCode}`);
|
|
297
|
+
try {
|
|
298
|
+
this.storage.recordAttempt({
|
|
299
|
+
eventId: event.id,
|
|
300
|
+
statusCode,
|
|
301
|
+
responseBody,
|
|
302
|
+
errorMessage: message,
|
|
303
|
+
latencyMs,
|
|
304
|
+
nextRetryAt
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error(`[Dispatcher] Failed to record attempt for ${event.id}:`, err.message);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = {
|
|
311
|
+
eventId: event.id,
|
|
312
|
+
endpointId: endpoint.id,
|
|
313
|
+
success: isSuccess,
|
|
314
|
+
statusCode,
|
|
315
|
+
latencyMs,
|
|
316
|
+
responseBody,
|
|
317
|
+
errorMessage: message || undefined,
|
|
318
|
+
nextRetryAt
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
this.emit('delivery', result);
|
|
322
|
+
|
|
323
|
+
if (!isSuccess) {
|
|
324
|
+
// Alert on initial failure and on final exhaustion to prevent notification flood (§6.6)
|
|
325
|
+
const isFirstFailure = (event.attempts === 0);
|
|
326
|
+
const isExhausted = !nextRetryAt;
|
|
327
|
+
if (isFirstFailure || isExhausted) {
|
|
328
|
+
await Alerter.sendAlert(endpoint.alert_webhook_url, {
|
|
329
|
+
event,
|
|
330
|
+
endpoint,
|
|
331
|
+
attempt: { statusCode, errorMessage: message, latencyMs, isExhausted }
|
|
332
|
+
}, { strictSSRF: this.strictSSRF });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
resolve(result);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const check = checkUrl(targetUrl, this.strictSSRF);
|
|
341
|
+
if (!check.ok) {
|
|
342
|
+
finish({ errorMessage: `Delivery blocked: ${check.reason}` });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const url = new URL(targetUrl);
|
|
347
|
+
const isHttps = url.protocol === 'https:';
|
|
348
|
+
const client = isHttps ? https : http;
|
|
349
|
+
|
|
350
|
+
let gotResponse = false;
|
|
351
|
+
const req = client.request(url, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers,
|
|
354
|
+
timeout: this.defaultTimeoutMs,
|
|
355
|
+
lookup: guardedLookup(this.strictSSRF)
|
|
356
|
+
}, (res) => {
|
|
357
|
+
gotResponse = true;
|
|
358
|
+
let responseBody = '';
|
|
359
|
+
res.setEncoding('utf8');
|
|
360
|
+
res.on('data', chunk => {
|
|
361
|
+
if (responseBody.length < 4000) {
|
|
362
|
+
responseBody += chunk;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
res.on('end', () => finish({ statusCode: res.statusCode || 0, responseBody }));
|
|
366
|
+
res.on('error', (err) => finish({ errorMessage: err.message }));
|
|
367
|
+
res.on('aborted', () => finish({ errorMessage: 'Response aborted by destination' }));
|
|
368
|
+
res.on('close', () => {
|
|
369
|
+
if (!res.complete) finish({ errorMessage: 'Response closed before it completed' });
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
req.on('timeout', () => {
|
|
374
|
+
req.destroy(new Error(`Connection timed out after ${this.defaultTimeoutMs}ms`));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
req.on('error', (err) => finish({ errorMessage: err.message }));
|
|
378
|
+
req.on('close', () => {
|
|
379
|
+
if (!gotResponse) finish({ errorMessage: 'Connection closed before a response was received' });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
req.write(rawBuffer);
|
|
383
|
+
req.end();
|
|
384
|
+
} catch (err) {
|
|
385
|
+
finish({ errorMessage: err.message });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async replayEvent(eventId) {
|
|
391
|
+
const event = this.storage.getEvent(eventId);
|
|
392
|
+
if (!event) {
|
|
393
|
+
const err = new Error(`Event not found: ${eventId}`);
|
|
394
|
+
err.code = 'NOT_FOUND';
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
const endpoint = this.storage.getEndpoint(event.endpoint_id);
|
|
398
|
+
if (!endpoint) {
|
|
399
|
+
const err = new Error(`Endpoint not found: ${event.endpoint_id}`);
|
|
400
|
+
err.code = 'NOT_FOUND';
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
if (!this.storage.claimEventForManualReplay(eventId)) {
|
|
404
|
+
const err = new Error(`Event ${eventId} is already being delivered`);
|
|
405
|
+
err.code = 'IN_FLIGHT';
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return this.dispatch(event, endpoint, { replay: true });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Claims every failed event (including ones that exhausted automatic retries) and delivers
|
|
413
|
+
// them in the background through the per-endpoint concurrency limiter.
|
|
414
|
+
replayAllFailed(endpointId = null, batchSize = 500) {
|
|
415
|
+
const failedEvents = this.storage.getFailedEventsForReplay(endpointId, batchSize);
|
|
416
|
+
const claimed = [];
|
|
417
|
+
for (const event of failedEvents) {
|
|
418
|
+
const endpoint = this.storage.getEndpoint(event.endpoint_id);
|
|
419
|
+
if (!endpoint || !this.storage.claimEventForRetry(event.id)) continue;
|
|
420
|
+
claimed.push(event.id);
|
|
421
|
+
this.dispatch(event, endpoint, { replay: true }).catch((err) => {
|
|
422
|
+
console.error(`[Dispatcher] Replay error for ${event.id}:`, err.message);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { replayedCount: claimed.length, eventIds: claimed, remaining: this.storage.countFailed(endpointId) };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
module.exports = Dispatcher;
|