Skip to content

[High] fix(stripe): enforce 5-minute timestamp tolerance to prevent webhook replay attacks#711

Open
katnisscalls99 wants to merge 1 commit into
profullstack:masterfrom
katnisscalls99:fix/stripe-webhook-replay-tolerance
Open

[High] fix(stripe): enforce 5-minute timestamp tolerance to prevent webhook replay attacks#711
katnisscalls99 wants to merge 1 commit into
profullstack:masterfrom
katnisscalls99:fix/stripe-webhook-replay-tolerance

Conversation

@katnisscalls99
Copy link
Copy Markdown

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

Severity: High

…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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 6, 2026

Greptile Summary

This PR adds a 5-minute replay-attack guard to the custom verifyStripeSignature() helper, closing a gap where a captured valid (payload, signature) pair could be re-submitted indefinitely since the previous implementation verified the HMAC but never checked the t= timestamp against the current clock.

  • The tolerance logic itself (Number.isFinite guard + Math.abs skew check against 300 s) is correct and mirrors Stripe's official stripe-node SDK default.
  • The existing test suite uses a hardcoded timestamp of 1700000000 (November 2023), which is now years outside the tolerance window. The "valid webhook" test will start throwing the new timestamp error, and the "invalid signature" test will fail its toThrow assertion because the error message now comes from the timestamp check rather than the HMAC check. Neither test is updated in this PR.
  • TOLERANCE_SECONDS is a private constant with no override path, making it difficult to write deterministic tests without fake timers.

Confidence Score: 3/5

The 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

Filename Overview
packages/payments/stripe/src/index.ts Adds a 300-second replay-attack guard to verifyStripeSignature(); the logic is correct and matches Stripe's SDK, but the existing test suite uses a stale hardcoded timestamp that is now years outside the tolerance window, causing two tests to fail.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "fix(stripe): enforce 5-minute timestamp ..." | Re-trigger Greptile

Comment on lines +187 to +200
// 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.',
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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:

  1. '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.
  2. 'rejects invalid Stripe webhook signatures' (line 99) — same stale t=1700000000 causes the rejection test to throw the wrong error, so rejects.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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

8 similar comments
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

🤖 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: git fetch upstream master && git rebase upstream/master.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant