From 928ce353a471b3b4606235fd2eed4bd5d400f529 Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Wed, 29 Jul 2026 18:28:30 +0530 Subject: [PATCH 1/2] sec: prevent ANSI escape control code injection in input fields (#3201) --- packages/core/src/utils/ansi.ts | 4 ++-- packages/ui/src/MultilineTextInput.test.ts | 15 +++++++++++++++ packages/ui/src/MultilineTextInput.ts | 10 +++++++--- packages/widgets/src/input/TextInput.test.ts | 19 +++++++++++++++++++ packages/widgets/src/input/TextInput.ts | 13 +++++++++---- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/ansi.ts b/packages/core/src/utils/ansi.ts index e8d33858b..7cfc7e697 100644 --- a/packages/core/src/utils/ansi.ts +++ b/packages/core/src/utils/ansi.ts @@ -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 diff --git a/packages/ui/src/MultilineTextInput.test.ts b/packages/ui/src/MultilineTextInput.test.ts index 47180b76d..282d3e221 100644 --- a/packages/ui/src/MultilineTextInput.test.ts +++ b/packages/ui/src/MultilineTextInput.test.ts @@ -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'); + }); +}); + diff --git a/packages/ui/src/MultilineTextInput.ts b/packages/ui/src/MultilineTextInput.ts index 289a7ecc8..24b0b9864 100644 --- a/packages/ui/src/MultilineTextInput.ts +++ b/packages/ui/src/MultilineTextInput.ts @@ -18,6 +18,7 @@ import { styleToCellAttrs, caps, stringWidth, + stripAnsiControl, } from '@termuijs/core'; export interface MultilineTextInputOptions { @@ -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); @@ -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(); } diff --git a/packages/widgets/src/input/TextInput.test.ts b/packages/widgets/src/input/TextInput.test.ts index 1e11dca1d..cd0d652d8 100644 --- a/packages/widgets/src/input/TextInput.test.ts +++ b/packages/widgets/src/input/TextInput.test.ts @@ -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'); + }); +}); + diff --git a/packages/widgets/src/input/TextInput.ts b/packages/widgets/src/input/TextInput.ts index e81265c21..692327970 100644 --- a/packages/widgets/src/input/TextInput.ts +++ b/packages/widgets/src/input/TextInput.ts @@ -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'; @@ -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(''); @@ -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(); @@ -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) { @@ -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(); From 40f592d1da35ce45bf9d581ec6f22d485e2a37fc Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Wed, 29 Jul 2026 23:27:52 +0530 Subject: [PATCH 2/2] sec: fix stripAnsiControl regex to correctly strip DCS/PM/APC payloads (#3201) - Fix alternative ordering in the regex: move DCS/PM/APC [PX^_] handler BEFORE the catch-all [@-Z\_] so that ESC P/X/^/_ is consumed by the specific handler (stripping the full payload), not by the catch-all (which left payload in output) - Broaden CSI final-byte range from [a-zA-Z] to [@-~] per ECMA-48 to cover sequences like ESC[2~ (Insert), ESC[15~ (F5) with ~ final byte - Add explicit SS2/SS3 (ESC N / ESC O) matching via [NO]. alternative - Fix incorrect test expectations: OSC and DCS should produce '' not partial payload - Add regression tests for DCS/PM/APC payload stripping, SS2/SS3, and CSI ~ Addresses security review comment from coderabbitai --- packages/core/src/utils/ansi.test.ts | 28 ++++++++++++++++++++++++---- packages/core/src/utils/ansi.ts | 7 +++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/ansi.test.ts b/packages/core/src/utils/ansi.test.ts index d86e1395e..8729522ce 100644 --- a/packages/core/src/utils/ansi.test.ts +++ b/packages/core/src/utils/ansi.test.ts @@ -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', () => { diff --git a/packages/core/src/utils/ansi.ts b/packages/core/src/utils/ansi.ts index 7cfc7e697..2941c1fee 100644 --- a/packages/core/src/utils/ansi.ts +++ b/packages/core/src/utils/ansi.ts @@ -228,10 +228,13 @@ export function notify(text: string): string { * to the terminal. */ export function stripAnsiControl(str: string): string { - // Remove all ESC-introduced sequences (OSC, CSI, 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(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-9;<=>?]*[a-zA-Z]|[@-Z\\_]|[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