fix(stripe): accept any matching webhook v1 signature#480
fix(stripe): accept any matching webhook v1 signature#480jsdavid278-cyber wants to merge 2 commits into
Conversation
Greptile SummaryThis PR fixes webhook signature validation to correctly handle Stripe's key-rotation scenario where the
Confidence Score: 4/5Safe to merge — the implementation correctly collects all v1 signatures and accepts the first that passes a timing-safe comparison, which is the right approach for Stripe rotation headers. The core logic change in verifyStripeSignature is sound: all v1 values are preserved and each is compared with timingSafeEqual, so neither the rotation case nor the single-signature case regresses. The only gap is that the new test only exercises one of the two possible orderings of valid/invalid signatures, leaving a minor blind spot in coverage. packages/payments/stripe/src/index.test.ts — the regression test could be strengthened by also asserting the inverse ordering (valid signature last). Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Stripe-Signature header] --> B[Split on comma]
B --> C{For each part, split on '='}
C --> D{key === 't'?}
D -- Yes --> E[Set timestamp]
D -- No --> F{key === 'v1' and value non-empty?}
F -- Yes --> G[Append to signatures array]
F -- No --> C
E --> C
G --> C
C -- Done --> H{timestamp present AND signatures non-empty?}
H -- No --> I[throw: missing t or v1]
H -- Yes --> J[Compute expected HMAC with timestamp + rawBody]
J --> K[Convert expected to Buffer]
K --> L{For each collected signature}
L --> M[Convert signature to Buffer]
M --> N{lengths equal AND timingSafeEqual?}
N -- Yes --> O[return — webhook accepted]
N -- No --> L
L -- Exhausted --> P[throw: Invalid signature]
Reviews (1): Last reviewed commit: "test(stripe): cover multiple webhook sig..." | Re-trigger Greptile |
| it('accepts any matching Stripe v1 signature when multiple are present', async () => { | ||
| const raw = JSON.stringify({ type: 'checkout.session.completed' }); | ||
| const secret = 'whsec_test'; | ||
| const timestamp = '1700000000'; | ||
| const signature = createHmac('sha256', secret) | ||
| .update(`${timestamp}.${raw}`) | ||
| .digest('hex'); | ||
|
|
||
| const webhook = await adapter.verifyWebhook( | ||
| ctx({ STRIPE_WEBHOOK_SECRET: secret }), | ||
| raw, | ||
| `t=${timestamp},v1=${signature},v1=${'0'.repeat(64)}`, | ||
| {}, | ||
| ); | ||
|
|
||
| expect(webhook.type).toBe('checkout.session.completed'); | ||
| }); |
There was a problem hiding this comment.
Test only covers one signature ordering
The regression test places the valid signature first and the invalid one last (v1=valid,v1=zeroes). This is the order the old Object.fromEntries code failed on (it kept the last v1, which was invalid). However, the symmetric case — v1=zeroes,v1=valid — is equally important to verify: Stripe may send the new-secret signature last during rotation, so confirming that ordering also passes guards against any future off-by-one regression in the loop.
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!
Summary
Why
Stripe can include multiple v1 signatures during webhook secret rotation. The previous Object.fromEntries parsing only kept the last v1 value, so a valid signature could be ignored if another v1 appeared later.
Testing
uGig gig: abd6b2a0-e728-48cf-a46f-f99e419ed94e