hookarmor 1.0.1 โ 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.
- package/README.md +71 -6
- package/bin/hookarmor.js +2 -2
- package/package.json +1 -1
- package/src/dispatcher.js +20 -1
- package/src/server.js +34 -11
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> **Zero-loss Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and modern B2B SaaS.**
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[]()
|
|
7
|
+
[]()
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -102,7 +102,7 @@ npx hookarmor replay evt_1758513516086_m8r0e7
|
|
|
102
102
|
|
|
103
103
|
## ๐ Verification Test Suite
|
|
104
104
|
|
|
105
|
-
HookArmor includes
|
|
105
|
+
HookArmor includes an 8-scenario automated verification test suite covering edge cases, replay re-signing, and pool protection:
|
|
106
106
|
```bash
|
|
107
107
|
npm test
|
|
108
108
|
```
|
|
@@ -120,7 +120,8 @@ Test 2: Testing Ingress Signature Verification (Security Boundary)...
|
|
|
120
120
|
Test 3: Testing 5-Minute Expiration Defeat (Fresh Outbound Re-Signing)...
|
|
121
121
|
โ
HookArmor defeated the 5-minute Stripe expiration trap:
|
|
122
122
|
Stored in DB: t=1600000000 (Expired 5+ years ago)
|
|
123
|
-
Re-signed on replay: t=
|
|
123
|
+
Re-signed on replay: t=1790970445 (Current) -> stripe.webhooks.constructEvent succeeds!
|
|
124
|
+
Provenance verified: is-replay=true, orig-sig preserved.
|
|
124
125
|
|
|
125
126
|
Test 4: Simulating Downstream Failure (500) -> Dead-Letter Queue...
|
|
126
127
|
โ
Event safely quarantined in Dead-Letter Queue with HTTP 500.
|
|
@@ -135,7 +136,12 @@ Test 6: Testing Concurrency Limiting (Pool Protection)...
|
|
|
135
136
|
Test 7: Testing Safe Idempotency Key Deduplication...
|
|
136
137
|
โ
Duplicate event intercepted and suppressed cleanly.
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
Test 8: Testing API Key Authentication & Route Lockdown...
|
|
140
|
+
โ
Unauthenticated reads and replays rejected with 401.
|
|
141
|
+
โ
Bearer token and x-api-key headers validated with 200.
|
|
142
|
+
โ
Ingress gateway remains open for external webhook providers.
|
|
143
|
+
|
|
144
|
+
๐ ALL 8 HARDENED HOOKARMOR VERIFICATION TESTS PASSED PERFECTLY!
|
|
139
145
|
```
|
|
140
146
|
|
|
141
147
|
---
|
|
@@ -153,6 +159,63 @@ HookArmor solves this automatically:
|
|
|
153
159
|
|
|
154
160
|
---
|
|
155
161
|
|
|
162
|
+
## ๐ก๏ธ Provenance Headers & Out-of-Order Replay Protection
|
|
163
|
+
|
|
164
|
+
When replaying dead-letter events hours or days later, applying payloads out of order can accidentally clobber newer database state (e.g., an older `customer.subscription.updated` event overwriting a newer cancellation).
|
|
165
|
+
|
|
166
|
+
To protect downstream handlers against state drift, HookArmor injects explicit provenance headers on every forward and replay:
|
|
167
|
+
|
|
168
|
+
* `x-hookarmor-delivery-id`: Unique HookArmor event delivery ID.
|
|
169
|
+
* `x-hookarmor-attempt`: Current delivery attempt number (e.g., `1` on initial, `2+` on retries).
|
|
170
|
+
* `x-hookarmor-original-timestamp`: The exact ISO-8601 timestamp when the webhook originally arrived at the ingress edge.
|
|
171
|
+
* `x-hookarmor-is-replay`: Set to `'true'` during automated retries and manual dashboard replays (`'false'` on initial delivery).
|
|
172
|
+
* `x-hookarmor-original-stripe-signature`: Preserves the authentic original Stripe signature for audit logs and forensic verification.
|
|
173
|
+
|
|
174
|
+
**Recommended Downstream Handler Pattern:**
|
|
175
|
+
```javascript
|
|
176
|
+
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
|
|
177
|
+
// Standard verification passes 100% of the time
|
|
178
|
+
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret);
|
|
179
|
+
|
|
180
|
+
// If this is a replay, verify you are not clobbering newer database state:
|
|
181
|
+
if (req.headers['x-hookarmor-is-replay'] === 'true') {
|
|
182
|
+
const originalTime = new Date(req.headers['x-hookarmor-original-timestamp']).getTime();
|
|
183
|
+
// Fetch latest object from Stripe API or check if local DB already has a newer updated_at timestamp
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
res.json({ received: true });
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## ๐๏ธ Dual Trust Boundaries: Transparent vs. Isolated Mode
|
|
193
|
+
|
|
194
|
+
HookArmor supports two forwarding architectures depending on your team's security posture:
|
|
195
|
+
|
|
196
|
+
1. **Mode A: Transparent Proxy (Default ยท Zero Code Changes)**
|
|
197
|
+
* HookArmor re-signs the outbound request using your provider secret with `t=now`.
|
|
198
|
+
* Your existing application code (`stripe.webhooks.constructEvent()`) runs unmodified.
|
|
199
|
+
* Original signatures are preserved in `x-hookarmor-original-stripe-signature`.
|
|
200
|
+
|
|
201
|
+
2. **Mode B: Isolated Trust Boundary (Enterprise Security)**
|
|
202
|
+
* Set the `HOOKARMOR_SIGNING_SECRET` environment variable.
|
|
203
|
+
* HookArmor attaches an isolated `x-hookarmor-signature: t=..., v1=...` HMAC header using your internal secret.
|
|
204
|
+
* Downstream handlers verify HookArmor as the internal sender, maintaining strict separation between external Stripe signatures and internal relay deliveries.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## โ ๏ธ Production Durability: The Early 200 OK Tradeoff
|
|
209
|
+
|
|
210
|
+
HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to protect your ingress latency and prevent providers from marking your endpoint failed during downstream outages.
|
|
211
|
+
|
|
212
|
+
**What this means for production operators:**
|
|
213
|
+
* Once HookArmor returns `200 OK`, Stripe considers the webhook delivered and **halts its external retry backup**.
|
|
214
|
+
* HookArmor's embedded SQLite database assumes **sole custody** of that event.
|
|
215
|
+
* **Persistent storage is mandatory:** When deploying via Docker, you must mount the data volume (`-v $(pwd)/data:/app/data`) and ensure host-level backups are active.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
156
219
|
## ๐ฆ Hosted Cloud & Self-Hosting
|
|
157
220
|
|
|
158
221
|
| Feature | Self-Hosted (MIT) | Hosted Cloud Starter ($29/mo) | Hosted Cloud Pro ($79/mo) |
|
|
@@ -185,7 +248,9 @@ HOOKARMOR_API_KEY=ha_sec_your_secure_random_key_here
|
|
|
185
248
|
```
|
|
186
249
|
|
|
187
250
|
When `HOOKARMOR_API_KEY` is present:
|
|
188
|
-
*
|
|
251
|
+
* All management and inspection endpoints (`/api/stats`, `/api/endpoints`, `/api/events`, `/api/events/:id/replay`, `/api/events/replay-all`) strictly reject unauthenticated requests with `401 Unauthorized` and require `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
|
|
252
|
+
* Ingress webhook receiving (`/in/:endpointId`) and public waitlist signups remain open for incoming traffic.
|
|
253
|
+
* Public read-only demo access can only be enabled if explicitly running with `HOOKARMOR_DEMO_MODE=true` (for isolated marketing sandboxes).
|
|
189
254
|
* Query parameter authentication (`?api_key=...`) is strictly prohibited to avoid leaking tokens into browser history and proxy access logs.
|
|
190
255
|
* The web dashboard displays an **Admin Login** prompt storing your key only in ephemeral session memory.
|
|
191
256
|
|
package/bin/hookarmor.js
CHANGED
|
@@ -11,12 +11,12 @@ const program = new Command();
|
|
|
11
11
|
program
|
|
12
12
|
.name('hookarmor')
|
|
13
13
|
.description('๐ก๏ธ HookArmor: Webhook Dead-Letter Queue & Replay Sentinel')
|
|
14
|
-
.version('1.0.
|
|
14
|
+
.version('1.0.3');
|
|
15
15
|
|
|
16
16
|
program
|
|
17
17
|
.command('start')
|
|
18
18
|
.description('Start HookArmor server with web dashboard and ingress proxy')
|
|
19
|
-
.option('-p, --port <number>', 'Port to listen on', '4000')
|
|
19
|
+
.option('-p, --port <number>', 'Port to listen on', process.env.PORT || '4000')
|
|
20
20
|
.action((options) => {
|
|
21
21
|
const port = parseInt(options.port, 10);
|
|
22
22
|
const { server, storage } = createServer();
|
package/package.json
CHANGED
package/src/dispatcher.js
CHANGED
|
@@ -164,11 +164,27 @@ class Dispatcher extends EventEmitter {
|
|
|
164
164
|
|
|
165
165
|
let headers = { ...event.headers };
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Preserve original raw signatures before re-signing for audit and forensic trace
|
|
168
|
+
if (event.headers && event.headers['stripe-signature']) {
|
|
169
|
+
headers['x-hookarmor-original-stripe-signature'] = event.headers['stripe-signature'];
|
|
170
|
+
}
|
|
171
|
+
if (event.headers && event.headers['x-shopify-hmac-sha256']) {
|
|
172
|
+
headers['x-hookarmor-original-shopify-hmac'] = event.headers['x-shopify-hmac-sha256'];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
|
|
168
176
|
if (endpoint.secret) {
|
|
169
177
|
headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
// Optional Isolated Trust Domain mode: if HOOKARMOR_SIGNING_SECRET is provided
|
|
181
|
+
const internalSecret = process.env.HOOKARMOR_SIGNING_SECRET || endpoint.internal_secret;
|
|
182
|
+
if (internalSecret) {
|
|
183
|
+
const freshTs = Math.floor(Date.now() / 1000);
|
|
184
|
+
const internalSig = crypto.createHmac('sha256', internalSecret).update(`${freshTs}.${event.raw_body}`).digest('hex');
|
|
185
|
+
headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
172
188
|
// Remove hop-by-hop and encoding headers
|
|
173
189
|
delete headers['host'];
|
|
174
190
|
delete headers['connection'];
|
|
@@ -180,6 +196,9 @@ class Dispatcher extends EventEmitter {
|
|
|
180
196
|
headers['content-length'] = rawBuffer.length;
|
|
181
197
|
headers['x-hookarmor-delivery-id'] = event.id;
|
|
182
198
|
headers['x-hookarmor-attempt'] = String(event.attempts + 1);
|
|
199
|
+
headers['x-hookarmor-original-timestamp'] = event.created_at;
|
|
200
|
+
const isReplay = (event.attempts > 0 || event.status === 'replaying');
|
|
201
|
+
headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
|
|
183
202
|
|
|
184
203
|
return new Promise((resolve) => {
|
|
185
204
|
try {
|
package/src/server.js
CHANGED
|
@@ -10,7 +10,7 @@ const Storage = require('./storage');
|
|
|
10
10
|
const Dispatcher = require('./dispatcher');
|
|
11
11
|
const RetryWorker = require('./worker');
|
|
12
12
|
|
|
13
|
-
function isPrivateOrMetadataUrl(urlString) {
|
|
13
|
+
function isPrivateOrMetadataUrl(urlString, strictSSRF = false) {
|
|
14
14
|
try {
|
|
15
15
|
const parsed = new URL(urlString);
|
|
16
16
|
const hostname = parsed.hostname.toLowerCase();
|
|
@@ -22,6 +22,19 @@ function isPrivateOrMetadataUrl(urlString) {
|
|
|
22
22
|
) {
|
|
23
23
|
return true;
|
|
24
24
|
}
|
|
25
|
+
if (strictSSRF) {
|
|
26
|
+
if (
|
|
27
|
+
hostname === 'localhost' ||
|
|
28
|
+
hostname === '127.0.0.1' ||
|
|
29
|
+
hostname === '::1' ||
|
|
30
|
+
hostname === '0.0.0.0' ||
|
|
31
|
+
hostname.startsWith('10.') ||
|
|
32
|
+
hostname.startsWith('192.168.') ||
|
|
33
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
|
|
34
|
+
) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
25
38
|
return false;
|
|
26
39
|
} catch (e) {
|
|
27
40
|
return true;
|
|
@@ -186,22 +199,32 @@ function createServer(options = {}) {
|
|
|
186
199
|
// REST API: JSON Body Parser for Dashboard / Management
|
|
187
200
|
app.use(express.json());
|
|
188
201
|
|
|
189
|
-
// Management API Authentication Middleware (
|
|
202
|
+
// Management API Authentication Middleware (active when HOOKARMOR_API_KEY is configured)
|
|
190
203
|
const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
|
|
204
|
+
const isDemoMode = options.demoMode !== undefined
|
|
205
|
+
? options.demoMode
|
|
206
|
+
: (process.env.HOOKARMOR_DEMO_MODE === 'true');
|
|
207
|
+
const strictSSRF = options.strictSSRF !== undefined
|
|
208
|
+
? options.strictSSRF
|
|
209
|
+
: (process.env.HOOKARMOR_STRICT_SSRF === 'true');
|
|
210
|
+
|
|
191
211
|
app.use('/api', (req, res, next) => {
|
|
192
212
|
// Keep public waitlist signup open for landing page
|
|
193
213
|
if (req.path === '/waitlist' && req.method === 'POST') return next();
|
|
194
214
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
(req.method === 'GET' && (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path))) ||
|
|
198
|
-
(req.method === 'POST' && (req.path === '/events/replay-all' || /^\/events\/[^\/]+\/replay$/.test(req.path)));
|
|
215
|
+
// If apiKey is NOT configured and demo mode is NOT forced, allow open local dev access
|
|
216
|
+
if (!apiKey && !isDemoMode) return next();
|
|
199
217
|
|
|
200
|
-
|
|
218
|
+
// If explicit demo mode is active (e.g. public marketing demo sandbox), allow read-only & replay simulation
|
|
219
|
+
if (isDemoMode) {
|
|
220
|
+
const isPublicDemo =
|
|
221
|
+
(req.method === 'GET' && (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path))) ||
|
|
222
|
+
(req.method === 'POST' && (req.path === '/events/replay-all' || /^\/events\/[^\/]+\/replay$/.test(req.path)));
|
|
201
223
|
|
|
202
|
-
|
|
224
|
+
if (isPublicDemo) return next();
|
|
225
|
+
}
|
|
203
226
|
|
|
204
|
-
//
|
|
227
|
+
// When apiKey is configured (production mode), strictly require authentication
|
|
205
228
|
const authHeader = req.headers['authorization'] || '';
|
|
206
229
|
const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
|
|
207
230
|
if (!token || token !== apiKey) {
|
|
@@ -226,8 +249,8 @@ function createServer(options = {}) {
|
|
|
226
249
|
if (!id || !name || !targetUrl) {
|
|
227
250
|
return res.status(400).json({ error: 'id, name, and targetUrl are required' });
|
|
228
251
|
}
|
|
229
|
-
if (isPrivateOrMetadataUrl(targetUrl)) {
|
|
230
|
-
return res.status(400).json({ error: 'targetUrl cannot target cloud metadata or
|
|
252
|
+
if (isPrivateOrMetadataUrl(targetUrl, strictSSRF)) {
|
|
253
|
+
return res.status(400).json({ error: 'targetUrl cannot target cloud metadata or private network addresses (SSRF blocked)' });
|
|
231
254
|
}
|
|
232
255
|
const endpoint = storage.createEndpoint({
|
|
233
256
|
id,
|