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
28 changes: 24 additions & 4 deletions packages/core/src/utils/ansi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,32 @@ describe('stripAnsiControl', () => {
expect(stripAnsiControl('Hello World')).toBe('Hello World');
});

it('strips OSC sequences truncated by catch-all', () => {
expect(stripAnsiControl('\x1b]0;My App\x07')).toBe('0;My App');
it('strips OSC sequences including full payload (title, clipboard, etc.)', () => {
// OSC with BEL terminator: ESC ] params BEL
expect(stripAnsiControl('\x1b]0;My App\x07')).toBe('');
// OSC with ST terminator: ESC ] params ESC \\
expect(stripAnsiControl('\x1b]0;Title\x1b\\')).toBe('');
});

it('strips DCS sequences truncated by catch-all', () => {
expect(stripAnsiControl('\x1bPsome data\x1b\\')).toBe('some data');
it('strips DCS sequences including full payload (regression: old regex left payload in output)', () => {
// DCS: ESC P payload ESC \\ — payload must NOT appear in output
expect(stripAnsiControl('\x1bPsome data\x1b\\')).toBe('');
// PM: ESC ^ payload ESC \\
expect(stripAnsiControl('\x1b^private data\x1b\\')).toBe('');
// APC: ESC _ payload ESC \\
expect(stripAnsiControl('\x1b_app data\x1b\\')).toBe('');
});

it('strips SS2 and SS3 sequences (ESC N / ESC O + one character)', () => {
expect(stripAnsiControl('\x1bNa')).toBe('');
expect(stripAnsiControl('\x1bOa')).toBe('');
});

it('strips CSI sequences with numeric final bytes (e.g. ~ for function keys)', () => {
// ESC [ 2 ~ = Insert key — final byte is ~ (0x7E), not alpha
expect(stripAnsiControl('\x1b[2~')).toBe('');
// ESC [ 1 5 ~ = F5
expect(stripAnsiControl('\x1b[15~')).toBe('');
});

it('handles empty string', () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/utils/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,13 @@ export function notify(text: string): string {
* to the terminal.
*/
export function stripAnsiControl(str: string): string {
// Remove all ESC-introduced sequences (CSI, OSC, DCS, SS2/SS3, etc.)
// Remove all ESC-introduced sequences (OSC, CSI, DCS, PM, APC, SS2/SS3, etc.)
// Ordering matters: specific multi-byte handlers must appear before the [@-Z\_] catch-all,
// otherwise `ESC P/X/^/_` would be consumed by the catch-all before the DCS/PM/APC
// handler runs, leaving the payload text in the output.
// eslint-disable-next-line no-control-regex
let out = str.replace(
/\x1b(?:[@-Z\\-_]|\[[0-9;<=>?]*[a-zA-Z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[PX^_][^\x1b]*\x1b\\|.)/g,
/\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|[PX^_][^\x1b]*\x1b\\|\[[0-?]*[ -/]*[@-~]|[NO].|[@-Z\\_]|.)/g,
''
);
// Remove remaining bare C0 controls (keep TAB=0x09, LF=0x0A) and C1/DEL
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/MultilineTextInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,3 +698,18 @@ describe('MultilineTextInput — unicode and cursor rendering', () => {
expect(() => renderInput(input, 12, 4)).not.toThrow();
});
});

describe('MultilineTextInput — ANSI Control Code Security Sanitization', () => {
it('strips ANSI escape sequences and control codes from value assignment', () => {
const input = makeInput();
input.value = '\x1b[2J\x1b[32mLine 1\x1b[0m\n\x1b]0;Title\x07Line 2';
expect(input.value).toBe('Line 1\nLine 2');
});

it('strips injected ANSI control characters in insertChar()', () => {
const input = makeInput();
input.insertChar('\x1b[2J\x1b]0;Title\x07a');
expect(input.value).toBe('a');
});
});

10 changes: 7 additions & 3 deletions packages/ui/src/MultilineTextInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
styleToCellAttrs,
caps,
stringWidth,
stripAnsiControl,
} from '@termuijs/core';

export interface MultilineTextInputOptions {
Expand Down Expand Up @@ -54,7 +55,8 @@ export class MultilineTextInput extends Widget {

/** Set text programmatically. */
set value(v: string) {
this._lines = v.split('\n');
const sanitizedVal = stripAnsiControl(v);
this._lines = sanitizedVal.split('\n');
if (this._lines.length === 0) this._lines = [''];
this._cursorLine = Math.min(this._cursorLine, this._lines.length - 1);
this._cursorCol = Math.min(this._cursorCol, this._lines[this._cursorLine].length);
Expand All @@ -74,10 +76,12 @@ export class MultilineTextInput extends Widget {

/** Insert a single printable character at the cursor position. */
insertChar(char: string): void {
const sanitizedChar = stripAnsiControl(char);
if (!sanitizedChar) return;
const line = this._lines[this._cursorLine];
this._lines[this._cursorLine] =
line.slice(0, this._cursorCol) + char + line.slice(this._cursorCol);
this._cursorCol++;
line.slice(0, this._cursorCol) + sanitizedChar + line.slice(this._cursorCol);
this._cursorCol += sanitizedChar.length;
this._notify();
}

Expand Down
19 changes: 19 additions & 0 deletions packages/widgets/src/input/TextInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,22 @@ describe('Performance optimizations', () => {
expect(input.isDirty).toBe(false);
});
});

describe('ANSI Control Code Security Sanitization', () => {
it('strips ANSI escape sequences and control codes by default (raw: false)', () => {
const input = new TextInput({}, { value: '\x1b[2J\x1b[31mHello\x1b[0m' });
expect(input.value).toBe('Hello');
});

it('strips injected ANSI control codes in insertChar() when raw: false', () => {
const input = new TextInput();
input.insertChar('\x1b[2J\x1b]0;HackTitle\x07x');
expect(input.value).toBe('x');
});

it('preserves ANSI escape sequences when raw: true option is specified', () => {
const input = new TextInput({}, { value: '\x1b[31mRed\x1b[0m', raw: true });
expect(input.value).toBe('\x1b[31mRed\x1b[0m');
});
});

13 changes: 9 additions & 4 deletions packages/widgets/src/input/TextInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type KeyEvent,
splitGraphemes,
stripAnsiEscapes,
stripAnsiControl,
} from '@termuijs/core';
import { Widget } from '../base/Widget.js';
import { type VimMode } from './vim.js';
Expand Down Expand Up @@ -65,7 +66,8 @@ export class TextInput extends Widget {
this.signal = options.signal;
this._raw = options.raw ?? false;

const initialVal = options.value ?? '';
const rawInitialVal = options.value ?? '';
const initialVal = this._raw ? rawInitialVal : stripAnsiControl(rawInitialVal);
const graphemes = splitGraphemes(initialVal);
if (graphemes.length > this._maxLength) {
this._value = graphemes.slice(0, this._maxLength).join('');
Expand All @@ -90,11 +92,12 @@ export class TextInput extends Widget {
}

set value(v: string) {
const graphemes = splitGraphemes(v);
const sanitizedVal = this._raw ? v : stripAnsiControl(v);
const graphemes = splitGraphemes(sanitizedVal);
if (graphemes.length > this._maxLength) {
this._value = graphemes.slice(0, this._maxLength).join('');
} else {
this._value = v;
this._value = sanitizedVal;
}
this._cursorPos = Math.min(this._cursorPos, splitGraphemes(this._value).length);
this._clearSelection();
Expand Down Expand Up @@ -136,6 +139,8 @@ export class TextInput extends Widget {
}

insertChar(char: string): void {
const sanitizedChar = this._raw ? char : stripAnsiControl(char);
if (!sanitizedChar) return;
const graphemes = splitGraphemes(this._value);
const deletedSelection = this._deleteSelectionFrom(graphemes);
if (graphemes.length >= this._maxLength) {
Expand All @@ -146,7 +151,7 @@ export class TextInput extends Widget {
}
return;
}
graphemes.splice(this._cursorPos, 0, char);
graphemes.splice(this._cursorPos, 0, sanitizedChar);
this._value = graphemes.join('');
this._cursorPos++;
this._clearSelection();
Expand Down
Loading