Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/safe-mpp-redirects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@stripe/link-cli': patch
---

Send MPP payment credentials only to the URL that returned the payment challenge.
Approved spend requests and paid retries now reject redirects instead of moving
credentials to a new destination. Remote MPP endpoints must use HTTPS; HTTP
remains supported for exact loopback addresses used in local development.
59 changes: 54 additions & 5 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ let serverPort: number;
let lastRequest: RequestLog;
let requests: RequestLog[];
let nextResponse: { status: number; body: unknown };
let responsesByUrl: Record<string, { status: number; body: unknown }> = {};
let responsesByUrl: Record<
string,
{ status: number; body: unknown; headers?: Record<string, string> }
> = {};

// ─── Second mock server for merchant endpoints ─────────────────────────────
let merchantServer: http.Server;
Expand All @@ -99,8 +102,13 @@ function setNextResponse(status: number, body: unknown) {
nextResponse = { status, body };
}

function setResponseForUrl(url: string, status: number, body: unknown) {
responsesByUrl[url] = { status, body };
function setResponseForUrl(
url: string,
status: number,
body: unknown,
headers?: Record<string, string>,
) {
responsesByUrl[url] = { status, body, headers };
}

async function runProdCli(...args: string[]): Promise<CliResult> {
Expand Down Expand Up @@ -195,6 +203,7 @@ describe('production mode', () => {
const response = urlOverride ?? nextResponse;
res.writeHead(response.status, {
'Content-Type': 'application/json',
...response.headers,
});
res.end(JSON.stringify(response.body));
});
Expand Down Expand Up @@ -2894,17 +2903,57 @@ describe('production mode', () => {
it('carries the raw URL in pay_argv and a quoted URL in pay_command', async () => {
const marker = `${os.tmpdir()}/link-cli-injection-argv-${process.pid}`;
const url = payloadUrl(marker);
const effectiveUrl = new URL(url).href;

const next = await runFullFlow(url);

expect(next.pay_argv.command).toBe('mpp');
expect(next.pay_argv.args[0]).toBe('pay');
expect(next.pay_argv.args[1]).toBe(url);
expect(next.pay_argv.args[1]).toBe(effectiveUrl);
expect(next.pay_argv.args).toContain('--spend-request-id');
expect(next.pay_argv.args).toContain('lsrq_spt_002');

expect(next.pay_command).not.toContain('pay $(touch');
expect(next.pay_command).toContain(`'${url}'`);
expect(next.pay_command).toContain(`'${effectiveUrl}'`);
});

it('continues from the effective redirected request', async () => {
setNextResponse(200, PENDING_SPT_REQUEST);
setResponseForUrl('/merchant-redirect', 302, null, {
Location: `http://127.0.0.1:${merchantPort}/api/charge`,
});
setMerchantResponse(402, '{"error":"payment required"}', {
'www-authenticate': WWW_AUTHENTICATE_STRIPE,
});

const result = await runProdCli(
'mpp',
'pay',
`http://127.0.0.1:${serverPort}/merchant-redirect`,
'--context',
VALID_CONTEXT,
'--payment-method-id',
'pd_prod_test',
'--data',
'{"item":"book"}',
'--header',
'Authorization: Bearer caller-value',
'--format',
'json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Array<{
_next: { pay_argv: { command: string; args: string[] } };
}>;
const args = output[0]._next.pay_argv.args;
expect(args[1]).toBe(`http://127.0.0.1:${merchantPort}/api/charge`);
expect(args.slice(args.indexOf('-X'), args.indexOf('-X') + 2)).toEqual([
'-X',
'GET',
]);
expect(args).not.toContain('-d');
expect(args.join(' ')).not.toMatch(/authorization|content-type/i);
});

it('does not execute the payload when pay_command is run through bash', async () => {
Expand Down
33 changes: 20 additions & 13 deletions packages/cli/src/commands/mpp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
runMppPayFullFlow,
runMppPayWithSpendRequest,
} from './pay';
import { createMppRequest, probeMppRequest } from './request';
import { decodeOptions, payOptions } from './schema';

export function createMppCli(
Expand Down Expand Up @@ -91,11 +92,10 @@ export function createMppCli(
const httpMethod = method ?? (data !== undefined ? 'POST' : 'GET');
const requestHeaders = buildHeaders(data, headers);

const probeResponse = await fetch(url, {
method: httpMethod,
body: data,
headers: requestHeaders,
});
const probe = await probeMppRequest(
createMppRequest(url, httpMethod, data, requestHeaders),
);
const probeResponse = probe.response;

if (probeResponse.status !== 402) {
yield await readPayResult(probeResponse);
Expand All @@ -111,6 +111,7 @@ export function createMppCli(
}

const decoded = decodeStripeChallenge(wwwAuth);
await probeResponse.body?.cancel();
const networkId = decoded.network_id;
const challengeAmount = decoded.request_json.amount
? Number(decoded.request_json.amount)
Expand Down Expand Up @@ -160,14 +161,20 @@ export function createMppCli(
test: opts.test || undefined,
});

// Build the mpp pay continuation for _next with the spend request ID.
// `url`, `data` and `header` carry merchant-controlled text, so the
// argv form is authoritative and `pay_command` must stay shell-quoted.
const nextArgs = ['pay', url, '--spend-request-id', spendRequest.id];
if (method) nextArgs.push('-X', method);
if (data) nextArgs.push('-d', data);
if (headers) {
for (const h of headers) nextArgs.push('-H', h);
// Continue from the request that actually returned the challenge. Redirects
// may have changed its URL, method, body, or safe-to-forward headers.
// Merchant-controlled values stay shell-quoted in the display command.
const nextArgs = [
'pay',
probe.url,
'--spend-request-id',
spendRequest.id,
'-X',
probe.method,
];
if (probe.body !== undefined) nextArgs.push('-d', probe.body);
for (const [name, value] of probe.headers) {
nextArgs.push('-H', `${name}: ${value}`);
}
const nextCommand = `mpp ${shellCommand(nextArgs)}`;
const pollCommand = `spend-request retrieve ${shellQuote(spendRequest.id)} --interval 2 --max-attempts 300`;
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/src/commands/mpp/pay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { ISpendRequestResource } from '@stripe/link-sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { payWithSpt, runMppPayFullFlow } from './pay';

const WWW_AUTHENTICATE_STRIPE = [
'Payment id="ch_001",',
'realm="merchant.example",',
'method="stripe",',
'intent="charge",',
`request="${Buffer.from(JSON.stringify({ networkId: 'net_001', amount: '1000', currency: 'usd', decimals: 2, paymentMethodTypes: ['card'] })).toString('base64')}",`,
'expires="2099-01-01T00:00:00Z"',
].join(' ');

function challengeResponse(): Response {
return new Response('{"error":"payment required"}', {
status: 402,
headers: { 'www-authenticate': WWW_AUTHENTICATE_STRIPE },
});
}

beforeEach(() => {
vi.stubGlobal('__CLI_VERSION__', 'test');
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('payWithSpt', () => {
it('rejects a redirect before using an approved credential', async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(null, {
status: 307,
headers: { location: 'https://other.example/challenge' },
}),
);
vi.stubGlobal('fetch', fetcher);

await expect(
payWithSpt(
'https://merchant.example/challenge',
'spt_test_123',
undefined,
undefined,
undefined,
),
).rejects.toThrow(/redirected with status 307 after approval/);

expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0][0]).toBe('https://merchant.example/challenge');
expect(
new Headers(fetcher.mock.calls[0][1]?.headers).has('authorization'),
).toBe(false);
});

it('replaces caller authorization and refuses a redirect after payment', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(challengeResponse())
.mockResolvedValueOnce(
new Response(null, {
status: 307,
headers: { location: 'https://other.example/payment' },
}),
);
vi.stubGlobal('fetch', fetcher);

await expect(
payWithSpt(
'https://merchant.example/challenge',
'spt_test_123',
'POST',
'{"item":"book"}',
['authorization: Bearer caller-value'],
),
).rejects.toThrow('redirect 307');

const paidHeaders = new Headers(fetcher.mock.calls[1][1]?.headers);
expect(
[...paidHeaders].filter(([name]) => name === 'authorization'),
).toEqual([['authorization', expect.stringMatching(/^Payment /)]]);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls[1][1]?.redirect).toBe('manual');
expect(fetcher.mock.calls[1][1]?.body).toBe('{"item":"book"}');
});

it('refreshes an approved challenge at the pinned destination without following redirects', async () => {
const repository = {
create: vi.fn().mockResolvedValue({
id: 'lsrq_123',
status: 'pending_approval',
}),
retrieve: vi
.fn()
.mockResolvedValueOnce({ id: 'lsrq_123', status: 'approved' })
.mockResolvedValueOnce({
id: 'lsrq_123',
status: 'approved',
shared_payment_token: { id: 'spt_test_123' },
}),
} as unknown as ISpendRequestResource;
const fetcher = vi
.fn()
.mockResolvedValueOnce(
new Response(null, {
status: 302,
headers: { location: 'https://merchant.example/challenge' },
}),
)
.mockResolvedValueOnce(challengeResponse())
.mockResolvedValueOnce(
new Response(null, {
status: 307,
headers: { location: 'https://other.example/challenge' },
}),
);
vi.stubGlobal('fetch', fetcher);

await expect(
runMppPayFullFlow({
url: 'https://redirector.example/start',
method: 'GET',
data: undefined,
headers: undefined,
context:
'Buy a test item from the merchant after explicit Link approval for this machine payment request.',
amountOverride: 1000,
paymentMethodId: 'pd_test_123',
test: true,
repository,
paymentMethodsFactory: vi.fn(),
}),
).rejects.toThrow(/redirected with status 307 after approval/);
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'https://redirector.example/start',
'https://merchant.example/challenge',
'https://merchant.example/challenge',
]);
});
});
Loading
Loading