From ff2541eb954ea6c0258a38b3a5158d9ea8f09f0b Mon Sep 17 00:00:00 2001 From: GautamSharma99 Date: Fri, 31 Jul 2026 17:36:55 +0530 Subject: [PATCH] fix: sanitize streamed terminal control sequences --- .changeset/safe-streaming-text.md | 5 ++ src/cli.tsx | 4 +- src/utils.ts | 114 ++++++++++++++++++++++++++++++ test/utils.test.ts | 30 +++++++- 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 .changeset/safe-streaming-text.md diff --git a/.changeset/safe-streaming-text.md b/.changeset/safe-streaming-text.md new file mode 100644 index 00000000..1e764de6 --- /dev/null +++ b/.changeset/safe-streaming-text.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +fix: strip terminal control sequences from streamed Markdown output diff --git a/src/cli.tsx b/src/cli.tsx index 0ff17717..a3a51dbb 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -41,7 +41,7 @@ import { isSecretLikeKey, sanitizeDiagnosticText, } from "./diagnostics.js"; -import { stripHtmlTags } from "./utils.js"; +import { stripHtmlTags, stripTerminalControlSequences } from "./utils.js"; import { type OpenWikiRunEvent, type OpenWikiRunResult, @@ -1523,7 +1523,7 @@ function getSpinnerFrame(frame: number): string { } function MarkdownText({ markdown }: { markdown: string }) { - const tokens = marked.lexer(markdown, { + const tokens = marked.lexer(stripTerminalControlSequences(markdown), { async: false, gfm: true, }); diff --git a/src/utils.ts b/src/utils.ts index 558e09c0..613bc7c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,3 +16,117 @@ export function stripHtmlTags(input: string): string { return output.replace(/[<>]/gu, ""); } + +/** + * Removes terminal control protocols from untrusted text before rendering it. + * Newlines and tabs remain useful Markdown whitespace; other C0/C1 controls + * are discarded rather than passed to a terminal emulator. + */ +export function stripTerminalControlSequences(input: string): string { + let output = ""; + + for (let index = 0; index < input.length;) { + const code = input.charCodeAt(index); + + if (code === 0x1b) { + index = skipEscapeSequence(input, index + 1); + continue; + } + + if (code >= 0x80 && code <= 0x9f) { + index = skipC1Sequence(input, index); + continue; + } + + if (code < 0x20 || code === 0x7f) { + if (code === 0x09 || code === 0x0a) { + output += input[index]; + } + index += 1; + continue; + } + + output += input[index]; + index += 1; + } + + return output; +} + +function skipEscapeSequence(input: string, index: number): number { + const introducer = input.charCodeAt(index); + + if (introducer === 0x5b) { + return skipCsiSequence(input, index + 1); + } + + if (introducer === 0x5d) { + return skipStringSequence(input, index + 1); + } + + // DCS, SOS, PM, and APC are ESC-prefixed string controls. + if ( + introducer === 0x50 || + introducer === 0x58 || + introducer === 0x5e || + introducer === 0x5f + ) { + return skipStringSequence(input, index + 1); + } + + // For a two-byte escape (for example, a charset select), discard the + // sequence introducer and its final byte. + return Math.min(input.length, index + 1); +} + +function skipC1Sequence(input: string, index: number): number { + const code = input.charCodeAt(index); + + if (code === 0x9b) { + return skipCsiSequence(input, index + 1); + } + + if ( + code === 0x9d || + code === 0x90 || + code === 0x98 || + code === 0x9e || + code === 0x9f + ) { + return skipStringSequence(input, index + 1); + } + + return index + 1; +} + +function skipCsiSequence(input: string, index: number): number { + while (index < input.length) { + const code = input.charCodeAt(index); + index += 1; + + // CSI final bytes are in the range 0x40-0x7e. + if (code >= 0x40 && code <= 0x7e) { + break; + } + } + + return index; +} + +function skipStringSequence(input: string, index: number): number { + while (index < input.length) { + const code = input.charCodeAt(index); + + if (code === 0x07 || code === 0x9c) { + return index + 1; + } + + if (code === 0x1b && input.charCodeAt(index + 1) === 0x5c) { + return index + 2; + } + + index += 1; + } + + return index; +} diff --git a/test/utils.test.ts b/test/utils.test.ts index 0492e454..fde5c12c 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { stripHtmlTags } from "../src/utils.ts"; +import { stripHtmlTags, stripTerminalControlSequences } from "../src/utils.ts"; describe("stripHtmlTags", () => { test("removes a complete tag pair", () => { @@ -39,3 +39,31 @@ describe("stripHtmlTags", () => { expect(stripHtmlTags("")).toBe(""); }); }); + +describe("stripTerminalControlSequences", () => { + test("removes OSC clipboard and hyperlink sequences", () => { + const value = + "before\u001b]52;c;SGVsbG8=\u0007middle\u001b]8;;https://evil.example\u0007link\u001b]8;;\u0007after"; + + expect(stripTerminalControlSequences(value)).toBe("beforemiddlelinkafter"); + }); + + test("removes CSI cursor/display controls and C1 controls", () => { + const value = "a\u001b[2J\u001b[H\u0080b\u009cc"; + + expect(stripTerminalControlSequences(value)).toBe("abc"); + }); + + test("removes BEL, carriage returns, and other C0 controls but keeps Markdown whitespace", () => { + expect(stripTerminalControlSequences("a\u0007\r\u0000b\tcode\nnext")).toBe( + "ab\tcode\nnext", + ); + }); + + test("removes unterminated OSC and DCS payloads", () => { + expect(stripTerminalControlSequences("safe\u001b]52;c;secret")).toBe( + "safe", + ); + expect(stripTerminalControlSequences("safe\u001bP1;2;secret")).toBe("safe"); + }); +});