From 746de9e4eecc84ce7d2fc9b01eeeb967310e6298 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Thu, 13 Aug 2026 05:17:25 +0000 Subject: [PATCH] fix(stripe): reject idempotent-replayed PaymentIntents in charge verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Fixes tempoxyz/mpp-tools#111 (AGR-2026-035) --- .../stripe/mpp/methods/stripe/StripeApi.java | 26 ++++++++++++++++--- .../methods/stripe/StripeChargeIntent.java | 4 +++ .../stripe/StripeChargeIntentTest.java | 15 +++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java index bff758a..31f4eac 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeApi.java @@ -20,14 +20,21 @@ class StripeApi { static final class Result { private final String id; private final String status; + private final boolean idempotentReplayed; Result(String id, String status) { + this(id, status, false); + } + + Result(String id, String status, boolean idempotentReplayed) { this.id = id; this.status = status; + this.idempotentReplayed = idempotentReplayed; } String id() { return id; } String status() { return status; } + boolean idempotentReplayed() { return idempotentReplayed; } @Override public boolean equals(Object o) { @@ -35,17 +42,19 @@ public boolean equals(Object o) { if (!(o instanceof Result)) return false; Result result = (Result) o; return Objects.equals(id, result.id) - && Objects.equals(status, result.status); + && Objects.equals(status, result.status) + && idempotentReplayed == result.idempotentReplayed; } @Override public int hashCode() { - return Objects.hash(id, status); + return Objects.hash(id, status, idempotentReplayed); } @Override public String toString() { - return "Result[id=" + id + ", status=" + status + "]"; + return "Result[id=" + id + ", status=" + status + + ", idempotentReplayed=" + idempotentReplayed + "]"; } } @@ -77,7 +86,16 @@ Result createAndConfirm( .build(); PaymentIntent pi = client.paymentIntents().create(builder.build(), options); - return new Result(pi.getId(), pi.getStatus()); + + boolean idempotentReplayed = false; + if (pi.getLastResponse() != null) { + idempotentReplayed = pi.getLastResponse().headers() + .firstValue("Idempotent-Replayed") + .map(Boolean::parseBoolean) + .orElse(false); + } + + return new Result(pi.getId(), pi.getStatus(), idempotentReplayed); } catch (StripeException e) { throw new VerificationFailedException(e.getMessage()); diff --git a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java index d51f00a..0c73dd0 100644 --- a/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/stripe/StripeChargeIntent.java @@ -103,6 +103,10 @@ public Receipt verify(Credential credential, Map request) { StripeApi.Result result = stripeApi.createAndConfirm( secretKey, amountMinorUnits, currency, spt, paymentMethodTypes, metadata, challengeId); + if (result.idempotentReplayed()) { + throw new VerificationFailedException( + "PaymentIntent " + result.id() + " was an idempotent replay, not a fresh charge"); + } if ("requires_action".equals(result.status())) { throw new com.stripe.mpp.error.PaymentActionRequiredException( "PaymentIntent " + result.id() + " requires action"); diff --git a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java index 29f6a00..cf4bce8 100644 --- a/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java +++ b/src/test/java/com/stripe/mpp/methods/stripe/StripeChargeIntentTest.java @@ -97,6 +97,21 @@ void successfulChargeReturnsReceipt() { assertThat(receipt.externalId()).isNull(); } + @Test + void idempotentReplayedSucceededChargeIsRejected() { + // Regression test for AGR-2026-035: Stripe reporting a succeeded + // PaymentIntent whose creation was an idempotent replay (i.e. the + // charge already happened for an earlier challenge/SPT pair) must + // not be accepted as a fresh successful verification. + StubStripeApi api = new StubStripeApi( + new StripeApi.Result("pi_replay123", "succeeded", true)); + + assertThatThrownBy(() -> intent(api).verify(credential("spt_xxx"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("pi_replay123") + .hasMessageContaining("replay"); + } + @Test void externalIdIncludedInReceipt() { StubStripeApi api = new StubStripeApi(new StripeApi.Result("pi_abc123", "succeeded"));