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/true-words-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stripe/link-cli": patch
---

Sanitize output of decode call during mpp flow
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,10 @@ Key input field notes:

Server-returned strings can contain ANSI escape sequences or control characters that spoof the terminal approval UI. Sanitization is handled automatically via `sanitizeDeep()` from `packages/cli/src/utils/sanitize-text.ts`:

- **SDK-resource data** — sanitized automatically at the `sanitizeResource()` proxy boundary in `packages/cli/src/utils/resource-factory.ts`. All server data flowing through SDK resources (spend-request, payment-methods, sources, etc.) is `sanitizeDeep()`'d before reaching components or the incur formatter, in every output format.
- **Commands using `useAsyncAction` hook** — sanitized automatically. The hook calls `sanitizeDeep()` on all returned data before it reaches components.
- **Commands with manual state management** (e.g. `create.tsx`, `retrieve.tsx`, `request-approval.tsx`, `mpp/pay.tsx`) — must call `sanitizeDeep()` on API responses before calling `setRequest()`/`setState()`.
- **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.
## Environment Variables
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/commands/mpp/decode-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render } from 'ink-testing-library';
import { describe, expect, it } from 'vitest';
import { decodeStripeChallenge } from './decode';
import { DecodeChallengeView } from './decode-view';

const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden';
const CLEAN_TEXT = 'EvilHidden';

function encodeRequest(request: Record<string, unknown>): string {
return Buffer.from(JSON.stringify(request)).toString('base64');
}

describe('DecodeChallengeView', () => {
it('renders no raw ANSI escapes for an attacker-controlled challenge', () => {
// The challenge string is fully attacker-controlled. Sanitization happens
// at the decode.ts boundary, so render the real decoded output rather than
// a hand-built object.
const header = [
`Payment id="${ESCAPE_PAYLOAD}",`,
`realm="${ESCAPE_PAYLOAD}",`,
'method="stripe",',
'intent="charge",',
`request="${encodeRequest({
amount: '1000',
currency: 'usd',
merchantName: ESCAPE_PAYLOAD,
methodDetails: {
networkId: 'net_001',
paymentMethodTypes: ['card'],
},
})}"`,
].join(' ');

const decoded = decodeStripeChallenge(header);
const { lastFrame } = render(<DecodeChallengeView decoded={decoded} />);

const frame = lastFrame() ?? '';
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});
37 changes: 37 additions & 0 deletions packages/cli/src/commands/mpp/decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,41 @@ describe('decodeStripeChallenge', () => {
},
});
});

it('strips ANSI escape and control characters from decoded fields', () => {
const payload = '\x1b[2JEvil\rHidden';
const clean = 'EvilHidden';
const header = [
`Payment id="${payload}",`,
`realm="${payload}",`,
'method="stripe",',
'intent="charge",',
`description="${payload}",`,
`request="${encodeRequest({
amount: '1000',
currency: 'usd',
merchantName: payload,
methodDetails: {
networkId: 'net_001',
paymentMethodTypes: ['card'],
},
})}"`,
].join(' ');

const decoded = decodeStripeChallenge(header);
expect(decoded).toMatchObject({
id: clean,
realm: clean,
description: clean,
network_id: 'net_001',
request_json: {
amount: '1000',
currency: 'usd',
merchantName: clean,
},
});
const serialized = JSON.stringify(decoded);
expect(serialized).not.toContain('\x1b[2J');
expect(serialized).not.toContain('\r');
});
});
5 changes: 3 additions & 2 deletions packages/cli/src/commands/mpp/decode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Challenge } from 'mppx';
import { sanitizeDeep } from '../../utils/sanitize-text';

type StripeChargeChallenge = Challenge.Challenge<
Record<string, unknown>,
Expand Down Expand Up @@ -118,7 +119,7 @@ export function decodeStripeChallenge(
Challenge.deserializeList(challengeHeader),
);

return {
return sanitizeDeep({
id: challenge.id,
realm: challenge.realm,
method: 'stripe',
Expand All @@ -128,5 +129,5 @@ export function decodeStripeChallenge(
expires: challenge.expires,
network_id: networkId,
request_json: request,
};
});
}
Loading