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
5 changes: 5 additions & 0 deletions .changeset/sharp-horses-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stripe/link-cli": minor
---

security: sanitize mpp output for shell-unsafe output
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Key input field notes:
- `mpp pay <url> --spend-request-id <id> [--method <method>] [--data <body>] [--header <header>]...` — backward-compat mode: uses a pre-approved spend request directly, skipping creation/approval.
- `--header` is repeatable and uses `"Name: Value"` format. `Content-Type: application/json` is auto-applied when `--data` is provided; user-provided headers take precedence.
- The SPT is one-time-use — a failed payment requires running `mpp pay` again (creates a new spend request).
- In agent mode the full flow yields `_next.pay_argv` (`{ command: 'mpp', args: [...] }`) alongside `_next.pay_command`. **`pay_argv` is authoritative** — it holds the raw values and is meant to be invoked without a shell. `pay_command` is the compatibility string and every dynamic part of it (url, method, body, each header, spend-request id) must go through `shellQuote` from `packages/cli/src/utils/shell-quote.ts`. See "Security: shell-quoting command strings".
- Implemented in `packages/cli/src/commands/mpp/` — pay.tsx (logic), schema.ts (input/output schema), index.tsx (incur registration).

### demo command
Expand Down Expand Up @@ -138,6 +139,18 @@ Server-returned strings can contain ANSI escape sequences or control characters
- **Attacker-controlled data that does NOT flow through an SDK resource** — must be sanitized at its own parse boundary. `mpp pay` sanitizes the HTTP response in `readPayResult()` (`pay.tsx`); `mpp decode` sanitizes the parsed `WWW-Authenticate` challenge in `decodeStripeChallenge()` (`decode.ts`). These bypass the resource factory, so the return value of the parse/fetch helper is the chokepoint — sanitizing there covers both the interactive Ink render and the agent (toon/yaml/md) output at once.

JSON output mode (`--format json`) is **not** affected — `JSON.stringify` encodes escape sequences as Unicode literals.

## Security: Shell-Quoting Command Strings

Any string the CLI emits for an agent to *run* (`instruction`, `_next.command`, `_next.pay_command`) is a shell-injection sink. Agents commonly execute these through Bash, so interpolating an unquoted value there gives whoever controls that value command execution on the agent's host — even though the value was safe as an argv entry. Sanitization does not help: `$(...)`, backticks and `;` are ordinary printable characters.

Rules:

- Every dynamic value interpolated into a command string goes through `shellQuote()` from `packages/cli/src/utils/shell-quote.ts`, or the whole argv list through `shellCommand()`. This applies to server-issued IDs too — uniform treatment removes the "is this field trusted?" judgment call from future edits.
- Prefer emitting a **structured** continuation next to the string (`_next.pay_argv` = `{ command, args }`) and point agents at it. A list of arguments has no seam to smuggle syntax through; a string always does.
- Naive `'${value}'` wrapping is **not** quoting — a single `'` in the value closes it and escapes.
- Regression coverage lives in `packages/cli/src/utils/__tests__/shell-quote.test.ts` (bash round-trip) and the `_next continuation quoting` block in `packages/cli/src/__tests__/cli.test.ts`.

## Environment Variables

| Variable | Effect |
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,24 @@ card SpendRequest instead; do not create an LPT request.
| Rolling creation rate | 200 per 60 days |


Use `mpp pay` to complete purchases on merchants that use the [Machine Payments Protocol](https://mpp.dev). The spend request must use `credential_type: "shared_payment_token"` and you must approve it before paying. The SPT is one-time-use — if payment fails, create a new spend request.

```bash
link-cli mpp pay https://climate.stripe.dev/api/contribute \
--spend-request-id lsrq_001 \
--method POST \
--data '{"amount":100}' \
--header "X-Custom: value"
```

In agent mode (`--format json`), the full flow returns the payment continuation twice: as `_next.pay_argv` (`{ "command": "mpp", "args": [...] }`) and as `_next.pay_command`. Prefer `pay_argv` and invoke it directly, passing each `args` entry as its own process argument. The URL, body and headers can carry merchant-controlled text, so `pay_command` is shell-quoted for callers that must go through a shell — pass it to the shell verbatim, without unquoting or re-splitting it.

Use `mpp decode` to validate a raw `WWW-Authenticate` header and extract the `network_id` needed for `shared_payment_token` spend requests:

```bash
link-cli mpp decode \
--challenge 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge", request="..."'
```

### Report outcomes

Expand Down
130 changes: 130 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import { promisify } from 'node:util';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { storage } from '../auth/storage';
Expand Down Expand Up @@ -105,6 +107,22 @@ async function runProdCli(...args: string[]): Promise<CliResult> {
return runProdCliWithEnv({}, ...args);
}

async function runShell(command: string): Promise<CliResult> {
try {
const { stdout, stderr } = await execFileAsync('bash', ['-c', command], {
timeout: 10_000,
});
return { stdout, stderr, exitCode: 0 };
} catch (err: unknown) {
const e = err as { stdout?: string; stderr?: string; code?: number };
return {
stdout: e.stdout ?? '',
stderr: e.stderr ?? '',
exitCode: e.code ?? 1,
};
}
}

async function runProdCliWithEnv(
extraEnv: Record<string, string>,
...args: string[]
Expand Down Expand Up @@ -2790,6 +2808,118 @@ describe('production mode', () => {
'text/plain',
);
});

describe('_next continuation quoting', () => {
const PENDING_SPT_REQUEST = {
...BASE_REQUEST,
id: 'lsrq_spt_002',
status: 'pending_approval',
credential_type: 'shared_payment_token',
network_id: 'net_001',
approval_url: 'https://link.com/approve/lsrq_spt_002',
};

function payloadUrl(marker: string): string {
return `http://127.0.0.1:${merchantPort}/api/charge$(touch${'${IFS}'}${marker})`;
}

async function runFullFlow(url: string) {
setNextResponse(200, PENDING_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
'www-authenticate': WWW_AUTHENTICATE_STRIPE,
});

const result = await runProdCli(
'mpp',
'pay',
url,
'--context',
VALID_CONTEXT,
'--payment-method-id',
'pd_prod_test',
'--format',
'json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Array<{
_next: {
pay_command: string;
pay_argv: { command: string; args: string[] };
};
}>;
return output[0]._next;
}

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 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).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}'`);
});

it('does not execute the payload when pay_command is run through bash', async () => {
const marker = `${os.tmpdir()}/link-cli-injection-bash-${process.pid}`;
if (fs.existsSync(marker)) fs.unlinkSync(marker);

const next = await runFullFlow(payloadUrl(marker));

// `mpp` is not on PATH, so this fails — but an unquoted $(...) would
// still have been expanded by the shell before that failure.
await runShell(next.pay_command);

expect(fs.existsSync(marker)).toBe(false);
});

it('quotes payloads passed via --data and --header', async () => {
setNextResponse(200, PENDING_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
'www-authenticate': WWW_AUTHENTICATE_STRIPE,
});

const marker = `${os.tmpdir()}/link-cli-injection-flags-${process.pid}`;
if (fs.existsSync(marker)) fs.unlinkSync(marker);
const dataPayload = `{"a":"'; touch ${marker}; echo '"}`;

const result = await runProdCli(
'mpp',
'pay',
`http://127.0.0.1:${merchantPort}/api/charge`,
'--context',
VALID_CONTEXT,
'--payment-method-id',
'pd_prod_test',
'--data',
dataPayload,
'--header',
`X-Evil: $(touch${'${IFS}'}${marker})`,
'--format',
'json',
);

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Array<{
_next: {
pay_command: string;
pay_argv: { command: string; args: string[] };
};
}>;
const next = output[0]._next;

expect(next.pay_argv.args).toContain(dataPayload);
await runShell(next.pay_command);
expect(fs.existsSync(marker)).toBe(false);
});
});
});

describe('mpp decode', () => {
Expand Down
23 changes: 14 additions & 9 deletions packages/cli/src/commands/mpp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React from 'react';
import type { CliAuthStorage } from '../../auth/storage';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth } from '../../utils/require-auth';
import { shellCommand, shellQuote } from '../../utils/shell-quote';
import { decodeStripeChallenge } from './decode';
import { DecodeChallengeView } from './decode-view';
import {
Expand Down Expand Up @@ -159,23 +160,27 @@ export function createMppCli(
test: opts.test || undefined,
});

// Build the mpp pay command for _next with the spend request ID
const nextFlags = [`--spend-request-id ${spendRequest.id}`];
if (method) nextFlags.push(`-X ${method}`);
if (data) nextFlags.push(`-d '${data}'`);
// 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) nextFlags.push(`-H '${h}'`);
for (const h of headers) nextArgs.push('-H', h);
}
const nextCommand = `mpp pay ${url} ${nextFlags.join(' ')}`;
const nextCommand = `mpp ${shellCommand(nextArgs)}`;
const pollCommand = `spend-request retrieve ${shellQuote(spendRequest.id)} --interval 2 --max-attempts 300`;

// Yield approval URL and return — agent drives completion via _next
yield {
...spendRequest,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${spendRequest.id} --interval 2 --max-attempts 300\` to poll until approved. Once approved, run the _next.command to complete payment. Do not wait for the user to reply — start polling immediately.`,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`${pollCommand}\` to poll until approved. Once approved, run _next.pay_argv (preferred — invoke it directly without a shell) or _next.pay_command to complete payment. Do not wait for the user to reply — start polling immediately.`,
_next: {
poll_command: `spend-request retrieve ${spendRequest.id} --interval 2 --max-attempts 300`,
poll_command: pollCommand,
pay_command: nextCommand,
until: 'status changes from pending_approval, then run pay_command',
pay_argv: { command: 'mpp', args: nextArgs },
until: 'status changes from pending_approval, then run pay_argv',
},
};
},
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { pollUntil } from '../../utils/poll-until';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth, requireAuthGuard } from '../../utils/require-auth';
import { shellQuote } from '../../utils/shell-quote';
import { CancelSpendRequest } from './cancel';
import { CreateSpendRequest } from './create';
import { SpendRequestList } from './list';
Expand All @@ -38,11 +39,11 @@ function buildRequiresActionResult(request: SpendRequest) {
return {
...request,
instruction: isAutoResume
? `The spend request requires 3D Secure verification. Present action_url (${nextAction?.action_url}) to the user, then call \`spend-request retrieve ${request.id} --interval 2 --max-attempts 300\` to poll until it resolves. Do not create a new spend request — this one resumes automatically once the challenge is completed.`
? `The spend request requires 3D Secure verification. Present action_url (${nextAction?.action_url}) to the user, then call \`spend-request retrieve ${shellQuote(request.id)} --interval 2 --max-attempts 300\` to poll until it resolves. Do not create a new spend request — this one resumes automatically once the challenge is completed.`
: `The spend request requires action (${nextAction?.type}): ${nextAction?.display_message}${nextAction?.action_url ? ` URL: ${nextAction.action_url}` : ''} Have the user complete this, then create a new spend request.`,
_next: isAutoResume
? {
command: `spend-request retrieve ${request.id} --interval 2 --max-attempts 300`,
command: `spend-request retrieve ${shellQuote(request.id)} --interval 2 --max-attempts 300`,
until: 'status changes from requires_action',
}
: undefined,
Expand Down Expand Up @@ -363,9 +364,9 @@ export function createSpendRequestCli(
}
yield {
...created,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${created.id} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${shellQuote(created.id)} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`,
_next: {
command: `spend-request retrieve ${created.id} --interval 2 --max-attempts 300`,
command: `spend-request retrieve ${shellQuote(created.id)} --interval 2 --max-attempts 300`,
until: 'status changes from pending_approval',
},
};
Expand Down Expand Up @@ -486,9 +487,9 @@ export function createSpendRequestCli(
}
yield {
...approval,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${id} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`,
instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${shellQuote(id)} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`,
_next: {
command: `spend-request retrieve ${id} --interval 2 --max-attempts 300`,
command: `spend-request retrieve ${shellQuote(id)} --interval 2 --max-attempts 300`,
until: 'status changes from pending_approval',
},
};
Expand Down
80 changes: 80 additions & 0 deletions packages/cli/src/utils/__tests__/shell-quote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { execFileSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { shellCommand, shellQuote } from '../shell-quote';

const PAYLOADS = [
'https://merchant.example/pay$(touch /tmp/link-proof)',
'https://merchant.example/pay`touch /tmp/link-proof`',
'https://merchant.example/pay${IFS}x',
"https://merchant.example/pay'; touch /tmp/link-proof; echo '",
'https://merchant.example/pay; rm -rf /',
'https://merchant.example/pay && whoami',
'https://merchant.example/pay | tee /tmp/x',
'https://merchant.example/pay > /tmp/x',
'https://merchant.example/pay\ntouch /tmp/link-proof',
'https://merchant.example/pay with spaces',
"it's a trap",
"''",
'\\',
'!!',
'~/x',
'*',
'a\tb',
];

describe('shellQuote', () => {
it('leaves ordinary URLs and IDs unquoted', () => {
expect(shellQuote('https://merchant.example/pay')).toBe(
'https://merchant.example/pay',
);
expect(shellQuote('sr_123abc')).toBe('sr_123abc');
expect(shellQuote('POST')).toBe('POST');
});

it('quotes an empty string so it survives as an argument', () => {
expect(shellQuote('')).toBe("''");
});

it('quotes values carrying shell metacharacters', () => {
for (const payload of PAYLOADS) {
const quoted = shellQuote(payload);
expect(quoted.startsWith("'")).toBe(true);
expect(quoted.endsWith("'")).toBe(true);
}
});

it('escapes embedded single quotes rather than closing the quote', () => {
expect(shellQuote("a'b")).toBe(`'a'\\''b'`);
});

// The assertion that actually proves the encoder: hand the quoted value to a
// real shell and require the byte-for-byte original back.
it.each(PAYLOADS)('round-trips through bash: %j', (payload) => {
const out = execFileSync(
'bash',
['-c', `printf %s ${shellQuote(payload)}`],
{ encoding: 'utf8' },
);
expect(out).toBe(payload);
});

it('round-trips every argument of a multi-part command', () => {
const args = ['pay', PAYLOADS[0], '-d', '{"a":"it\'s"}', '-H', 'X: a b'];
const out = execFileSync(
'bash',
['-c', `for a in ${shellCommand(args)}; do printf '%s\\n' "$a"; done`],
{ encoding: 'utf8' },
);
expect(out.split('\n').slice(0, args.length)).toEqual(args);
});

it('does not execute a command substitution payload', () => {
const out = execFileSync(
'bash',
['-c', `printf %s ${shellQuote('$(echo pwned)')}`],
{ encoding: 'utf8' },
);
expect(out).toBe('$(echo pwned)');
expect(out).not.toContain('pwned\n');
});
});
Loading
Loading