[High] fix(stripe): enforce 5-minute timestamp tolerance to prevent webhook replay attacks#711
Conversation
…replay attacks Stripe's own SDK and documentation require implementations to reject webhook events whose timestamp (t=) is more than 5 minutes from the current wall clock, preventing replay attacks where an attacker captures a valid (payload, signature) pair and re-submits it later. The previous verifyStripeSignature() verified the HMAC correctly but never compared the timestamp against Date.now(), so a captured webhook could be replayed indefinitely and re-trigger business logic (refunds, orders marked paid, etc.). Fix: add a 300-second (5 min) tolerance check, matching the default in Stripe's official stripe-node SDK, before computing the HMAC. Also validate that t= is a finite number to guard against NaN-based bypasses. Ref: https://docs.stripe.com/webhooks/signatures#replay-prevention
Greptile SummaryThis PR adds a 5-minute replay-attack guard to the custom
Confidence Score: 3/5The timestamp guard itself is implemented correctly, but merging as-is will break the existing webhook test suite immediately — two tests rely on a stale hardcoded timestamp that is now years outside the 5-minute window. The core security fix is sound, but the companion test file was not updated: the "valid webhook" test uses timestamp 1700000000 (Nov 2023) and will fail the new skew check before it even reaches the HMAC, and the "invalid signature" test will fail its toThrow assertion because the error now originates from the timestamp path rather than the HMAC path. Both tests will fail on the first CI run after merge. packages/payments/stripe/src/index.test.ts — both webhook tests need their timestamps updated or replaced with fake-timer pinning to match the new tolerance check. Important Files Changed
Sequence DiagramsequenceDiagram
participant Stripe
participant Server
participant verifyStripeSignature
Stripe->>Server: "POST /webhook (Stripe-Signature: t=...,v1=...)"
Server->>verifyStripeSignature: rawBody, header, secret
verifyStripeSignature->>verifyStripeSignature: "Parse t= and v1= from header"
alt t or v1 missing
verifyStripeSignature-->>Server: throw Stripe-Signature missing t or v1
end
verifyStripeSignature->>verifyStripeSignature: "ts = Number(timestamp)"
alt ts is not finite
verifyStripeSignature-->>Server: throw t is not numeric
end
verifyStripeSignature->>verifyStripeSignature: "skew = abs(floor(Date.now()/1000) - ts)"
alt "skew > 300s"
verifyStripeSignature-->>Server: throw timestamp outside tolerance
end
verifyStripeSignature->>verifyStripeSignature: HMAC-SHA256(secret, timestamp.rawBody)
alt no v1 signature matches
verifyStripeSignature-->>Server: throw Invalid Stripe webhook signature
end
verifyStripeSignature-->>Server: return valid
Server-->>Stripe: 200 OK
Reviews (1): Last reviewed commit: "fix(stripe): enforce 5-minute timestamp ..." | Re-trigger Greptile |
| // Reject replayed webhooks per Stripe's guidance: | ||
| // https://docs.stripe.com/webhooks/signatures#replay-prevention | ||
| const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own SDK default | ||
| const ts = Number(timestamp); | ||
| if (!Number.isFinite(ts)) { | ||
| throw new Error('Stripe-Signature t is not numeric'); | ||
| } | ||
| const skew = Math.abs(Math.floor(Date.now() / 1000) - ts); | ||
| if (skew > TOLERANCE_SECONDS) { | ||
| throw new Error( | ||
| `Stripe webhook timestamp ${ts} is outside the ${TOLERANCE_SECONDS}s tolerance (skew: ${skew}s). ` + | ||
| 'Reject to prevent replay attacks.', | ||
| ); | ||
| } |
There was a problem hiding this comment.
Existing tests break because timestamp is hardcoded to a stale value
The test file uses timestamp = '1700000000' (November 2023), which is now more than 2 years outside the 300-second window. Two tests in index.test.ts will fail after this change:
'verifies Stripe webhook signatures…'(line 62) — the timestamp check fires before the HMAC, so the previously-valid test now throws"Stripe webhook timestamp … is outside the tolerance"and the test fails.'rejects invalid Stripe webhook signatures'(line 99) — same stalet=1700000000causes the rejection test to throw the wrong error, sorejects.toThrow('Invalid Stripe webhook signature')fails because the actual message is about the timestamp, not the signature.
Neither test is updated in this PR. Both need to use vi.setSystemTime / vi.useFakeTimers to pin Date.now() to the fixture timestamp, or generate the timestamp dynamically from Math.floor(Date.now() / 1000) at test runtime.
|
|
||
| // Reject replayed webhooks per Stripe's guidance: | ||
| // https://docs.stripe.com/webhooks/signatures#replay-prevention | ||
| const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own SDK default |
There was a problem hiding this comment.
TOLERANCE_SECONDS is not configurable, making integration tests awkward
TOLERANCE_SECONDS is hardcoded inside the function with no way to override it. Stripe's own stripe-node SDK exposes this as a parameter (defaulting to 300 s). Without an override path, test authors must either pin Date.now via fake timers or generate signatures at runtime — both add friction. Consider accepting an optional tolerance parameter in verifyStripeSignature so callers can pass Infinity or a wider window in test environments.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
8 similar comments
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
|
🤖 Auto-rebase: The branch was rebased successfully locally but could not be pushed to the fork. Please enable 'Allow edits from maintainers' in the PR settings, or rebase manually: |
Stripe's own SDK and documentation require implementations to reject webhook events whose timestamp (t=) is more than 5 minutes from the current wall clock, preventing replay attacks where an attacker captures a valid (payload, signature) pair and re-submits it later.
The previous
verifyStripeSignature()verified the HMAC correctly but never compared the timestamp againstDate.now(), so a captured webhook could be replayed indefinitely and re-trigger business logic (refunds, orders marked paid, etc.).Fix: add a 300-second (5 min) tolerance check, matching the default in Stripe's official stripe-node SDK, before computing the HMAC. Also validate that
t=is a finite number to guard against NaN-based bypasses.Ref: https://docs.stripe.com/webhooks/signatures#replay-prevention
Severity: High