| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; |
| 4 | |
| 5 | /** |
| 6 | * Mirrors Electron's webview attribute parser closely enough to catch the |
| 7 | * regressions we've already hit: |
| 8 | * |
| 9 | * - whitespace inside the comma-separated list silently drops keys (so |
| 10 | * `" sandbox=true"` becomes an unknown key and Electron falls back to |
| 11 | * defaults — re-opening the Node-leak window we closed), |
| 12 | * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy |
| 13 | * strings and assigned to a boolean preference, which silently flips |
| 14 | * `contextIsolation=no` to ENABLED (then react-grab can't see the React |
| 15 | * DevTools hook and componentName resolution always returns null). |
| 16 | * |
| 17 | * The actual Electron parser does roughly: |
| 18 | * |
| 19 | * for (const pair of webpreferences.split(',')) { |
| 20 | * const [key, value] = pair.split('='); |
| 21 | * prefs[key] = value; // value left as a string |
| 22 | * } |
| 23 | * |
| 24 | * then later coerces booleans via `Boolean(value)`. Replicating that here |
| 25 | * keeps the test independent of Electron internals while still failing if |
| 26 | * we accidentally ship `"contextIsolation=no"` again. |
| 27 | */ |
| 28 | function parseWebPreferences(input: string): Record<string, unknown> { |
| 29 | const out: Record<string, unknown> = {}; |
| 30 | for (const pair of input.split(",")) { |
| 31 | if (pair !== pair.trim()) { |
| 32 | // Electron's parser doesn't trim; surface the bug as undefined-key. |
| 33 | out[pair] = pair.split("=")[1]; |
| 34 | continue; |
| 35 | } |
| 36 | const [key, value] = pair.split("="); |
| 37 | if (!key) continue; |
| 38 | out[key] = value; |
| 39 | } |
| 40 | return out; |
| 41 | } |
| 42 | |
| 43 | describe("PREVIEW_WEBVIEW_PREFERENCES", () => { |
| 44 | const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); |
| 45 | |
| 46 | it("contains exactly the three security-critical keys", () => { |
| 47 | expect(Object.keys(parsed).toSorted()).toEqual( |
| 48 | ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), |
| 49 | ); |
| 50 | }); |
| 51 | |
| 52 | it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { |
| 53 | // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so |
| 54 | // `contextIsolation="no"` would silently leave isolation ENABLED. Lock |
| 55 | // the values to `"true"` / `"false"` so the parser does the right thing. |
| 56 | for (const value of Object.values(parsed)) { |
| 57 | expect(value).toMatch(/^(true|false)$/); |
| 58 | } |
| 59 | }); |
| 60 | |
| 61 | it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { |
| 62 | expect(parsed["contextIsolation"]).toBe("false"); |
| 63 | }); |
| 64 | |
| 65 | it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { |
| 66 | expect(parsed["sandbox"]).toBe("true"); |
| 67 | }); |
| 68 | |
| 69 | it("disables nodeIntegration (defense in depth — page never gets Node)", () => { |
| 70 | expect(parsed["nodeIntegration"]).toBe("false"); |
| 71 | }); |
| 72 | |
| 73 | it("contains no whitespace (Electron's parser does not trim)", () => { |
| 74 | // Electron splits on `,` without trimming, so any whitespace would turn |
| 75 | // a key into an unknown one and silently drop the security flag. |
| 76 | expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); |
| 77 | }); |
| 78 | }); |