Skip to content

fix(stripe): reject idempotent-replayed PaymentIntents in charge verification - #21

Open
ygd58 wants to merge 1 commit into
stripe:mainfrom
ygd58:fix/stripe-idempotent-replay-check
Open

fix(stripe): reject idempotent-replayed PaymentIntents in charge verification#21
ygd58 wants to merge 1 commit into
stripe:mainfrom
ygd58:fix/stripe-idempotent-replay-check

Conversation

@ygd58

Copy link
Copy Markdown

What

StripeApi.createAndConfirm discarded the create response entirely except for the PaymentIntent's id and status. Stripe signals a replayed idempotent request via the Idempotent-Replayed response header, but that header was never inspected, so StripeChargeIntent.verify() treated a replayed PaymentIntent exactly like a fresh one: any succeeded status was accepted and a new success Receipt was issued.

Impact: a credential (challenge + spt) reused against a different challenge triggers a second successful verification and a second resource grant, even though Stripe only charged the customer once.

The canonical TypeScript implementation already guards against this — see wevm/mppx GHSA-8mhj-rffc-rcvw, fixed in mppx 0.4.11 — by checking this exact header. This port never carried that check over. Flagged by the cross-SDK audit as AGR-2026-035; /ag fix isn't available for this repo since it's outside Agricola's write scope, so opening this directly.

Fix

  • Read pi.getLastResponse().headers().firstValue("Idempotent-Replayed") after the create call.
  • Thread it through StripeApi.Result as a new idempotentReplayed field — kept the existing 2-arg Result(id, status) constructor (defaulting to false) for source compatibility with existing tests.
  • StripeChargeIntent.verify() now rejects the credential with VerificationFailedException when idempotentReplayed() is true, before the status check.

Testing

Added idempotentReplayedSucceededChargeIsRejected: a stubbed Result with status=succeeded, idempotentReplayed=true must throw VerificationFailedException rather than return a success Receipt. Matches the reproduction shape suggested in the linked finding.

I could not compile or run this against the real com.stripe:stripe-java dependency — my sandbox doesn't have Maven Central access. Instead I verified the getLastResponse() / headers() / firstValue() call chain directly against the stripe-java v25.3.0 source on GitHub (the version pinned in build.gradle), which is the same accessor pattern the SDK itself uses internally (idempotencyKey(), requestId() use headers().firstValue(...) the same way). But I want to be upfront that this hasn't been through an actual ./gradlew test — please compile-check before merging, and let me know if anything doesn't match and I'll fix it.

Fixes #(the corresponding issue in this repo, if one exists) / tempoxyz/mpp-tools#111

…fication
StripeApi.createAndConfirm discarded the response entirely except for
the PaymentIntent's id and status. Stripe signals a replayed idempotent
request via the `Idempotent-Replayed` response header, but that header
was never inspected, so StripeChargeIntent.verify() treated a replayed
PaymentIntent exactly like a fresh one: any `succeeded` status was
accepted and a new success Receipt was issued.
A credential (challenge + spt) reused against a different challenge
therefore triggered a second successful verification and a second
resource grant, even though Stripe only charged the customer once.
The canonical TypeScript implementation already guards against this
(see wevm/mppx GHSA-8mhj-rffc-rcvw, fixed in mppx 0.4.11) by checking
this exact header; this port never carried that check over.
Read pi.getLastResponse().headers().firstValue("Idempotent-Replayed")
after the create call, thread it through StripeApi.Result as a new
idempotentReplayed field (existing 2-arg Result(id, status) constructor
kept for source compatibility with existing tests), and reject the
credential with VerificationFailedException in StripeChargeIntent.verify()
before the status check when it's true.
Adds a regression test reproducing the issue: a stubbed Result with
status=succeeded and idempotentReplayed=true must throw
VerificationFailedException rather than return a success Receipt.
I could not compile/run this against the real com.stripe:stripe-java
dependency in my environment (no Maven Central access), so I verified
the getLastResponse()/headers()/firstValue() call chain directly
against the stripe-java v25.3.0 source on GitHub (the version pinned
in build.gradle) instead of a live build. Flagging this explicitly —
please double-check compilation before merge.
Fixestempoxyz/mpp-tools#111 (AGR-2026-035)
@cla-assistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for freeto 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

@ygd58