| 1 | // Run with: node --test "extension/lib/*.test.js" |
| 2 | // |
| 3 | // This is the boundary between page-controlled text and a live shell. Every |
| 4 | // case below is something a hostile (or merely careless) page can put in an |
| 5 | // id, a class, or an aria-label. |
| 6 | |
| 7 | const test = require("node:test"); |
| 8 | const assert = require("node:assert"); |
| 9 | const { execFileSync } = require("node:child_process"); |
| 10 | const S = require("./sanitize.js"); |
| 11 | |
| 12 | test("newlines are removed — a newline in a terminal is a pressed Enter", () => { |
| 13 | for (const nl of ["\n", "\r", "\r\n", "\u2028", "\u2029"]) { |
| 14 | const { text } = S.forTerminal(`a${nl}whoami`); |
| 15 | assert.strictEqual(text, "'awhoami'", `newline survived: ${JSON.stringify(text)}`); |
| 16 | } |
| 17 | }); |
| 18 | |
| 19 | test("the classic injection payload cannot execute", () => { |
| 20 | const { text } = S.forTerminal("x'; rm -rf ~; echo '"); |
| 21 | // Single-quoted, with the embedded quotes neutralised. Handing this to sh |
| 22 | // yields one literal argument. |
| 23 | assert.strictEqual(text, "'x'\\''; rm -rf ~; echo '\\'''"); |
| 24 | }); |
| 25 | |
| 26 | test("shell metacharacters are inert inside single quotes", () => { |
| 27 | for (const payload of [ |
| 28 | "$(whoami)", |
| 29 | "`id`", |
| 30 | "${HOME}", |
| 31 | "a && b", |
| 32 | "a || b", |
| 33 | "a; b", |
| 34 | "a | b", |
| 35 | "a > /etc/passwd", |
| 36 | "~/secrets", |
| 37 | "*", |
| 38 | "$IFS", |
| 39 | ]) { |
| 40 | const { text } = S.forTerminal(payload); |
| 41 | assert.strictEqual(text, `'${payload}'`, `mangled: ${payload}`); |
| 42 | } |
| 43 | }); |
| 44 | |
| 45 | test("escape sequences are stripped, not passed to the terminal", () => { |
| 46 | const { text, removedControl } = S.forTerminal("\u001b]0;pwned\u0007ok"); |
| 47 | assert.ok(!text.includes("\u001b"), "ESC survived"); |
| 48 | assert.ok(!text.includes("\u0007"), "BEL survived"); |
| 49 | assert.strictEqual(text, "']0;pwnedok'"); |
| 50 | assert.ok(removedControl, "should report that something was removed"); |
| 51 | }); |
| 52 | |
| 53 | test("tab is stripped — it triggers shell completion", () => { |
| 54 | assert.strictEqual(S.forTerminal("a\tb").text, "'ab'"); |
| 55 | }); |
| 56 | |
| 57 | test("NUL and other C0 controls are stripped", () => { |
| 58 | assert.strictEqual(S.forTerminal("a\u0000b\u0001c").text, "'abc'"); |
| 59 | }); |
| 60 | |
| 61 | test("removedControl is false for ordinary input", () => { |
| 62 | const { text, removedControl, truncated } = S.forTerminal("#main > div.card"); |
| 63 | assert.strictEqual(text, "'#main > div.card'"); |
| 64 | assert.strictEqual(removedControl, false); |
| 65 | assert.strictEqual(truncated, false); |
| 66 | }); |
| 67 | |
| 68 | test("absurdly long input is capped and reported", () => { |
| 69 | const { text, truncated } = S.forTerminal("a".repeat(S.MAX_LEN * 3)); |
| 70 | assert.ok(truncated); |
| 71 | assert.ok(text.length <= S.MAX_LEN + 2); |
| 72 | }); |
| 73 | |
| 74 | // The real proof: hand the quoted string to an actual shell and check it comes |
| 75 | // back byte-for-byte as a single argument. |
| 76 | test("quoting survives a round trip through sh", () => { |
| 77 | for (const payload of [ |
| 78 | "x'; rm -rf ~; echo '", |
| 79 | "$(id)", |
| 80 | "it's a `test`", |
| 81 | 'double "quotes" too', |
| 82 | "back\\slash", |
| 83 | "#main > div.card:nth-of-type(2)", |
| 84 | "//*[@id='thing']", |
| 85 | ]) { |
| 86 | const { text } = S.forTerminal(payload); |
| 87 | const out = execFileSync("/bin/sh", ["-c", `printf %s ${text}`], { |
| 88 | encoding: "utf8", |
| 89 | }); |
| 90 | assert.strictEqual(out, payload, `sh did not treat it as one literal: ${payload}`); |
| 91 | } |
| 92 | }); |
| 93 | |
| 94 | // A newline must not become a second command even when sh evaluates the line. |
| 95 | test("an injected newline cannot start a second command in sh", () => { |
| 96 | const { text } = S.forTerminal("harmless\nid"); |
| 97 | const out = execFileSync("/bin/sh", ["-c", `printf %s ${text}`], { |
| 98 | encoding: "utf8", |
| 99 | }); |
| 100 | assert.strictEqual(out, "harmlessid"); |
| 101 | assert.ok(!/uid=/.test(out), "a second command ran"); |
| 102 | }); |
| 103 | |
| 104 | test("clipboard output strips controls but does not shell-quote", () => { |
| 105 | assert.strictEqual(S.forClipboard("#main > .card"), "#main > .card"); |
| 106 | assert.strictEqual(S.forClipboard("a\nwhoami"), "awhoami"); |
| 107 | assert.strictEqual(S.forClipboard("it's"), "it's"); |
| 108 | }); |
| 109 | |
| 110 | test("null and undefined do not throw", () => { |
| 111 | assert.strictEqual(S.forTerminal(null).text, "''"); |
| 112 | assert.strictEqual(S.forTerminal(undefined).text, "''"); |
| 113 | assert.strictEqual(S.forClipboard(null), ""); |
| 114 | }); |