hookarmor 1.0.2 → 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 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
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
- [![Tests: Passing](https://img.shields.io/badge/Tests-7%20Passing-brightgreen.svg)]()
7
- [![Status: Production Ready](https://img.shields.io/badge/Status-v1.0.0-blueviolet.svg)]()
6
+ [![Tests: Passing](https://img.shields.io/badge/Tests-8%20Passing-brightgreen.svg)]()
7
+ [![Status: Production Ready](https://img.shields.io/badge/Status-v1.0.3-blueviolet.svg)]()
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 a 7-scenario automated verification test suite covering edge cases, replay re-signing, and pool protection:
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=1790143178 (Current) -> stripe.webhooks.constructEvent succeeds!
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
- 🎉 ALL 7 HARDENED HOOKARMOR VERIFICATION TESTS PASSED PERFECTLY!
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
  ---
@@ -242,7 +248,9 @@ HOOKARMOR_API_KEY=ha_sec_your_secure_random_key_here
242
248
  ```
243
249
 
244
250
  When `HOOKARMOR_API_KEY` is present:
245
- * Management endpoints (`/api/endpoints`, `/api/events/replay-all`, and waitlist exports) strictly reject unauthenticated requests and require an `Authorization: Bearer <key>` header.
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).
246
254
  * Query parameter authentication (`?api_key=...`) is strictly prohibited to avoid leaking tokens into browser history and proxy access logs.
247
255
  * The web dashboard displays an **Admin Login** prompt storing your key only in ephemeral session memory.
248
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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Zero-loss Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and B2B SaaS.",
5
5
  "main": "index.js",
6
6
  "bin": {
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 (optional, active when HOOKARMOR_API_KEY is configured)
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
- // Allow public read-only demo access & replay simulation so prospective users can explore live
196
- const isPublicDemo =
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
- if (isPublicDemo) return next();
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
- if (!apiKey) return next();
224
+ if (isPublicDemo) return next();
225
+ }
203
226
 
204
- // Accept API token strictly via Authorization or X-Api-Key headers (NEVER query string URL)
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 link-local addresses (SSRF blocked)' });
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,