Skip to content

Commit 103cdbf

Browse files
olafuraclaude
andcommitted
fix(server): stop terminal escape sequences leaking as garbled text
Returning to a terminal — and live use — leaked garbage like "69;0$y2026;2$y", "1;2c", "11;rgb:…" onto the screen (web and TUI both render the server's sanitized stream), and a prompt that re-queries on redraw could amplify it into a runaway flood. Reported in #1238. Fixed from both directions: - Input: the browser emulator auto-answers the program's capability queries (DECRPM/DA/DSR/OSC-colour) and emits focus events, sending them as PTY input; at an idle prompt the shell echoes them and the loop runs away. Strip that whole terminal→host response class from client input at the source (terminal.write) so it never reaches the shell. Cursor-position reports and bare query forms are kept (programs block on those). - Output / scrollback: one single-pass sanitizer (sanitizeTerminalChunkDual) emits both the scrollback view (drops queries AND responses, so a replay can't re-trigger an echo) and the live view (drops only responses, relays queries). Covers CSI (DECRQM "$p"/DECRPM "$y", DA, DSR, CPR, 8-bit C1), OSC 10/11/12 colour, and DCS (DECRQSS/DECRPSS), leaving sixel/DECUDK alone. - History on load: readHistory now sanitizes the persisted log (older builds wrote it raw), and also drops the *flattened* residue a shell echoes once the ESC introducer is gone — runs of DECRPM "$y" / DA "<m>;<v>c" / OSC colour (incl. OSC 4 palette), plus those distinctive tokens when isolated — which the escape-aware strip can't see. Ambiguous lone tokens and ordinary words are preserved. Validated against a real 1.5 GB dataset (970 KB polluted log: $y→0, rgb: 9070→<50, prompt text intact). Tests cover every class for both views, 8-bit C1, split-across-chunks, within-chunk divergence, the input strip, the flattened residue, and load-time sanitize of a raw #1238-residue log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a4964b3 commit 103cdbf

2 files changed

Lines changed: 552 additions & 40 deletions

File tree

apps/server/src/terminal/Manager.test.ts

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
2-
import { assert, it } from "@effect/vitest";
2+
import { assert, describe, it } from "@effect/vitest";
33
import {
44
DEFAULT_TERMINAL_ID,
55
type TerminalAttachStreamEvent,
@@ -28,6 +28,7 @@ import { expect } from "vite-plus/test";
2828

2929
import * as ProcessRunner from "../processRunner.ts";
3030
import * as TerminalManager from "./Manager.ts";
31+
import { sanitizeTerminalHistoryChunk, stripTerminalResponsesFromInput } from "./Manager.ts";
3132
import * as PtyAdapter from "./PtyAdapter.ts";
3233

3334
class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{
@@ -991,6 +992,23 @@ it.layer(
991992
}),
992993
);
993994

995+
it.effect("sanitizes a pre-existing raw history log on load (older builds wrote it dirty)", () =>
996+
Effect.gen(function* () {
997+
const { manager, logsDir } = yield* createManager();
998+
const logPath = yield* historyLogPath(logsDir);
999+
// A log an older build persisted without sanitizing: the exact repeating
1000+
// DECRPM residue from #1238, ESC introducers intact. On load it must be
1001+
// stripped so it cannot replay (and re-trigger) at the prompt.
1002+
const garble =
1003+
"[?69;0$y[?2026;2$y[?2027;0$y[?2031;0$y[?2048;0$y";
1004+
yield* writeFileString(logPath, `prompt$ ${garble.repeat(15)}done\n`);
1005+
1006+
const opened = yield* manager.open(openInput());
1007+
assert.equal(opened.history, "prompt$ done\n");
1008+
// The cleaned history is persisted back, so it stays clean on re-read.
1009+
assert.equal(yield* readFileString(logPath), "prompt$ done\n");
1010+
}),
1011+
);
9941012
it.effect(
9951013
"preserves clear and style control sequences while dropping chunk-split query traffic",
9961014
() =>
@@ -1645,3 +1663,265 @@ it.layer(
16451663
}).pipe(Effect.provide(TestClock.layer())),
16461664
);
16471665
});
1666+
1667+
describe("sanitizeTerminalHistoryChunk", () => {
1668+
const sanitize = (data: string, pending = "") => sanitizeTerminalHistoryChunk(pending, data);
1669+
1670+
it("strips DECRPM mode reports (CSI ? Pm ; Ps $ y) from history", () => {
1671+
const reports = "\x1b[?69;0$y\x1b[?2026;2$y\x1b[?2048;0$y";
1672+
const { visibleText } = sanitize(`before${reports}after`);
1673+
assert.equal(visibleText, "beforeafter");
1674+
// The residue users were seeing must not survive.
1675+
assert.ok(!visibleText.includes("$y"));
1676+
assert.ok(!visibleText.includes("2026"));
1677+
});
1678+
1679+
it("strips DECRQM mode queries (CSI ? Pm $ p) so replay can't re-trigger them", () => {
1680+
const { visibleText } = sanitize("x\x1b[?2026$p\x1b[?2048$py");
1681+
assert.equal(visibleText, "xy");
1682+
});
1683+
1684+
it("keeps ordinary text and non-report CSI sequences", () => {
1685+
// SGR colour (m) and cursor moves stay; a plain 'p'/'y' without the `$`
1686+
// intermediate is not a mode sequence and must be preserved.
1687+
const { visibleText } = sanitize("\x1b[31mred\x1b[0m \x1b[2Aup happy");
1688+
assert.equal(visibleText, "\x1b[31mred\x1b[0m \x1b[2Aup happy");
1689+
});
1690+
1691+
it("drops the flattened mode-reply residue a shell echoes at the prompt", () => {
1692+
// The ESC introducer is already gone (the shell flattened the reply), so the
1693+
// escape-aware strip can't see it. A run of flattened DECRPM / DA / OSC-colour
1694+
// replies is dropped (DSR "n"/BEL/CR may separate them).
1695+
assert.equal(
1696+
sanitize("prompt$ 69;0$y2026;2$y2027;0$y2031;0$y2048;0$y").visibleText,
1697+
"prompt$ ",
1698+
);
1699+
assert.equal(sanitize("a 1;2c11;rgb:1616/1616/1616n1;2c b").visibleText, "a b");
1700+
// Lone DECRPM / OSC-colour / DECRPSS tokens are distinctive enough on their own.
1701+
assert.equal(sanitize("x 2026;2$y y").visibleText, "x y");
1702+
assert.equal(sanitize("c 4;0;rgb:1818/1e1e/2626 d").visibleText, "c d");
1703+
assert.equal(sanitize("tail 1$r0m end").visibleText, "tail end"); // flattened DECRPSS (#1238)
1704+
// Ambiguous lone tokens and ordinary words are preserved.
1705+
assert.equal(sanitize("see commit 1;2c now").visibleText, "see commit 1;2c now");
1706+
assert.equal(sanitize("running a connection").visibleText, "running a connection");
1707+
});
1708+
1709+
it("drops a flattened cursor-position-report (CPR) run, keeps a lone one", () => {
1710+
// The `;1RR`/`<row>;<col>R` flood from a prompt's CSI 6n re-query echoing at
1711+
// an idle prompt. Stripped as a run; a lone `<n>;<n>R` is ambiguous and kept.
1712+
assert.equal(sanitize(`prompt$ ${";1RR".repeat(40)}`).visibleText, "prompt$ ");
1713+
assert.equal(sanitize(`x ${"1;1R".repeat(20)} y`).visibleText, "x y");
1714+
assert.equal(sanitize("at 12;5R done").visibleText, "at 12;5R done"); // lone, kept
1715+
});
1716+
1717+
it("does not over-match ordinary text that merely looks reply-shaped", () => {
1718+
// The colour alternative is pinned to OSC 10/11/12 and OSC 4 (`4;<idx>;`), so
1719+
// an arbitrary "<n>;rgb:…" in program output survives.
1720+
assert.equal(sanitize("set 1;rgb:ff/00/00 now").visibleText, "set 1;rgb:ff/00/00 now");
1721+
assert.equal(sanitize("hsl 7;rgb:aabbcc done").visibleText, "hsl 7;rgb:aabbcc done");
1722+
// A DECRPM/DA token immediately followed by a word must not swallow its first
1723+
// letter (regression: a trailing "n?" used to eat the "n" of "next").
1724+
assert.equal(sanitize("v 1;2$ynext").visibleText, "v next");
1725+
// The DECRPSS payload is length-bounded so it can't eat a following number run.
1726+
assert.equal(
1727+
sanitize("tail 1$r0;120;340;Hello there").visibleText,
1728+
"tail 1$r0;120;340;Hello there",
1729+
);
1730+
});
1731+
1732+
it("preserves a framed OSC 4 palette report instead of mangling its inner rgb", () => {
1733+
// The escape walk keeps a framed OSC 4 report (only OSC 10/11/12 are stripped),
1734+
// so the flattened pass must not delete the inner `4;<idx>;rgb:…` and leave a
1735+
// broken `ESC ] … ST` shell — in either view. The flattened (unframed) form is
1736+
// still dropped.
1737+
const framed = "\x1b]4;1;rgb:ff/00/00\x07";
1738+
assert.equal(sanitize(`a ${framed} b`).visibleText, `a ${framed} b`);
1739+
assert.equal(
1740+
sanitizeTerminalHistoryChunk("", `a ${framed} b`, { responsesOnly: true }).visibleText,
1741+
`a ${framed} b`,
1742+
);
1743+
assert.equal(sanitize("echo 4;1;rgb:ff/00/00 here").visibleText, "echo here");
1744+
});
1745+
1746+
it("strips a huge adversarial ';'-run in linear time (no ReDoS)", () => {
1747+
// A program-controlled buffer of many "<digits>;" groups that never reaches
1748+
// "rgb:" used to drive catastrophic backtracking (tens of seconds). The
1749+
// pinned colour alternative makes this fail fast.
1750+
const evil = "1".repeat(20) + ";";
1751+
const start = process.hrtime.bigint();
1752+
sanitize(`${evil.repeat(16000)}rgb`);
1753+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
1754+
assert.ok(ms < 1000, `flattened strip took ${ms}ms — possible ReDoS`);
1755+
});
1756+
1757+
it("handles a report split across chunks via the pending buffer", () => {
1758+
const first = sanitize("tail\x1b[?69;0");
1759+
assert.equal(first.visibleText, "tail");
1760+
assert.notEqual(first.pendingControlSequence, "");
1761+
const second = sanitize("$ydone", first.pendingControlSequence);
1762+
assert.equal(second.visibleText, "done");
1763+
});
1764+
1765+
it("self-heals a flattened token split across PTY chunks on the next reload", () => {
1766+
// A *flattened* reply (ESC introducer already gone) has no escape framing for
1767+
// the pending buffer to hold, so if a PTY read splits it mid-token both halves
1768+
// are written to history live (transient garble). But they land contiguously,
1769+
// so readHistory()'s whole-buffer sanitize rejoins and strips them on restore —
1770+
// the residue does not return after a restart (the persistence concern in the
1771+
// cross-chunk #1238 follow-up).
1772+
const live = sanitize("prompt$ 2026;2").visibleText + sanitize("$y done").visibleText;
1773+
assert.equal(live, "prompt$ 2026;2$y done"); // contiguous in the persisted log
1774+
assert.equal(sanitize(live).visibleText, "prompt$ done"); // stripped on reload
1775+
});
1776+
1777+
it("strips the real-world restore residue reported in issue #1238", () => {
1778+
// The exact escape-reply fragments a user saw flood the prompt on terminal
1779+
// restore: "2026;2$y2027;0$y2031;0$y2048;0$y1$r0m" — DECRPM mode reports
1780+
// (CSI ? Pm ; Ps $ y) plus a DECRPSS status reply (DCS Ps $ r D…D ST),
1781+
// reconstructed as the raw sequences the replayed history carried.
1782+
const residue =
1783+
"\x1b[?2026;2$y\x1b[?2027;0$y\x1b[?2031;0$y\x1b[?2048;0$y\x1bP1$r0m\x1b\\";
1784+
assert.equal(sanitize(`prompt$ ${residue}`).visibleText, "prompt$ ");
1785+
});
1786+
1787+
describe("responsesOnly (live stream)", () => {
1788+
const live = (data: string, pending = "") =>
1789+
sanitizeTerminalHistoryChunk(pending, data, { responsesOnly: true });
1790+
1791+
it("strips terminal responses (DA, DECRPM, cursor, DSR, OSC colour) that leak as garbage", () => {
1792+
const responses = "\x1b[?1;2c\x1b[?2026;2$y\x1b[2;5R\x1b[0n\x1b]11;rgb:1616/1616/1616\x07";
1793+
assert.equal(live(`a${responses}b`).visibleText, "ab");
1794+
});
1795+
1796+
it("keeps queries the client must still answer (DECRQM, DA, DSR, OSC colour)", () => {
1797+
const queries = "\x1b[?2026$p\x1b[c\x1b[6n\x1b]11;?\x07";
1798+
assert.equal(live(`x${queries}y`).visibleText, `x${queries}y`);
1799+
});
1800+
1801+
it("keeps ordinary display sequences", () => {
1802+
assert.equal(live("\x1b[31mred\x1b[0m up").visibleText, "\x1b[31mred\x1b[0m up");
1803+
});
1804+
1805+
it("relays a query split across chunks while history strips it", () => {
1806+
// The query (DECRQM `$p`) arrives in two pieces. The live view must relay
1807+
// it across the pending boundary; the scrollback view strips it.
1808+
const liveFirst = live("x\x1b[?2026");
1809+
assert.equal(liveFirst.visibleText, "x");
1810+
assert.notEqual(liveFirst.pendingControlSequence, "");
1811+
assert.equal(live("$py", liveFirst.pendingControlSequence).visibleText, "\x1b[?2026$py");
1812+
1813+
const histFirst = sanitize("x\x1b[?2026");
1814+
assert.equal(histFirst.visibleText, "x");
1815+
assert.equal(sanitize("$py", histFirst.pendingControlSequence).visibleText, "y");
1816+
});
1817+
1818+
it("diverges within one chunk: strips the response, relays the query", () => {
1819+
// `\x1b[0n` is a DSR *response* (stripped by both views); `\x1b[6n` is the
1820+
// cursor-position *query* the client must answer (relayed live, stripped
1821+
// from scrollback). Same input, two outputs from one parse.
1822+
const data = "A\x1b[0n B\x1b[6n C";
1823+
assert.equal(live(data).visibleText, "A B\x1b[6n C");
1824+
assert.equal(sanitize(data).visibleText, "A B C");
1825+
});
1826+
});
1827+
1828+
describe("8-bit C1 introducers", () => {
1829+
it("strips an 8-bit CSI DECRPM report (0x9b … $ y) like its ESC[ form", () => {
1830+
assert.equal(sanitize("a\x9b?2026;2$yb").visibleText, "ab");
1831+
// Live view strips the report too (it is a response, not a query).
1832+
assert.equal(
1833+
sanitizeTerminalHistoryChunk("", "a\x9b?2026;2$yb", { responsesOnly: true }).visibleText,
1834+
"ab",
1835+
);
1836+
});
1837+
1838+
it("strips an 8-bit OSC colour report (0x9d … BEL); relays the colour query live", () => {
1839+
assert.equal(sanitize("a\x9d11;rgb:1616/1616/1616\x07b").visibleText, "ab");
1840+
// The `?` colour query is relayed live (the client must answer it) but
1841+
// stripped from scrollback so a replay cannot re-trigger it.
1842+
assert.equal(
1843+
sanitizeTerminalHistoryChunk("", "a\x9d11;?\x07b", { responsesOnly: true }).visibleText,
1844+
"a\x9d11;?\x07b",
1845+
);
1846+
assert.equal(sanitize("a\x9d11;?\x07b").visibleText, "ab");
1847+
});
1848+
1849+
it("buffers an incomplete 8-bit CSI across chunks", () => {
1850+
const first = sanitize("tail\x9b?69;0");
1851+
assert.equal(first.visibleText, "tail");
1852+
assert.notEqual(first.pendingControlSequence, "");
1853+
assert.equal(sanitize("$ydone", first.pendingControlSequence).visibleText, "done");
1854+
});
1855+
});
1856+
1857+
describe("DCS status strings (DECRQSS / DECRPSS)", () => {
1858+
const live = (data: string) =>
1859+
sanitizeTerminalHistoryChunk("", data, { responsesOnly: true });
1860+
1861+
it("strips a DECRPSS status reply (DCS Ps $ r D…D ST) from both views", () => {
1862+
assert.equal(sanitize("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1863+
assert.equal(live("a\x1bP1$r0m\x1b\\b").visibleText, "ab");
1864+
});
1865+
1866+
it("relays a DECRQSS query (DCS $ q D…D ST) live but strips it from scrollback", () => {
1867+
assert.equal(live("a\x1bP$qm\x1b\\b").visibleText, "a\x1bP$qm\x1b\\b");
1868+
assert.equal(sanitize("a\x1bP$qm\x1b\\b").visibleText, "ab");
1869+
});
1870+
1871+
it("leaves other DCS strings (sixel, DECUDK) untouched", () => {
1872+
const sixel = "\x1bPq#0;2;0;0;0#0~~\x1b\\";
1873+
assert.equal(sanitize(`a${sixel}b`).visibleText, `a${sixel}b`);
1874+
assert.equal(live(`a${sixel}b`).visibleText, `a${sixel}b`);
1875+
});
1876+
});
1877+
});
1878+
1879+
describe("stripTerminalResponsesFromInput", () => {
1880+
it("drops the browser's auto-replies that drive the echo loop", () => {
1881+
const flood =
1882+
"\x1b[?69;0$y\x1b[?2026;2$y\x1b[?1;2c\x1b]11;rgb:1616/1616/1616\x1b\\\x1b[0n\x1bP1$r0m\x1b\\\x1b[>0;276;0c";
1883+
assert.equal(stripTerminalResponsesFromInput(flood), "");
1884+
});
1885+
1886+
it("accepts the 8-bit ST (0x9c) terminator for OSC/DCS replies", () => {
1887+
assert.equal(stripTerminalResponsesFromInput("\x1b]11;rgb:1616/1616/1616\x9c"), "");
1888+
assert.equal(stripTerminalResponsesFromInput("\x1bP1$r0m\x9c"), "");
1889+
});
1890+
1891+
it("strips OSC 4 palette colour replies so they can't re-arm the echo loop", () => {
1892+
assert.equal(stripTerminalResponsesFromInput("\x1b]4;1;rgb:1616/1616/1616\x07"), "");
1893+
assert.equal(stripTerminalResponsesFromInput("\x1b]4;255;rgb:ffff/0000/0000\x1b\\"), "");
1894+
});
1895+
1896+
it("strips replies that use 8-bit C1 introducers (0x9b CSI, 0x9d OSC, 0x90 DCS)", () => {
1897+
assert.equal(stripTerminalResponsesFromInput("\x9b?69;0$y"), ""); // C1 CSI DECRPM
1898+
assert.equal(stripTerminalResponsesFromInput("\x9b>0;276;0c"), ""); // C1 CSI secondary DA
1899+
assert.equal(stripTerminalResponsesFromInput("\x9d4;1;rgb:1616/1616/1616\x9c"), ""); // C1 OSC 4 + C1 ST
1900+
assert.equal(stripTerminalResponsesFromInput("\x901$r0m\x9c"), ""); // C1 DCS DECRPSS
1901+
});
1902+
1903+
it("keeps focus events so DECSET ?1004 programs (vim/tmux) still receive them", () => {
1904+
assert.equal(stripTerminalResponsesFromInput("\x1b[I"), "\x1b[I"); // focus in
1905+
assert.equal(stripTerminalResponsesFromInput("\x1b[O"), "\x1b[O"); // focus out
1906+
});
1907+
1908+
it("strips cursor-position report (CPR) replies that drive the prompt redraw flood", () => {
1909+
assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R"), ""); // CPR reply
1910+
assert.equal(stripTerminalResponsesFromInput("\x1b[;1R"), ""); // empty-row CPR
1911+
assert.equal(stripTerminalResponsesFromInput("\x1b[1;1R\x1b[1;1R\x1b[1;1R"), ""); // flood
1912+
assert.equal(stripTerminalResponsesFromInput("\x9b5;10R"), ""); // 8-bit C1 CPR
1913+
});
1914+
1915+
it("keeps real user input, cursor moves, and bare query forms", () => {
1916+
assert.equal(stripTerminalResponsesFromInput("ls -la\r"), "ls -la\r"); // keystrokes
1917+
assert.equal(
1918+
stripTerminalResponsesFromInput("\x1b[A\x1b[B\x1b[C\x1b[D"),
1919+
"\x1b[A\x1b[B\x1b[C\x1b[D",
1920+
); // arrows
1921+
assert.equal(stripTerminalResponsesFromInput("\x03"), "\x03"); // Ctrl-C
1922+
assert.equal(stripTerminalResponsesFromInput("\x1b[1;5H"), "\x1b[1;5H"); // cursor-move (H, not CPR)
1923+
assert.equal(stripTerminalResponsesFromInput("\x1b[c"), "\x1b[c"); // bare DA query kept
1924+
assert.equal(stripTerminalResponsesFromInput("\x1b[>c"), "\x1b[>c"); // bare secondary DA query kept
1925+
assert.equal(stripTerminalResponsesFromInput("\x1b[6n"), "\x1b[6n"); // DSR query kept
1926+
});
1927+
});

0 commit comments

Comments
 (0)