Skip to content

Find out what sandboxing the renderer would cost and what it would break #2399

Description

@dex4er

Goal

Find out — without committing to it — whether Freelens could run its renderer with
contextIsolation: true and nodeIntegration: false, the standard Electron security
posture.

Split out of #2395, which touched this by accident: working out why the renderer's fetch
is Chromium's turned up that the renderer is node-integrated and has been since the fork.

Scope: exploratory, and deliberately not on the v2.0.0 milestone

This is soft reconnaissance. It does not block v2.0.0 and nothing here has to ship with
it.
The deliverable is knowing what sandboxing would cost and what it would break — not
a sandboxed renderer.

That is affordable because #2400 removes the argument that would have forced this into the
release. Once require() is blocked in the renderer and extensions load from a URL rather
than a path, the contract no longer promises Node in the renderer, so flipping
contextIsolation later breaks host code — ours to fix on our own schedule — rather than
published extensions, which are not. See the
milestone note for the full reasoning, including the condition
that would bring this back onto the release: #2400 concluding that the renderer keeps
require() after all.

The work items below are therefore findings to gather, not changes to make. Anything
they turn out to justify gets its own issue.

Why now

Two reasons, and the second is the one that makes it a contract issue rather than a
hardening chore.

Extensions should not be the reason the application weakens its own security. If the
only thing standing between Freelens and a sandboxed renderer is that extension code
expects require(), that is a bad trade to keep making silently.

Right now nobody has promised anything either way. Ask "can my renderer extension use
Node?" and the answer is "yes" — but it comes from
create-electron-window.injectable.ts:89-91, not from any document. #2304 is about to
freeze the v2 contract without mentioning it. Extension authors are already relying on
this; they simply have not been told whether they may.

Current state

// packages/core/src/main/start-main-application/lens-window/application-window/create-electron-window.injectable.ts:89-91
nodeIntegration: true,
nodeIntegrationInSubFrames: true,
contextIsolation: false,

Unchanged since the OpenLens fork. nodeIntegrationInSubFrames matters too: cluster views
render in cross-origin <clusterId>.renderer.freelens.app iframes, so the flag is what
gives those frames Node as well.

For historical context on how load-bearing this has been: in 1.x every KubeJsonApi and
JsonApi call in the renderer went through @freelensapp/node-fetch, a CJS build of
node-fetch made for electron-renderer, running in-process. See the historical section
in #2395.

What the renderer actually uses today

Measured over packages/core/src/renderer, excluding *.test.*. freelens/src/renderer
imports neither Node nor Electron, so the whole surface is here.

Import Files Notes
node:assert 64 Invariant checks. Not a real Node dependency — a dozen-line local assert replaces it — but it is the bulk of the mechanical work.
node:path 5 file-picker, and four in the extension install flow (validate-package, unpack-extension, get-extension-dest-folder, attempt-installs).
node:os 2 pod-attach-menu, pod-shell-menu. Almost certainly just the platform, which the host can inject.
node:events 2 websocket-api, catalog-add-button.
node:url 1 terminal-api, legacy url. WHATWG URL is global.
node:timers 1 browser-console-transport, setImmediate.
node:fs/promises 1 dock/create-resource/user-templates.injectable.tsactual disk reads from the renderer. Has to move to main.
electron 12 ipcRenderer x5, clipboard x4, shell x2, webFrame x1 — each needs a preload bridge under contextIsolation.

Most of this is smaller than it looks. The extension-install flow doing path arithmetic and
one injectable reading templates off disk are the only places doing genuinely
Node-privileged work.

The blocker is the extension loader itself

Not extension code — the mechanism that loads it:

// packages/core/src/extensions/extension-loader/extension-loader.ts:40-47
// v2 (plan D2/D6): the bundles are ESM, where the main process has no
// `require` global; the node-integrated renderer keeps it. Node 24's
// require(esm) loads both CJS and ESM extension entry points synchronously
// (verified empirically in #1718), so a sync loader keeps working for both
// module formats in both processes.
const extensionRequire = globalThis.require ?? createRequire(import.meta.url);

A sandboxed renderer has no globalThis.require, and the createRequire fallback needs
Node's module, which it also would not have. So the renderer would have no way to load
an extension at all
until this is redesigned — most plausibly by serving extension
bundles over a custom protocol or the existing proxy so the renderer can import() them as
URLs rather than resolve file paths.

That redesign is the real cost of this issue, and it should be priced before anything else
is touched.

Hypothesis to test

Sandboxing the renderer does not break the host application itself — only the extension
loading path, which can be redesigned.

The table above is evidence for it (the host's own Node usage is thin and mostly
mechanical) and the loader is evidence against it (one subsystem is built directly on the
flag). Test it properly rather than reasoning from the table:

  • flip the flags in a scratch branch, with a preload script exposing the twelve Electron
    entry points, and see what the app does before touching any of the imports;
  • pay attention to the cluster iframes specifically — nodeIntegrationInSubFrames means
    they are a second surface, and cross-origin frames under contextIsolation may behave
    differently from the top frame;
  • check the terminal (terminal-api, websocket-api) and the protocol handler, which are
    the parts most likely to depend on renderer privileges in ways an import list does not
    show.

Findings to gather

Not a plan of changes. Each item is something to learn and write down here.

  • Run the scratch-branch experiment above and record what actually breaks. This is the
    whole point of the issue — the import table is evidence, a running app is proof.
  • Check the cluster iframes specifically. nodeIntegrationInSubFrames makes them a
    second surface, and cross-origin frames under contextIsolation may not behave like
    the top frame.
  • Check the terminal (terminal-api, websocket-api) and the protocol handler, the
    two places most likely to depend on renderer privileges in ways an import list does
    not reveal.
  • Write down what a preload bridge for the twelve Electron entry points would look
    like, in enough detail to be estimated.
  • Record the conclusion either way. "The renderer stays node-integrated, and here is
    what that costs" is as much a result as the opposite, and either way it stops being
    an undocumented consequence of a window flag.

Separable work this turned up

Both are worth doing on their own terms, neither belongs to this issue, and neither waits
for its conclusion. Split them out when someone picks them up.

  • Replacing node:assert with a local helper: 64 files, mechanical, removes most of the
    renderer's Node imports whatever is decided about sandboxing.
  • Moving the user-templates disk read out of the renderer and into main — the only place
    in the renderer doing genuinely Node-privileged work.

Not in scope

Actually sandboxing the renderer — that is whatever issue this one justifies, if it
justifies one. Also out: the main process (meaningless), and sandbox: true on top of
contextIsolation, a further step to judge separately once the first is known to be
possible.

Context from #2395. Depends on #2400 for the loader question. Informs #2304 only if it
reaches a conclusion worth stating.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions