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
5 changes: 5 additions & 0 deletions .changeset/safe-streaming-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

fix: strip terminal control sequences from streamed Markdown output
4 changes: 2 additions & 2 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
114 changes: 114 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
30 changes: 29 additions & 1 deletion test/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});