Skip to content
Open
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ Key input field notes:
|------|--------|
| `--auth <path>` | Store auth credentials in a specific file instead of the default platform config location. `auth login` writes to this file; all other commands read from it. Parsed from `process.argv` and stripped before incur processes flags. |

## Security: Terminal Output Sanitization

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`:

- **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()`.

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

| Variable | Effect |
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@
"ink": "^5.2.1",
"ink-spinner": "^5.0.0",
"mppx": "0.6.16",
"viem": "^2.47.5",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"strip-ansi": "^7.2.0",
"update-notifier": "^7.3.1",
"viem": "^2.47.5",
"zod": "^4.3.6"
},
"devDependencies": {
Expand All @@ -45,6 +46,7 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.28",
"@types/update-notifier": "^6.0.8",
"ink-testing-library": "^4.0.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IPaymentMethodsResource } from '@stripe/link-sdk';
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { sanitizeResource } from '../../../utils/resource-factory';
import { PaymentMethodsList } from '../list';

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

describe('payment-methods', () => {
describe('sanitization', () => {
it('sanitizes brand and nickname in payment method list', async () => {
const resource = sanitizeResource({
listPaymentMethods: vi.fn(async () => [
{
id: 'pm_1',
card_details: { brand: ESCAPE_PAYLOAD, last4: '4242' },
bank_account_details: null,
nickname: ESCAPE_PAYLOAD,
is_default: false,
},
]),
} as unknown as IPaymentMethodsResource);

const { lastFrame } = render(
<PaymentMethodsList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});
});
});
4 changes: 1 addition & 3 deletions packages/cli/src/commands/payment-methods/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ export const PaymentMethodsList: React.FC<PaymentMethodsListProps> = ({
'Bank account';
const last4 =
pm.card_details?.last4 ?? pm.bank_account_details?.last4;
const suffix = [pm.nickname ? `(${pm.nickname})` : '']
.filter(Boolean)
.join(' ');
const suffix = pm.nickname ? `(${pm.nickname})` : '';
return (
<Box key={pm.id} paddingX={2}>
<Text>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk';
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { sanitizeResource } from '../../../utils/resource-factory';
import { CreateSpendRequest } from '../create';
import { RetrieveSpendRequest } from '../retrieve';
import { UpdateSpendRequest } from '../update';

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

function makeSpendRequest(overrides: Partial<SpendRequest> = {}): SpendRequest {
return {
id: 'sr_test',
status: 'approved',
amount: 1000,
currency: 'usd',
merchant_name: ESCAPE_PAYLOAD,
merchant_url: 'https://example.com',
context: 'x'.repeat(100),
credential_type: 'card',
payment_details: 'pm_1',
line_items: [{ name: ESCAPE_PAYLOAD }],
totals: [{ type: 'total', amount: 1000, display_text: 'Total' }],
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
approval_url: '',
card: undefined,
shared_payment_token: undefined,
...overrides,
} as SpendRequest;
}

function makeMockRepo(result: SpendRequest) {
return sanitizeResource({
createSpendRequest: vi.fn(async () => result),
getSpendRequest: vi.fn(async () => result),
updateSpendRequest: vi.fn(async () => result),
requestApproval: vi.fn(async () => result),
cancelSpendRequest: vi.fn(async () => result),
} as unknown as ISpendRequestResource);
}

describe('spend-request', () => {
describe('sanitization', () => {
it('CreateSpendRequest sanitizes merchant_name and line_items', async () => {
const request = makeSpendRequest();
const repo = makeMockRepo(request);

const { lastFrame } = render(
<CreateSpendRequest
repository={repo}
params={{
payment_details: 'pm_1',
amount: 1000,
currency: 'usd',
merchant_name: 'test',
merchant_url: 'https://example.com',
context: 'x'.repeat(100),
}}
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Merchant');
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});

it('UpdateSpendRequest sanitizes merchant_name and line_items', async () => {
const request = makeSpendRequest();
const repo = makeMockRepo(request);

const { lastFrame } = render(
<UpdateSpendRequest
repository={repo}
id="sr_test"
params={{ amount: 2000 }}
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Merchant');
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});

it('RetrieveSpendRequest sanitizes merchant_name, line_items, and billing_address', async () => {
const request = makeSpendRequest({
card: {
id: 'card_1',
number: '4242424242424242',
brand: 'visa',
exp_month: 12,
exp_year: 2030,
cvc: '123',
valid_until: '2025-12-31',
billing_address: {
name: ESCAPE_PAYLOAD,
line1: ESCAPE_PAYLOAD,
city: 'Test City',
state: 'TS',
postal_code: '12345',
country: 'US',
},
},
});
const repo = makeMockRepo(request);

const { lastFrame } = render(
<RetrieveSpendRequest
repository={repo}
id="sr_test"
onComplete={() => {}}
/>,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Billing Address');
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});
});
});
79 changes: 79 additions & 0 deletions packages/cli/src/utils/__tests__/sanitize-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { sanitizeDeep, sanitizeText } from '../sanitize-text';

describe('sanitizeText', () => {
it('returns empty string for null/undefined', () => {
expect(sanitizeText(null)).toBe('');
expect(sanitizeText(undefined)).toBe('');
expect(sanitizeText('')).toBe('');
});

it('passes through normal text unchanged', () => {
expect(sanitizeText('Hello World')).toBe('Hello World');
expect(sanitizeText('Café & Résumé')).toBe('Café & Résumé');
});

it('strips CSI sequences (colors, cursor movement)', () => {
expect(sanitizeText('\x1b[2JScreen Cleared')).toBe('Screen Cleared');
expect(sanitizeText('\x1b[1A\x1b[2KOverwrite')).toBe('Overwrite');
expect(sanitizeText('\x1b[31mRed Text\x1b[0m')).toBe('Red Text');
});

it('strips OSC sequences (window title, hyperlinks)', () => {
expect(sanitizeText('\x1b]0;PWNED\x07Normal Store')).toBe('Normal Store');
expect(sanitizeText('\x1b]8;;https://evil.com\x07Click\x1b]8;;\x07')).toBe(
'Click',
);
});

it('strips carriage returns', () => {
expect(sanitizeText('Legit Store\rEvil Store')).toBe(
'Legit StoreEvil Store',
);
});

it('strips other control characters', () => {
expect(sanitizeText('Hello\x00World')).toBe('HelloWorld');
expect(sanitizeText('Tab\x09is fine but \x01 is not')).toBe(
'Tab\tis fine but is not',
);
});

it('preserves newlines and tabs', () => {
expect(sanitizeText('Line1\nLine2')).toBe('Line1\nLine2');
expect(sanitizeText('Col1\tCol2')).toBe('Col1\tCol2');
});

it('handles compound attack payloads', () => {
const payload = '\x1b[2J\x1b[1;1H\x1b]0;Hijacked\x07\rAmount: $0.01';
expect(sanitizeText(payload)).toBe('Amount: $0.01');
});
});

describe('sanitizeDeep', () => {
it('sanitizes strings in nested objects', () => {
const input = {
merchant_name: '\x1b[2JEvil',
amount: 1000,
line_items: [{ name: '\rHidden' }],
card: { billing_address: { name: '\x1b]0;X\x07Test' } },
};
const result = sanitizeDeep(input);
expect(result.merchant_name).toBe('Evil');
expect(result.amount).toBe(1000);
expect(result.line_items[0].name).toBe('Hidden');
expect(result.card.billing_address.name).toBe('Test');
});

it('passes through null, undefined, and primitives', () => {
expect(sanitizeDeep(null)).toBe(null);
expect(sanitizeDeep(undefined)).toBe(undefined);
expect(sanitizeDeep(42)).toBe(42);
expect(sanitizeDeep(true)).toBe(true);
});

it('sanitizes arrays of strings', () => {
const input = ['\x1b[2JA', 'B', '\rC'];
expect(sanitizeDeep(input)).toEqual(['A', 'B', 'C']);
});
});
Loading
Loading