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
4 changes: 2 additions & 2 deletions packages/core/src/utils/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,10 @@ 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, SS2/SS3, etc.)
// 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\\)|\[[0-9;<=>?]*[a-zA-Z]|[@-Z\\_]|[PX^_][^\x1b]*\x1b\\|.)/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