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
12 changes: 7 additions & 5 deletions src/commands/emails/receiving/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { GlobalOpts } from '../../../lib/client';
import { requireClient } from '../../../lib/client';
import { buildHelpText } from '../../../lib/help-text';
import { errorMessage, outputError } from '../../../lib/output';
import { safeTerminalText } from '../../../lib/safe-terminal-text';
import { createSpinner } from '../../../lib/spinner';
import { isInteractive } from '../../../lib/tty';

Expand All @@ -19,14 +20,15 @@ function displayEmail(email: ListReceivingEmail, jsonMode: boolean): void {
if (jsonMode) {
console.log(JSON.stringify(email));
} else {
const to = email.to.join(', ');
const to = email.to.map(safeTerminalText).join(', ');
const ts = pc.dim(`[${timestamp()}]`);
const rawSubject = safeTerminalText(email.subject);
const subject =
email.subject.length > 50
? `${email.subject.slice(0, 47)}...`
: email.subject;
rawSubject.length > 50 ? `${rawSubject.slice(0, 47)}...` : rawSubject;
const from = safeTerminalText(email.from);
const id = safeTerminalText(email.id);
process.stderr.write(
`${ts} ${email.from} -> ${to} ${pc.bold(`"${subject}"`)} ${pc.dim(email.id)}\n`,
`${ts} ${from} -> ${to} ${pc.bold(`"${subject}"`)} ${pc.dim(id)}\n`,
);
}
}
Expand Down
15 changes: 9 additions & 6 deletions src/commands/webhooks/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { requireClient } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { outputError } from '../../lib/output';
import { requireText } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';
import { createSpinner } from '../../lib/spinner';
import { isInteractive } from '../../lib/tty';
import { ALL_WEBHOOK_EVENTS, normalizeEvents } from './utils';
Expand All @@ -27,22 +28,24 @@ function summarizeEvent(body: Record<string, unknown>): {
resourceId: string;
detail: string;
} {
const type = (body.type as string) ?? 'unknown';
const type = safeTerminalText((body.type as string) ?? 'unknown');
const data = (body.data as Record<string, unknown>) ?? {};

const resourceId = (data.id as string) ?? '';
const resourceId = safeTerminalText((data.id as string) ?? '');

let detail = '';
if (type.startsWith('email.')) {
const from = (data.from as string) ?? '';
const to = Array.isArray(data.to) ? (data.to[0] as string) : '';
const from = safeTerminalText((data.from as string) ?? '');
const to = safeTerminalText(
Array.isArray(data.to) ? (data.to[0] as string) : '',
);
if (from || to) {
detail = `${from} -> ${to}`;
}
} else if (type.startsWith('domain.')) {
detail = (data.name as string) ?? '';
detail = safeTerminalText((data.name as string) ?? '');
} else if (type.startsWith('contact.')) {
detail = (data.email as string) ?? '';
detail = safeTerminalText((data.email as string) ?? '');
}

return { type, resourceId, detail };
Expand Down
7 changes: 4 additions & 3 deletions src/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireClient } from './client';
import type { ApiKeyPermission } from './config';
import { outputResult } from './output';
import { confirmDelete } from './prompts';
import { deepSanitize } from './safe-terminal-text';
import { withSpinner } from './spinner';
import { isInteractive } from './tty';

Expand Down Expand Up @@ -35,7 +36,7 @@ export async function runGet<T>(
globalOpts,
);
if (!globalOpts.json && isInteractive()) {
config.onInteractive(data);
config.onInteractive(deepSanitize(data));
} else {
outputResult(data, { json: globalOpts.json });
}
Expand Down Expand Up @@ -105,7 +106,7 @@ export async function runCreate<T>(
globalOpts,
);
if (!globalOpts.json && isInteractive()) {
config.onInteractive(data);
config.onInteractive(deepSanitize(data));
} else {
outputResult(data, { json: globalOpts.json });
}
Expand Down Expand Up @@ -170,7 +171,7 @@ export async function runList<T>(
globalOpts,
);
if (!globalOpts.json && isInteractive()) {
config.onInteractive(result);
config.onInteractive(deepSanitize(result));
} else {
outputResult(result, { json: globalOpts.json });
}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/safe-terminal-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { stripVTControlCharacters } from 'node:util';

// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this regex strips dangerous C0 control chars
const DANGEROUS_CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\r(?!\n)/g;

export const safeTerminalText = (value: unknown): string =>
stripVTControlCharacters(String(value)).replace(DANGEROUS_CONTROL_CHARS, '');

export function deepSanitize<T>(value: T): T {
if (typeof value === 'string') {
return safeTerminalText(value) as T;
}
if (Array.isArray(value)) {
return value.map(deepSanitize) as T;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, deepSanitize(v)]),
) as T;
}
return value;
}
8 changes: 5 additions & 3 deletions src/lib/table.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeTerminalText } from './safe-terminal-text';
import { isUnicodeSupported } from './tty';

// All box-drawing characters generated via String.fromCodePoint() — never literal
Expand Down Expand Up @@ -62,16 +63,17 @@ export function renderTable(
if (rows.length === 0) {
return emptyMessage;
}
const sanitizedRows = rows.map((r) => r.map(safeTerminalText));
const widths = headers.map((h, i) =>
Math.max(h.length, ...rows.map((r) => r[i].length)),
Math.max(h.length, ...sanitizedRows.map((r) => r[i].length)),
);

const termWidth = getTerminalWidth();
if (termWidth !== undefined) {
const totalWidth =
widths.reduce((s, w) => s + w, 0) + 3 * widths.length + 1;
if (totalWidth > termWidth) {
return renderCards(headers, rows, termWidth);
return renderCards(headers, sanitizedRows, termWidth);
}
}

Expand All @@ -87,5 +89,5 @@ export function renderTable(
cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) +
' ' +
BOX.v;
return [top, row(headers), mid, ...rows.map(row), bot].join('\n');
return [top, row(headers), mid, ...sanitizedRows.map(row), bot].join('\n');
}
105 changes: 105 additions & 0 deletions tests/lib/safe-terminal-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
deepSanitize,
safeTerminalText,
} from '../../src/lib/safe-terminal-text';

describe('safeTerminalText', () => {
it('returns plain strings unchanged', () => {
expect(safeTerminalText('hello world')).toBe('hello world');
});

it('coerces non-string values via String()', () => {
expect(safeTerminalText(42)).toBe('42');
expect(safeTerminalText(null)).toBe('null');
expect(safeTerminalText(undefined)).toBe('undefined');
expect(safeTerminalText(true)).toBe('true');
});

it('strips ANSI escape sequences', () => {
expect(safeTerminalText('\u001b[31mred text\u001b[0m')).toBe('red text');
});

it('strips OSC hyperlink sequences', () => {
const input =
'\u001b]8;;https://attacker.example\u0007CLICK HERE\u001b]8;;\u0007';
expect(safeTerminalText(input)).toBe('CLICK HERE');
});

it('strips cursor movement sequences', () => {
expect(safeTerminalText('\u001b[2K\u001b[1Aspoofed')).toBe('spoofed');
});

it('removes NUL and other C0 control characters', () => {
expect(safeTerminalText('a\x00b\x01c\x02d')).toBe('abcd');
});

it('preserves tabs and newlines', () => {
expect(safeTerminalText('line1\nline2\ttab')).toBe('line1\nline2\ttab');
});

it('removes DEL character (0x7F)', () => {
expect(safeTerminalText('abc\x7Fdef')).toBe('abcdef');
});

it('handles combined VT + C0 control characters', () => {
const input = '\u001b[1mBOLD\u001b[0m\x00\x01hidden';
expect(safeTerminalText(input)).toBe('BOLDhidden');
});

it('preserves CRLF but strips bare carriage return', () => {
expect(safeTerminalText('line1\r\nline2')).toBe('line1\r\nline2');
expect(safeTerminalText('safe\roverwrite')).toBe('safeoverwrite');
});

it('handles empty string input', () => {
expect(safeTerminalText('')).toBe('');
});

it('strips title-setting OSC sequences', () => {
expect(safeTerminalText('\u001b]0;evil-title\u0007safe')).toBe('safe');
});
});

describe('deepSanitize', () => {
it('sanitizes all string values in a flat object', () => {
const input = { name: '\u001b[31mred\u001b[0m', id: 'abc' };
expect(deepSanitize(input)).toEqual({ name: 'red', id: 'abc' });
});

it('sanitizes nested objects', () => {
const input = { data: { from: '\u001b[1mbold\u001b[0m' } };
expect(deepSanitize(input)).toEqual({ data: { from: 'bold' } });
});

it('sanitizes arrays of strings', () => {
const input = { to: ['\u001b[31ma\u001b[0m', 'b'] };
expect(deepSanitize(input)).toEqual({ to: ['a', 'b'] });
});

it('passes through numbers, booleans, and null', () => {
const input = { count: 42, active: true, meta: null };
expect(deepSanitize(input)).toEqual({
count: 42,
active: true,
meta: null,
});
});

it('handles a realistic API response', () => {
const input = {
id: 'abc-123',
from: '\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007',
to: ['user@example.com'],
subject: 'Hello\x00World',
created_at: '2025-01-01',
};
expect(deepSanitize(input)).toEqual({
id: 'abc-123',
from: 'click',
to: ['user@example.com'],
subject: 'HelloWorld',
created_at: '2025-01-01',
});
});
});