anvilsign in

collin/browser-terminal-extension

main / extension / lib / sanitize.js
1// Turning page-controlled text into something safe to type at a live shell.
2//
3// Everything here treats its input as hostile. A selector is built from
4// attributes the *page* chose — an id, an aria-label, a class name — so a page
5// can put anything it likes in there, including a newline. A newline typed into
6// a terminal is a pressed Enter key, which is arbitrary code execution.
7//
8// Two independent defenses, both required:
9// 1. strip control characters, so nothing can submit a line or drive the
10// terminal's own escape-sequence parser;
11// 2. single-quote the result, so shell metacharacters are inert.
12//
13// Pure functions, no DOM — so they can be tested under `node --test`.
14
15/** Hard cap. Nothing legitimate is this long; a 10MB "selector" is an attack. */
16const MAX_LEN = 4096;
17
18/**
19 * Remove every C0 control character, DEL, and the Unicode line separators.
20 *
21 * \r and \n are the dangerous ones (they execute). ESC matters too: xterm.js
22 * feeds what we send straight to the pty, and an escape sequence could drive
23 * an application's own input handling. \t is dropped as well — it triggers
24 * shell completion, which can execute in some configurations.
25 *
26 * @param {unknown} input anything at all — callers pass page-controlled values
27 */
28function stripControl(input) {
29 const text = String(input ?? "");
30 // C0 controls (covers NUL, TAB, LF, CR, ESC), DEL, and the Unicode line
31 // separators, which some terminals also treat as line breaks.
32 return text.replace(/[\u0000-\u001F\u007F\u2028\u2029]/g, "");
33}
34
35/**
36 * POSIX single-quoting. Inside single quotes the shell interprets nothing, so
37 * $, `, ;, &, |, newline and friends are all literal. The only character that
38 * needs handling is the single quote itself, which we close/escape/reopen.
39 *
40 * @param {unknown} input
41 */
42function shellQuote(input) {
43 const text = String(input ?? "");
44 return "'" + text.replace(/'/g, "'\\''") + "'";
45}
46
47/**
48 * The full pipeline for text that is about to be written to the pty.
49 *
50 * Returns the safe string plus what had to be removed, so the UI can tell the
51 * user rather than silently altering what they picked.
52 *
53 * @param {unknown} input
54 */
55function forTerminal(input) {
56 const raw = String(input ?? "");
57 const truncated = raw.length > MAX_LEN;
58 const capped = truncated ? raw.slice(0, MAX_LEN) : raw;
59 const stripped = stripControl(capped);
60 return {
61 text: shellQuote(stripped),
62 removedControl: stripped.length !== capped.length,
63 truncated,
64 };
65}
66
67/**
68 * For the system clipboard. Control characters still come out — a newline in
69 * the clipboard is not execution — but a paste into a terminal *would* be, and
70 * some terminals paste without bracketed-paste protection. Same treatment,
71 * minus the shell quoting, which would be noise in an editor.
72 *
73 * @param {unknown} input
74 */
75function forClipboard(input) {
76 const raw = String(input ?? "");
77 const capped = raw.length > MAX_LEN ? raw.slice(0, MAX_LEN) : raw;
78 return stripControl(capped);
79}
80
81const Sanitize = { stripControl, shellQuote, forTerminal, forClipboard, MAX_LEN };
82
83if (typeof module !== "undefined" && module.exports) {
84 module.exports = Sanitize;
85}