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 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
  ---
@@ -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
- * 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).
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.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.1",
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/dispatcher.js CHANGED
@@ -164,11 +164,27 @@ class Dispatcher extends EventEmitter {
164
164
 
165
165
  let headers = { ...event.headers };
166
166
 
167
- // Re-sign outbound headers if endpoint secret is configured
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 (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,