hookarmor 1.0.3 → 1.1.0
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/README.md +303 -260
- package/bin/hookarmor.js +168 -133
- package/package.json +52 -52
- package/src/alerter.js +72 -63
- package/src/dispatcher.js +409 -356
- package/src/netguard.js +117 -0
- package/src/public/index.html +96 -40
- package/src/server.js +544 -393
- package/src/storage.js +337 -266
- package/src/worker.js +72 -62
package/src/server.js
CHANGED
|
@@ -1,393 +1,544 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
|
-
const cors = require('cors');
|
|
3
|
-
const
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const http = require('http');
|
|
7
|
-
const { WebSocketServer, WebSocket } = require('ws');
|
|
8
|
-
|
|
9
|
-
const Storage = require('./storage');
|
|
10
|
-
const Dispatcher = require('./dispatcher');
|
|
11
|
-
const RetryWorker = require('./worker');
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
app.get('/', (req, res) => {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
8
|
+
|
|
9
|
+
const Storage = require('./storage');
|
|
10
|
+
const Dispatcher = require('./dispatcher');
|
|
11
|
+
const RetryWorker = require('./worker');
|
|
12
|
+
const { checkUrl } = require('./netguard');
|
|
13
|
+
|
|
14
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
15
|
+
const ENDPOINT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
16
|
+
|
|
17
|
+
function envFlag(name) {
|
|
18
|
+
const v = process.env[name];
|
|
19
|
+
if (v === undefined || v === '') return undefined;
|
|
20
|
+
return v === 'true' || v === '1';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hostnameOf(hostHeader = '') {
|
|
24
|
+
const h = String(hostHeader).toLowerCase();
|
|
25
|
+
if (h.startsWith('[')) return h.slice(0, h.indexOf(']') + 1);
|
|
26
|
+
return h.split(':')[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clampInt(value, fallback, min, max) {
|
|
30
|
+
const n = parseInt(value, 10);
|
|
31
|
+
if (!Number.isFinite(n)) return fallback;
|
|
32
|
+
return Math.min(Math.max(n, min), max);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fixed-window in-memory rate limiter keyed by client IP
|
|
36
|
+
function createRateLimiter({ limit, windowMs }) {
|
|
37
|
+
const hits = new Map();
|
|
38
|
+
return (key) => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (hits.size > 10000) {
|
|
41
|
+
for (const [k, v] of hits) if (v.resetAt <= now) hits.delete(k);
|
|
42
|
+
}
|
|
43
|
+
const entry = hits.get(key);
|
|
44
|
+
if (!entry || entry.resetAt <= now) {
|
|
45
|
+
hits.set(key, { count: 1, resetAt: now + windowMs });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
entry.count++;
|
|
49
|
+
return entry.count <= limit;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function publicEndpoint(ep) {
|
|
54
|
+
if (!ep) return ep;
|
|
55
|
+
const { secret, ...rest } = ep;
|
|
56
|
+
return { ...rest, has_secret: Boolean(secret) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createServer(options = {}) {
|
|
60
|
+
const isProduction = options.production !== undefined
|
|
61
|
+
? options.production
|
|
62
|
+
: Boolean(process.env.NODE_ENV === 'production' || process.env.RAILWAY_ENVIRONMENT || process.env.RENDER);
|
|
63
|
+
const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
|
|
64
|
+
const isDemoMode = options.demoMode !== undefined
|
|
65
|
+
? options.demoMode
|
|
66
|
+
: (process.env.HOOKARMOR_DEMO_MODE === 'true');
|
|
67
|
+
const allowNoAuth = options.allowNoAuth !== undefined ? options.allowNoAuth : envFlag('HOOKARMOR_ALLOW_NO_AUTH') === true;
|
|
68
|
+
// Strict outbound address checks default on in production (block loopback/private networks)
|
|
69
|
+
const strictSSRF = options.strictSSRF !== undefined
|
|
70
|
+
? options.strictSSRF
|
|
71
|
+
: (envFlag('HOOKARMOR_STRICT_SSRF') !== undefined ? envFlag('HOOKARMOR_STRICT_SSRF') : isProduction);
|
|
72
|
+
const mockEnabled = options.enableMock !== undefined
|
|
73
|
+
? options.enableMock
|
|
74
|
+
: (envFlag('HOOKARMOR_ENABLE_MOCK') !== undefined ? envFlag('HOOKARMOR_ENABLE_MOCK') : !isProduction);
|
|
75
|
+
const corsOrigins = options.corsOrigins || (process.env.HOOKARMOR_CORS_ORIGINS
|
|
76
|
+
? process.env.HOOKARMOR_CORS_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
|
|
77
|
+
: []);
|
|
78
|
+
const maxBody = options.maxBodySize || process.env.HOOKARMOR_MAX_BODY || '10mb';
|
|
79
|
+
const retentionDays = options.retentionDays !== undefined
|
|
80
|
+
? options.retentionDays
|
|
81
|
+
: clampInt(process.env.HOOKARMOR_RETENTION_DAYS, 0, 0, 3650);
|
|
82
|
+
|
|
83
|
+
if (isProduction && !apiKey && !allowNoAuth) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'HOOKARMOR_API_KEY is required in production: without it the management API (events, endpoints, replays) is open to anyone. ' +
|
|
86
|
+
'Generate one with `openssl rand -hex 32`. Set HOOKARMOR_ALLOW_NO_AUTH=true only if the instance is not reachable from untrusted networks.'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const app = express();
|
|
91
|
+
const server = http.createServer(app);
|
|
92
|
+
|
|
93
|
+
const trustProxy = process.env.HOOKARMOR_TRUST_PROXY || ((process.env.RAILWAY_ENVIRONMENT || process.env.RENDER) ? '1' : null);
|
|
94
|
+
if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) : trustProxy);
|
|
95
|
+
|
|
96
|
+
const storage = new Storage(options.dbPath);
|
|
97
|
+
const recovered = storage.recoverInterruptedDeliveries();
|
|
98
|
+
if (recovered > 0) {
|
|
99
|
+
console.warn(`[HookArmor] Re-queued ${recovered} event(s) that were mid-delivery when the process last stopped.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dispatcher = new Dispatcher(storage, {
|
|
103
|
+
defaultConcurrency: options.defaultConcurrency || 5,
|
|
104
|
+
defaultTimeoutMs: options.defaultTimeoutMs || 25000,
|
|
105
|
+
strictSSRF,
|
|
106
|
+
signingSecret: options.signingSecret || process.env.HOOKARMOR_SIGNING_SECRET || null
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const worker = new RetryWorker(storage, dispatcher, {
|
|
110
|
+
intervalMs: options.retryIntervalMs || 5000,
|
|
111
|
+
retentionDays
|
|
112
|
+
});
|
|
113
|
+
if (options.autoStartWorker !== false) {
|
|
114
|
+
worker.start();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
server.on('close', () => {
|
|
118
|
+
worker.stop();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function tokenMatches(token) {
|
|
122
|
+
if (!apiKey || !token) return false;
|
|
123
|
+
// Hash both sides so the comparison is constant-time regardless of length
|
|
124
|
+
const a = crypto.createHash('sha256').update(String(token)).digest();
|
|
125
|
+
const b = crypto.createHash('sha256').update(String(apiKey)).digest();
|
|
126
|
+
return crypto.timingSafeEqual(a, b);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const authFailureLimiter = createRateLimiter({ limit: options.authFailureLimit || 30, windowMs: 10 * 60 * 1000 });
|
|
130
|
+
const waitlistLimiter = createRateLimiter({ limit: options.waitlistRateLimit || 5, windowMs: 10 * 60 * 1000 });
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------- live WebSocket feed
|
|
133
|
+
const wss = new WebSocketServer({
|
|
134
|
+
server,
|
|
135
|
+
path: '/ws',
|
|
136
|
+
maxPayload: 4096,
|
|
137
|
+
// Without an API key the feed is only served to localhost (blocks DNS-rebinding pages)
|
|
138
|
+
verifyClient: (info) => Boolean(apiKey) || LOCAL_HOSTNAMES.has(hostnameOf(info.req.headers.host))
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
wss.on('connection', (socket) => {
|
|
142
|
+
socket.isAuthed = !apiKey || isDemoMode;
|
|
143
|
+
let authTimer = null;
|
|
144
|
+
if (!socket.isAuthed) {
|
|
145
|
+
authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 15000);
|
|
146
|
+
if (authTimer.unref) authTimer.unref();
|
|
147
|
+
}
|
|
148
|
+
// Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
|
|
149
|
+
socket.on('message', (raw) => {
|
|
150
|
+
try {
|
|
151
|
+
const msg = JSON.parse(String(raw));
|
|
152
|
+
if (msg.type === 'auth') {
|
|
153
|
+
if (tokenMatches(msg.token)) {
|
|
154
|
+
socket.isAuthed = true;
|
|
155
|
+
if (authTimer) clearTimeout(authTimer);
|
|
156
|
+
socket.send(JSON.stringify({ type: 'auth:ok' }));
|
|
157
|
+
} else {
|
|
158
|
+
socket.close(4001, 'Invalid API key');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function broadcast(type, data) {
|
|
166
|
+
const payload = JSON.stringify({ type, data });
|
|
167
|
+
wss.clients.forEach(client => {
|
|
168
|
+
if (client.readyState === WebSocket.OPEN && client.isAuthed) {
|
|
169
|
+
client.send(payload);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Hook dispatcher events to WebSocket broadcasts
|
|
175
|
+
dispatcher.on('delivery', (res) => {
|
|
176
|
+
broadcast('delivery', res);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// CORS is opt-in: the dashboard and landing page are same-origin and need none
|
|
180
|
+
if (corsOrigins.length > 0) {
|
|
181
|
+
app.use('/api', cors({ origin: corsOrigins }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Serve static UI assets
|
|
185
|
+
app.use('/static', express.static(path.join(__dirname, 'public')));
|
|
186
|
+
|
|
187
|
+
// Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
|
|
188
|
+
app.post('/in/:endpointId', express.raw({ type: '*/*', limit: maxBody }), (req, res) => {
|
|
189
|
+
const endpointId = req.params.endpointId;
|
|
190
|
+
const endpoint = storage.getEndpoint(endpointId);
|
|
191
|
+
|
|
192
|
+
if (!endpoint) {
|
|
193
|
+
return res.status(404).json({ error: 'HookArmor endpoint not found' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rawBodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
|
|
197
|
+
const rawBody = rawBodyBuffer.toString('utf8');
|
|
198
|
+
const headers = { ...req.headers };
|
|
199
|
+
|
|
200
|
+
// Detect provider & idempotency key
|
|
201
|
+
let provider = 'generic';
|
|
202
|
+
let eventType = null;
|
|
203
|
+
let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
|
|
204
|
+
|
|
205
|
+
if (headers['stripe-signature']) {
|
|
206
|
+
provider = 'stripe';
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(rawBody);
|
|
209
|
+
eventType = parsed.type;
|
|
210
|
+
if (parsed.id) idempotencyKey = parsed.id;
|
|
211
|
+
} catch (e) {}
|
|
212
|
+
} else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
|
|
213
|
+
provider = 'shopify';
|
|
214
|
+
eventType = headers['x-shopify-topic'];
|
|
215
|
+
if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
|
|
216
|
+
} else if (headers['x-github-event']) {
|
|
217
|
+
provider = 'github';
|
|
218
|
+
eventType = headers['x-github-event'];
|
|
219
|
+
if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
|
|
220
|
+
} else if (headers['clerk-signature'] || headers['svix-id']) {
|
|
221
|
+
provider = 'clerk/svix';
|
|
222
|
+
if (headers['svix-id']) idempotencyKey = headers['svix-id'];
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(rawBody);
|
|
225
|
+
eventType = parsed.type;
|
|
226
|
+
} catch (e) {}
|
|
227
|
+
} else {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(rawBody);
|
|
230
|
+
eventType = parsed.event || parsed.type || parsed.action || null;
|
|
231
|
+
if (parsed.id) idempotencyKey = String(parsed.id);
|
|
232
|
+
} catch (e) {}
|
|
233
|
+
}
|
|
234
|
+
if (typeof eventType !== 'string') eventType = eventType == null ? null : String(eventType);
|
|
235
|
+
if (eventType) eventType = eventType.slice(0, 200);
|
|
236
|
+
if (idempotencyKey) idempotencyKey = String(idempotencyKey).slice(0, 255);
|
|
237
|
+
|
|
238
|
+
// 0. Verify signature on ingress if endpoint secret is configured
|
|
239
|
+
let verified = false;
|
|
240
|
+
if (endpoint.secret) {
|
|
241
|
+
const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
|
|
242
|
+
if (!verification.valid) {
|
|
243
|
+
return res.status(400).json({
|
|
244
|
+
error: 'Webhook signature verification failed',
|
|
245
|
+
provider,
|
|
246
|
+
reason: verification.reason
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
verified = true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let savedEvent;
|
|
253
|
+
try {
|
|
254
|
+
// Deduplication: if HookArmor already holds this event, ack without creating a second delivery
|
|
255
|
+
if (idempotencyKey) {
|
|
256
|
+
const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
|
|
257
|
+
if (existing) {
|
|
258
|
+
return res.status(200).json({
|
|
259
|
+
received: true,
|
|
260
|
+
hookarmor_id: existing.id,
|
|
261
|
+
status: 'deduplicated',
|
|
262
|
+
message: `Idempotency key already received (current status: ${existing.status}). Suppressed duplicate delivery.`
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const eventId = `evt_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
|
|
268
|
+
|
|
269
|
+
// 1. Durably save event in SQLite, already claimed for its first delivery
|
|
270
|
+
savedEvent = storage.saveEvent({
|
|
271
|
+
id: eventId,
|
|
272
|
+
endpointId: endpoint.id,
|
|
273
|
+
idempotencyKey,
|
|
274
|
+
provider,
|
|
275
|
+
eventType,
|
|
276
|
+
headers,
|
|
277
|
+
rawBody,
|
|
278
|
+
status: 'replaying',
|
|
279
|
+
verified
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// Not persisted: return an error so the provider keeps its own retry schedule
|
|
283
|
+
console.error('[Ingress] Failed to persist event:', err.message);
|
|
284
|
+
return res.status(503).json({ error: 'HookArmor could not persist the event; please retry' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
broadcast('event:received', {
|
|
288
|
+
id: savedEvent.id,
|
|
289
|
+
endpointId: endpoint.id,
|
|
290
|
+
provider,
|
|
291
|
+
eventType,
|
|
292
|
+
createdAt: savedEvent.created_at
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
|
|
296
|
+
res.status(200).json({
|
|
297
|
+
received: true,
|
|
298
|
+
hookarmor_id: savedEvent.id,
|
|
299
|
+
status: 'buffered'
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// 3. Dispatch to target destination asynchronously
|
|
303
|
+
setImmediate(() => {
|
|
304
|
+
dispatcher.dispatch(savedEvent, endpoint).catch((err) => {
|
|
305
|
+
console.error(`[Ingress] Dispatch error for ${savedEvent.id}:`, err);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// REST API: JSON Body Parser for Dashboard / Management
|
|
311
|
+
app.use(express.json({ limit: '100kb' }));
|
|
312
|
+
|
|
313
|
+
app.use('/api', (req, res, next) => {
|
|
314
|
+
// Public waitlist signup for the landing page (rate limited)
|
|
315
|
+
if (req.path === '/waitlist' && req.method === 'POST') {
|
|
316
|
+
if (!waitlistLimiter(req.ip)) return res.status(429).json({ error: 'Too many signups from this address; try again later' });
|
|
317
|
+
return next();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!apiKey) {
|
|
321
|
+
if (!isDemoMode) {
|
|
322
|
+
// Open local-dev mode: only answer to localhost names, so a web page cannot reach this
|
|
323
|
+
// API through DNS rebinding
|
|
324
|
+
if (!LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
|
|
325
|
+
return res.status(403).json({ error: 'Without HOOKARMOR_API_KEY the management API is only served on localhost' });
|
|
326
|
+
}
|
|
327
|
+
return next();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Demo mode (public marketing sandbox): read-only access without a key
|
|
332
|
+
if (isDemoMode) {
|
|
333
|
+
const isPublicDemoRead = req.method === 'GET' &&
|
|
334
|
+
(req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path));
|
|
335
|
+
if (isPublicDemoRead) return next();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const authHeader = req.headers['authorization'] || '';
|
|
339
|
+
const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
|
|
340
|
+
if (!tokenMatches(token)) {
|
|
341
|
+
// Only wrong keys count towards the lockout, not anonymous page loads
|
|
342
|
+
if (token && !authFailureLimiter(req.ip)) {
|
|
343
|
+
return res.status(429).json({ error: 'Too many failed authentication attempts; try again later' });
|
|
344
|
+
}
|
|
345
|
+
return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
|
|
346
|
+
}
|
|
347
|
+
next();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// State-changing calls must be JSON. Cross-site forms cannot send that content type without a
|
|
351
|
+
// CORS preflight, which this server does not grant.
|
|
352
|
+
app.use('/api', (req, res, next) => {
|
|
353
|
+
const contentType = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
354
|
+
const isJson = contentType === 'application/json' || contentType.endsWith('+json');
|
|
355
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS' && !isJson) {
|
|
356
|
+
return res.status(415).json({ error: 'Content-Type must be application/json' });
|
|
357
|
+
}
|
|
358
|
+
next();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Get Stats
|
|
362
|
+
app.get('/api/stats', (req, res) => {
|
|
363
|
+
res.json(storage.getStats());
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// List Endpoints (secrets are never returned)
|
|
367
|
+
app.get('/api/endpoints', (req, res) => {
|
|
368
|
+
res.json(storage.listEndpoints().map(publicEndpoint));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Create or Update Endpoint
|
|
372
|
+
app.post('/api/endpoints', (req, res) => {
|
|
373
|
+
const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body || {};
|
|
374
|
+
if (!id || !name || !targetUrl) {
|
|
375
|
+
return res.status(400).json({ error: 'id, name, and targetUrl are required' });
|
|
376
|
+
}
|
|
377
|
+
if (typeof id !== 'string' || !ENDPOINT_ID_PATTERN.test(id)) {
|
|
378
|
+
return res.status(400).json({ error: 'id must be 1-64 characters: letters, digits, "-" or "_"' });
|
|
379
|
+
}
|
|
380
|
+
if (typeof name !== 'string' || name.length > 200) {
|
|
381
|
+
return res.status(400).json({ error: 'name must be a string of at most 200 characters' });
|
|
382
|
+
}
|
|
383
|
+
if (secret !== undefined && (typeof secret !== 'string' || secret.length > 500)) {
|
|
384
|
+
return res.status(400).json({ error: 'secret must be a string of at most 500 characters' });
|
|
385
|
+
}
|
|
386
|
+
const targetCheck = checkUrl(String(targetUrl), strictSSRF);
|
|
387
|
+
if (!targetCheck.ok) {
|
|
388
|
+
return res.status(400).json({ error: `targetUrl rejected: ${targetCheck.reason} (cloud metadata${strictSSRF ? ', loopback and private network' : ''} addresses are blocked)` });
|
|
389
|
+
}
|
|
390
|
+
if (alertWebhookUrl) {
|
|
391
|
+
const alertCheck = checkUrl(String(alertWebhookUrl), strictSSRF);
|
|
392
|
+
if (!alertCheck.ok) {
|
|
393
|
+
return res.status(400).json({ error: `alertWebhookUrl rejected: ${alertCheck.reason}` });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const endpoint = storage.createEndpoint({
|
|
397
|
+
id,
|
|
398
|
+
name,
|
|
399
|
+
targetUrl: String(targetUrl),
|
|
400
|
+
secret,
|
|
401
|
+
alertWebhookUrl: alertWebhookUrl === undefined ? undefined : String(alertWebhookUrl || ''),
|
|
402
|
+
autoRetry: autoRetry !== undefined ? autoRetry : 1,
|
|
403
|
+
maxRetries: clampInt(maxRetries, 5, 1, 50),
|
|
404
|
+
concurrencyLimit: clampInt(concurrencyLimit || concurrency_limit, 5, 1, 100)
|
|
405
|
+
});
|
|
406
|
+
res.json(publicEndpoint(endpoint));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// List Events
|
|
410
|
+
app.get('/api/events', (req, res) => {
|
|
411
|
+
const { endpointId, status, limit, offset } = req.query;
|
|
412
|
+
const events = storage.listEvents({
|
|
413
|
+
endpointId: typeof endpointId === 'string' ? endpointId : null,
|
|
414
|
+
status: typeof status === 'string' ? status : null,
|
|
415
|
+
limit: clampInt(limit, 50, 1, 500),
|
|
416
|
+
offset: clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
|
417
|
+
});
|
|
418
|
+
res.json(events);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Get single event with attempts
|
|
422
|
+
app.get('/api/events/:id', (req, res) => {
|
|
423
|
+
const event = storage.getEvent(req.params.id);
|
|
424
|
+
if (!event) return res.status(404).json({ error: 'Event not found' });
|
|
425
|
+
const attempts = storage.getAttempts(req.params.id);
|
|
426
|
+
res.json({ ...event, attempts });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Replay a specific event
|
|
430
|
+
app.post('/api/events/:id/replay', async (req, res) => {
|
|
431
|
+
try {
|
|
432
|
+
const result = await dispatcher.replayEvent(req.params.id);
|
|
433
|
+
res.json(result);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
const status = err.code === 'NOT_FOUND' ? 404 : (err.code === 'IN_FLIGHT' ? 409 : 500);
|
|
436
|
+
res.status(status).json({ error: err.message });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Replay all failed events, including ones that exhausted automatic retries.
|
|
441
|
+
// Returns immediately; deliveries run in the background through the concurrency limiter.
|
|
442
|
+
app.post('/api/events/replay-all', (req, res) => {
|
|
443
|
+
try {
|
|
444
|
+
const { endpointId } = req.body || {};
|
|
445
|
+
const result = dispatcher.replayAllFailed(typeof endpointId === 'string' ? endpointId : null);
|
|
446
|
+
res.json(result);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
res.status(500).json({ error: err.message });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Waitlist Registration
|
|
453
|
+
app.post('/api/waitlist', (req, res) => {
|
|
454
|
+
const { email, source } = req.body || {};
|
|
455
|
+
const cleanEmail = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
|
456
|
+
if (!cleanEmail || cleanEmail.length > 254 || !/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(cleanEmail)) {
|
|
457
|
+
return res.status(400).json({ error: 'Valid email required' });
|
|
458
|
+
}
|
|
459
|
+
const cleanSource = typeof source === 'string' ? source.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 50) || 'landing_page' : 'landing_page';
|
|
460
|
+
const result = storage.addWaitlist(cleanEmail, cleanSource);
|
|
461
|
+
res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
app.get('/api/waitlist', (req, res) => {
|
|
465
|
+
res.json(storage.listWaitlist());
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Mock Target Receiver (local simulation only; not mounted in production unless HOOKARMOR_ENABLE_MOCK=true)
|
|
469
|
+
if (mockEnabled) {
|
|
470
|
+
let mockTargetBehavior = {
|
|
471
|
+
statusCode: 200,
|
|
472
|
+
delayMs: 15,
|
|
473
|
+
responseBody: { status: 'mock_processed' }
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
app.post('/mock/target', (req, res) => {
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
|
|
479
|
+
}, mockTargetBehavior.delayMs);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
app.post('/mock/config', (req, res) => {
|
|
483
|
+
const body = req.body || {};
|
|
484
|
+
mockTargetBehavior = {
|
|
485
|
+
statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
|
|
486
|
+
delayMs: clampInt(body.delayMs, mockTargetBehavior.delayMs, 0, 30000),
|
|
487
|
+
responseBody: body.responseBody !== undefined ? body.responseBody : mockTargetBehavior.responseBody
|
|
488
|
+
};
|
|
489
|
+
res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.get('/mock/config', (req, res) => {
|
|
493
|
+
res.json(mockTargetBehavior);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// SEO & Web Crawler Discovery
|
|
498
|
+
app.get('/robots.txt', (req, res) => {
|
|
499
|
+
res.type('text/plain');
|
|
500
|
+
res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
app.get('/sitemap.xml', (req, res) => {
|
|
504
|
+
res.type('application/xml');
|
|
505
|
+
res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
app.get('/og-preview.png', (req, res) => {
|
|
509
|
+
res.type('image/png');
|
|
510
|
+
res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Root Route: Dashboard by default on self-hosted instances; Landing page only if explicitly enabled (§6.13)
|
|
514
|
+
app.get('/', (req, res) => {
|
|
515
|
+
if (process.env.HOOKARMOR_SERVE_LANDING === 'true') {
|
|
516
|
+
return res.sendFile('landing.html', { root: path.join(__dirname, 'public') });
|
|
517
|
+
}
|
|
518
|
+
res.sendFile('index.html', { root: path.join(__dirname, 'public') });
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Interactive Dashboard Routes
|
|
522
|
+
app.get(['/dashboard', '/app'], (req, res) => {
|
|
523
|
+
res.sendFile('index.html', { root: path.join(__dirname, 'public') });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Engineering Blog Routes
|
|
527
|
+
app.get('/blog', (req, res) => {
|
|
528
|
+
res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
app.get('/blog/:slug', (req, res) => {
|
|
532
|
+
const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
|
|
533
|
+
const fileName = `${slug}.html`;
|
|
534
|
+
const filePath = path.join(__dirname, 'public', 'blog', fileName);
|
|
535
|
+
if (fs.existsSync(filePath)) {
|
|
536
|
+
return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
|
|
537
|
+
}
|
|
538
|
+
res.redirect('/blog');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
module.exports = { createServer };
|