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.ts — actual 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.
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.
Goal
Find out — without committing to it — whether Freelens could run its renderer with
contextIsolation: trueandnodeIntegration: false, the standard Electron securityposture.
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 ratherthan a path, the contract no longer promises Node in the renderer, so flipping
contextIsolationlater breaks host code — ours to fix on our own schedule — rather thanpublished 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 tofreeze the v2 contract without mentioning it. Extension authors are already relying on
this; they simply have not been told whether they may.
Current state
Unchanged since the OpenLens fork.
nodeIntegrationInSubFramesmatters too: cluster viewsrender in cross-origin
<clusterId>.renderer.freelens.appiframes, so the flag is whatgives those frames Node as well.
For historical context on how load-bearing this has been: in 1.x every
KubeJsonApiandJsonApicall in the renderer went through@freelensapp/node-fetch, a CJS build ofnode-fetchmade forelectron-renderer, running in-process. See the historical sectionin #2395.
What the renderer actually uses today
Measured over
packages/core/src/renderer, excluding*.test.*.freelens/src/rendererimports neither Node nor Electron, so the whole surface is here.
node:assertassertreplaces it — but it is the bulk of the mechanical work.node:pathfile-picker, and four in the extension install flow (validate-package,unpack-extension,get-extension-dest-folder,attempt-installs).node:ospod-attach-menu,pod-shell-menu. Almost certainly just the platform, which the host can inject.node:eventswebsocket-api,catalog-add-button.node:urlterminal-api, legacyurl. WHATWGURLis global.node:timersbrowser-console-transport,setImmediate.node:fs/promisesdock/create-resource/user-templates.injectable.ts— actual disk reads from the renderer. Has to move to main.electronipcRendererx5,clipboardx4,shellx2,webFramex1 — each needs a preload bridge undercontextIsolation.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:
A sandboxed renderer has no
globalThis.require, and thecreateRequirefallback needsNode's
module, which it also would not have. So the renderer would have no way to loadan 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 asURLs 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
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:
entry points, and see what the app does before touching any of the imports;
nodeIntegrationInSubFramesmeansthey are a second surface, and cross-origin frames under
contextIsolationmay behavedifferently from the top frame;
terminal-api,websocket-api) and the protocol handler, which arethe 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.
whole point of the issue — the import table is evidence, a running app is proof.
nodeIntegrationInSubFramesmakes them asecond surface, and cross-origin frames under
contextIsolationmay not behave likethe top frame.
terminal-api,websocket-api) and the protocol handler, thetwo places most likely to depend on renderer privileges in ways an import list does
not reveal.
like, in enough detail to be estimated.
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.
node:assertwith a local helper: 64 files, mechanical, removes most of therenderer's Node imports whatever is decided about sandboxing.
user-templatesdisk read out of the renderer and into main — the only placein 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: trueon top ofcontextIsolation, a further step to judge separately once the first is known to bepossible.
Context from #2395. Depends on #2400 for the loader question. Informs #2304 only if it
reaches a conclusion worth stating.