@amirabaris/webhook-kit 0.1.1 → 0.1.2
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 +37 -20
- package/bin/webhook-kit.mjs +2 -0
- package/dist/index.cjs +404 -311
- package/dist/index.d.cts +168 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +168 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +168 -0
- package/dist/index.js +404 -311
- package/dist/index.mjs +382 -312
- package/dist/index.mjs.map +1 -1
- package/docs/agent-rules.md +10 -2
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -525,14 +525,14 @@ try {
|
|
|
525
525
|
|
|
526
526
|
All errors inherit from `WebhookKitError` and include machine-readable `code` properties:
|
|
527
527
|
|
|
528
|
-
| Error Class | `code` | Root Cause & Mitigation
|
|
529
|
-
| ----------------------------- | ------------------------------- |
|
|
530
|
-
| `SignatureVerificationError` | `SIGNATURE_VERIFICATION_FAILED` | HMAC digest mismatch
|
|
531
|
-
| `TimestampToleranceError` | `TIMESTAMP_TOLERANCE_EXCEEDED` | Payload timestamp
|
|
532
|
-
| `ReplayAttackError` | `WEBHOOK_REPLAY_DETECTED` | Event ID already exists in `store`. Prevents duplicate webhook processing within the TTL window.
|
|
533
|
-
| `InvalidSignatureHeaderError` | `INVALID_SIGNATURE_HEADER` | Header is missing, empty, or lacks required prefix (`sha256=`, `t=...,v1=...`).
|
|
534
|
-
| `PayloadExtractionError` | `PAYLOAD_EXTRACTION_FAILED` | Body is an unsupported format or input stream was prematurely closed.
|
|
535
|
-
| `WebhookKitError` | `WEBHOOK_KIT_ERROR` | Base class for all webhook-kit errors.
|
|
528
|
+
| Error Class | `code` | Root Cause & Mitigation |
|
|
529
|
+
| ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
530
|
+
| `SignatureVerificationError` | `SIGNATURE_VERIFICATION_FAILED` | HMAC digest mismatch, missing secret, or invalid format (e.g., non-hex, wrong length). |
|
|
531
|
+
| `TimestampToleranceError` | `TIMESTAMP_TOLERANCE_EXCEEDED` | Payload timestamp is outside `toleranceSeconds` window, or is non-finite (`NaN`/`Infinity`). |
|
|
532
|
+
| `ReplayAttackError` | `WEBHOOK_REPLAY_DETECTED` | Event ID already exists in `store`. Prevents duplicate webhook processing within the TTL window. |
|
|
533
|
+
| `InvalidSignatureHeaderError` | `INVALID_SIGNATURE_HEADER` | Header is missing, empty, or lacks required prefix (`sha256=`, `t=...,v1=...`). |
|
|
534
|
+
| `PayloadExtractionError` | `PAYLOAD_EXTRACTION_FAILED` | Body is an unsupported format or input stream was prematurely closed. |
|
|
535
|
+
| `WebhookKitError` | `WEBHOOK_KIT_ERROR` | Base class for all webhook-kit errors. |
|
|
536
536
|
|
|
537
537
|
### Replay Attack Mitigation & Idempotency Store (`WebhookReplayStore`)
|
|
538
538
|
|
|
@@ -541,15 +541,18 @@ Replay attacks occur when an attacker (or a retry storm) replays a valid webhook
|
|
|
541
541
|
`webhook-kit` combines two layers of replay protection:
|
|
542
542
|
|
|
543
543
|
1. **Timestamp Tolerance**:
|
|
544
|
-
- **Stripe**: Parses `t=...` from `Stripe-Signature` and validates against `toleranceSeconds` (default: 300s).
|
|
544
|
+
- **Stripe**: Parses `t=...` from `Stripe-Signature` and validates against `toleranceSeconds` (default: 300s). Fractional timestamps are safely floored.
|
|
545
545
|
- **Generic**: Automatically inspects `x-webhook-timestamp` (or explicit `timestamp` option) and validates against `toleranceSeconds`.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
-
|
|
546
|
+
- **Strict Numeric Hardening**: Both `timestamp` and `toleranceSeconds` must be positive, finite numbers. Non-finite values (`NaN`, `Infinity`, `-Infinity`) or negative numbers immediately reject with `TimestampToleranceError` to prevent tolerance check bypasses.
|
|
547
|
+
2. **Idempotency / Replay Store (`store: { has(eventId), set(eventId, ttl), setNx?(eventId, ttl) }`)**:
|
|
548
|
+
- Pass any synchronous or asynchronous store implementing `WebhookReplayStore`.
|
|
549
|
+
- Supports atomic `setNx` (_set-if-not-exists_) to eliminate race conditions under concurrent deliveries. If `setNx` is omitted, `webhook-kit` automatically serializes in-flight checks.
|
|
550
|
+
- `webhook-kit` automatically extracts the event ID from `options.eventId`, standard delivery headers (`svix-id`, `webhook-id`, `X-GitHub-Delivery`, `X-Shopify-Webhook-Id`, `X-Webhook-Id`), or `payload.id` **after** cryptographic HMAC verification succeeds, and rejects duplicates with `ReplayAttackError` (`409 Conflict` in `createWebhookHandler`).
|
|
551
|
+
- Supports both string and numeric event IDs (e.g. Shopify numeric webhook IDs) seamlessly.
|
|
549
552
|
|
|
550
553
|
#### Default In-Memory TTL Store (`createInMemoryReplayStore`)
|
|
551
554
|
|
|
552
|
-
For single-instance Node, Bun, or Deno servers, use the built-in zero-dependency TTL store with automatic expiration and LRU capacity bounding:
|
|
555
|
+
For single-instance Node, Bun, or Deno servers, use the built-in zero-dependency TTL store with atomic `setNx`, automatic expiration, and LRU capacity bounding:
|
|
553
556
|
|
|
554
557
|
```ts
|
|
555
558
|
import {
|
|
@@ -574,22 +577,34 @@ export const POST = createWebhookHandler({
|
|
|
574
577
|
|
|
575
578
|
#### Distributed Store Hooks (Redis / Upstash / Cloudflare KV)
|
|
576
579
|
|
|
577
|
-
For multi-instance or serverless edge deployments, wire a
|
|
580
|
+
For multi-instance or serverless edge deployments, wire a lightweight `WebhookReplayStore` adapter into Redis, Upstash, or Cloudflare Workers KV. Provide `setNx` (e.g. Redis `SET key value EX ttl NX`) for hardware-level atomic de-duplication:
|
|
578
581
|
|
|
579
582
|
```ts
|
|
580
583
|
import type { WebhookReplayStore } from "@amirabaris/webhook-kit";
|
|
581
584
|
|
|
582
|
-
// 1. Node / Bun Redis (ioredis or node-redis)
|
|
585
|
+
// 1. Node / Bun Redis (ioredis or node-redis) with atomic setNx
|
|
583
586
|
const redisStore: WebhookReplayStore = {
|
|
584
587
|
has: async (id) => (await redis.exists(`wh:replay:${id}`)) === 1,
|
|
585
588
|
set: async (id, ttl) => await redis.set(`wh:replay:${id}`, "1", "EX", ttl),
|
|
589
|
+
// Optional atomic set-if-not-exists for distributed lock-free deduplication
|
|
590
|
+
setNx: async (id, ttl) => {
|
|
591
|
+
const res = await redis.set(`wh:replay:${id}`, "1", "EX", ttl, "NX");
|
|
592
|
+
return res === "OK";
|
|
593
|
+
},
|
|
586
594
|
};
|
|
587
595
|
|
|
588
|
-
// 2. Upstash Redis (@upstash/redis — Edge & Next.js)
|
|
596
|
+
// 2. Upstash Redis (@upstash/redis — Edge & Next.js) with atomic setNx
|
|
589
597
|
const upstashStore: WebhookReplayStore = {
|
|
590
598
|
has: async (id) => (await upstash.exists(`wh:replay:${id}`)) === 1,
|
|
591
599
|
set: async (id, ttl) =>
|
|
592
600
|
await upstash.set(`wh:replay:${id}`, "1", { ex: ttl }),
|
|
601
|
+
setNx: async (id, ttl) => {
|
|
602
|
+
const res = await upstash.set(`wh:replay:${id}`, "1", {
|
|
603
|
+
ex: ttl,
|
|
604
|
+
nx: true,
|
|
605
|
+
});
|
|
606
|
+
return res !== null;
|
|
607
|
+
},
|
|
593
608
|
};
|
|
594
609
|
|
|
595
610
|
// 3. Cloudflare Workers KV ( passed via Hono / Worker env )
|
|
@@ -618,10 +633,12 @@ flowchart LR
|
|
|
618
633
|
|
|
619
634
|
`webhook-kit` is organized into modular layers:
|
|
620
635
|
|
|
621
|
-
- **`crypto/`**: Web Cryptography API (`crypto.subtle`) primitives and constant-time buffer comparison.
|
|
622
|
-
- **`utils/`**: Stream and
|
|
623
|
-
- **`providers/`**: Pluggable provider-specific format parsers (Stripe timestamped `t=...,v1=...`, GitHub `sha256=...`, Shopify Base64
|
|
624
|
-
- **`
|
|
636
|
+
- **`crypto/`**: Web Cryptography API (`crypto.subtle`) primitives, key LRU caching, and constant-time buffer comparison (`timingSafeEqual`).
|
|
637
|
+
- **`utils/`**: Stream and header utilities (`extractRawBody`, `extractHeaderValue`) preserving raw bytes without consuming input requests.
|
|
638
|
+
- **`providers/`**: Pluggable provider-specific format parsers and verifiers (Stripe timestamped `t=...,v1=...`, GitHub `sha256=...`, Shopify Base64).
|
|
639
|
+
- **`store/`**: Idempotency and replay attack prevention (`InMemoryReplayStore`, `checkAndRecordReplay`, atomic `setNx`).
|
|
640
|
+
- **`handler/`**: Declarative universal route handler (`createWebhookHandler`) for Next.js, Hono, and WinterCG runtimes.
|
|
641
|
+
- **`errors.ts`**: Strongly typed error class hierarchy (`SignatureVerificationError`, `TimestampToleranceError`, `ReplayAttackError`, `InvalidSignatureHeaderError`, `PayloadExtractionError`).
|
|
625
642
|
|
|
626
643
|
---
|
|
627
644
|
|
package/bin/webhook-kit.mjs
CHANGED
|
@@ -28,6 +28,8 @@ const AGENT_RULES_CONTENT = `${START_MARKER}
|
|
|
28
28
|
- **NEVER parse JSON before verifying signatures**: In frameworks like Next.js, Hono, or Express, parsing JSON reorders keys and strips whitespace, invalidating cryptographic HMAC checks. Always pass the raw \`Request\` or raw text string.
|
|
29
29
|
- **Constant-Time Verification**: Never compare signatures with \`===\` or \`!==\`. Always use \`timingSafeEqual\` or \`verifyWebhookSignature\` from \`@amirabaris/webhook-kit\`.
|
|
30
30
|
- **Zero Node-Only Crypto**: Web standard environments (Cloudflare Workers, Next.js Edge, Bun, Deno) do not support \`node:crypto\`. Rely on \`@amirabaris/webhook-kit\` which uses \`crypto.subtle\`.
|
|
31
|
+
- **Strict Format & Tolerance Hardening**: Validate signature format (e.g. strict 64-char hex or 44-char Base64) before crypto checks. Enforce positive, finite numbers for timestamps and \`toleranceSeconds\` (\`NaN\` and negative tolerance are rejected).
|
|
32
|
+
- **Replay Protection & Store**: Deduplicate retries with \`store: createInMemoryReplayStore()\` or distributed \`WebhookReplayStore\` adapters (\`has\`, \`set\`, optional atomic \`setNx\`), supporting both string and numeric event IDs.
|
|
31
33
|
- **Conventional Commits**: Follow \`type(scope): message\` (e.g. \`feat(stripe): add tolerance check\`).
|
|
32
34
|
${END_MARKER}`;
|
|
33
35
|
|