VYPR
High severity8.1NVD Advisory· Published Mar 31, 2026· Updated Apr 3, 2026

CVE-2026-34210

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.

PackageAffected versionsPatched versions
mppxnpm
< 0.4.110.4.11

Affected products

1
  • cpe:2.3:a:wevm:mppx:*:*:*:*:*:node.js:*:*
    Range: <0.4.11

Patches

1
b2b1a0b60506

Merge commit from fork

https://github.com/wevm/mppxtmmMar 26, 2026via ghsa
3 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

News mentions

0

No linked articles in our index yet.