CVE-2026-34210
Description
mppx is a TypeScript interface for machine payments protocol. Prior to version 0.4.11, the stripe/charge payment method did not check Stripe's Idempotent-Replayed response header when creating PaymentIntents. An attacker could replay a valid credential containing the same spt token against a new challenge, and the server would accept the replayed Stripe PaymentIntent as a new successful payment without actually charging the customer again. This allowed an attacker to pay once and consume unlimited resources by replaying the credential. This issue has been patched in version 0.4.11.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mppxnpm | < 0.4.11 | 0.4.11 |
Affected products
1Patches
13 files changed · +69 −6
src/stripe/internal/types.ts+5 −1 modified@@ -6,7 +6,11 @@ */ export type StripeClient = { paymentIntents: { - create(...args: any[]): Promise<{ id: string; status: string }> + create(...args: any[]): Promise<{ + id: string + status: string + lastResponse?: { headers?: Record<string, string> } + }> } }
src/stripe/server/Charge.test.ts+52 −1 modified@@ -16,9 +16,15 @@ function createMockStripeClient( overrides?: Partial<{ status: string; id: string; throws: boolean }>, ): { client: StripeClient; create: ReturnType<typeof vi.fn> } { const { status = 'succeeded', id = 'pi_mock_123', throws = false } = overrides ?? {} + let callCount = 0 const create = vi.fn(async () => { if (throws) throw new Error('Stripe API error') - return { id, status } + callCount++ + return { + id, + status, + ...(callCount > 1 ? { lastResponse: { headers: { 'idempotent-replayed': 'true' } } } : {}), + } }) return { client: { paymentIntents: { create } }, @@ -196,6 +202,51 @@ describe('stripe.charge with client', () => { expect(body.detail).toContain('requires action') }) + test('behavior: rejects replayed credential', async () => { + const { client } = createMockStripeClient() + + const server = Mppx.create({ + methods: [ + stripe.charge({ + client, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 }) + + // First request: get challenge + const firstResult = await handle(new Request('https://example.com')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const challenge = Challenge.fromResponse(firstResult.challenge) + const credential = Credential.from({ + challenge, + payload: { spt: 'spt_test_token' }, + }) + + // First payment: should succeed + const result1 = await handle( + new Request('https://example.com', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + expect(result1.status).toBe(200) + + // Replay same credential: should be rejected + const result2 = await handle( + new Request('https://example.com', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + expect(result2.status).toBe(402) + }) + test('behavior: receipt contains mock reference', async () => { const { client } = createMockStripeClient({ id: 'pi_custom_ref' })
src/stripe/server/Charge.ts+12 −4 modified@@ -89,6 +89,9 @@ export function charge<const parameters extends charge.Parameters>(parameters: p metadata: resolvedMetadata, }) + if (pi.replayed) + throw new VerificationFailedError({ reason: 'Payment has already been processed.' }) + if (pi.status === 'requires_action') { throw new PaymentActionRequiredError({ reason: 'Stripe PaymentIntent requires action' }) } @@ -136,7 +139,7 @@ async function createWithClient(parameters: { metadata: Record<string, string> request: { amount: unknown; currency: unknown } spt: string -}): Promise<{ id: string; status: string }> { +}): Promise<{ id: string; status: string; replayed: boolean }> { const { client, challenge, metadata, request, spt } = parameters try { const result = await client.paymentIntents.create( @@ -151,7 +154,9 @@ async function createWithClient(parameters: { } as any, { idempotencyKey: `mppx_${challenge.id}_${spt}` }, ) - return { id: result.id, status: result.status } + // https://docs.stripe.com/error-low-level#idempotency + const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true' + return { id: result.id, status: result.status, replayed } } catch { throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) } @@ -164,7 +169,7 @@ async function createWithSecretKey(parameters: { metadata: Record<string, string> request: { amount: unknown; currency: unknown } spt: string -}): Promise<{ id: string; status: string }> { +}): Promise<{ id: string; status: string; replayed: boolean }> { const { secretKey, challenge, metadata, request, spt } = parameters const body = new URLSearchParams({ @@ -190,7 +195,10 @@ async function createWithSecretKey(parameters: { }) if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) - return (await response.json()) as { id: string; status: string } + // https://docs.stripe.com/error-low-level#idempotency + const replayed = response.headers.get('idempotent-replayed') === 'true' + const result = (await response.json()) as { id: string; status: string } + return { ...result, replayed } } /** @internal */
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/wevm/mppx/commit/b2b1a0b60506fc71aa80b8a025084949dca1a994nvdPatchWEB
- github.com/wevm/mppx/security/advisories/GHSA-8mhj-rffc-rcvwnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-8mhj-rffc-rcvwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34210ghsaADVISORY
- github.com/wevm/mppx/releases/tag/mppx%400.4.11ghsaWEB
- github.com/wevm/mppx/releases/tag/mppx@0.4.11nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.