Hi, updating this issue with the concrete finding and expanded code references.
Finding
mpp-java verifies MPP credentials statelessly and does not claim either the challenge ID or the Tempo transaction hash before returning a successful receipt. As a result, a valid Authorization header or a push-mode transaction hash can be reused during the challenge validity window to obtain repeated protected resource delivery for a single payment.
Reviewed Code
Reviewed commit: d0cbe746133417cf1c7aa8ae26f5cca880b6cd5e.
Key Excerpts
src/main/java/com/stripe/mpp/server/Verify.java:33-58
33 | public static VerifyResult verifyOrChallenge(
34 | String authorization,
35 | Intent intent,
36 | Map<String, Object> request,
37 | String realm,
... | ...
54 | try {
55 | credential = Credential.fromAuthorization(paymentScheme);
56 | } catch (ParseException e) {
57 | return new VerifyResult.Challenged(createChallenge(methodName, intent, request, realm, secretKey, description, meta, expires));
58 | }
src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java:88-105
88 | private Receipt verifyHash(String txHash, Map<String, Object> request) {
89 | return awaitReceipt(txHash, request);
90 | }
91 |
92 | private Receipt awaitReceipt(String txHash, Map<String, Object> request) {
... | ...
101 | "transaction logs contain no Transfer matching the request currency, recipient, and amount"
102 | );
103 | }
104 | return Receipt.success(txHash, "tempo");
105 | }
Why This Matters
A malicious client can pay once, keep the accepted credential or tx hash, and replay it against the same paid route while the challenge remains unexpired. Because the verifier is stateless, each replay can be accepted as a fresh successful payment and the protected resource can be delivered again without an additional transfer.
For Tempo hash mode, any previously submitted transaction hash that matches the same amount/currency/recipient/sender can be accepted repeatedly because the hash is never marked used. This violates the MPP core single-use/exactly-once payment proof requirement.
Suggested Fix
Require a durable atomic replay store in the server verifier path. Claim challenge.id before or during verification, and claim a canonical Tempo transaction hash before accepting push or pull payments. If a claim already exists, reject the credential as a replay. The store needs atomic compare-and-set semantics to handle concurrent requests.
Thanks for taking a look. If you prefer to move detailed follow-up into a GitHub Security Advisory, please invite @chenshj73; otherwise this public issue should be enough to identify and fix the bug.
Hi, updating this issue with the concrete finding and expanded code references.
Finding
mpp-javaverifies MPP credentials statelessly and does not claim either the challenge ID or the Tempo transaction hash before returning a successful receipt. As a result, a valid Authorization header or a push-mode transaction hash can be reused during the challenge validity window to obtain repeated protected resource delivery for a single payment.Reviewed Code
Reviewed commit:
d0cbe746133417cf1c7aa8ae26f5cca880b6cd5e.Key Excerpts
src/main/java/com/stripe/mpp/server/Verify.java:33-58src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java:88-105Why This Matters
A malicious client can pay once, keep the accepted credential or tx hash, and replay it against the same paid route while the challenge remains unexpired. Because the verifier is stateless, each replay can be accepted as a fresh successful payment and the protected resource can be delivered again without an additional transfer.
For Tempo hash mode, any previously submitted transaction hash that matches the same amount/currency/recipient/sender can be accepted repeatedly because the hash is never marked used. This violates the MPP core single-use/exactly-once payment proof requirement.
Suggested Fix
Require a durable atomic replay store in the server verifier path. Claim
challenge.idbefore or during verification, and claim a canonical Tempo transaction hash before accepting push or pull payments. If a claim already exists, reject the credential as a replay. The store needs atomic compare-and-set semantics to handle concurrent requests.Thanks for taking a look. If you prefer to move detailed follow-up into a GitHub Security Advisory, please invite
@chenshj73; otherwise this public issue should be enough to identify and fix the bug.