| 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. */ |
| 16 | const 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 | */ |
| 28 | function 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 | */ |
| 42 | function 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 | */ |
| 55 | function 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 | */ |
| 75 | function 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 | |
| 81 | const Sanitize = { stripControl, shellQuote, forTerminal, forClipboard, MAX_LEN }; |
| 82 | |
| 83 | if (typeof module !== "undefined" && module.exports) { |
| 84 | module.exports = Sanitize; |
| 85 | } |