fix(javascript): reject Webpack getters replaced through any identity write (#3860)

The canonical `n.n` getter proof scanned for direct `loader.n =` assignments
and four Object methods on its own. `Reflect.set`, `Reflect.defineProperty`,
`delete`, destructuring and unknown-key writes left the proof intact, so a
replaced or deleted getter still attributed BrowserWindow calls to Electron.
Any Object method call on the loader also rejected the proof, even for an
unrelated key.

The proof now uses the shared identity-write owner
(`collectJavaScriptIdentityWritesSteps` with the module interop's intrinsic
resolution), as ambient writes already do. A write whose path may select `n`
(the key itself, an unknown key, or the whole loader) counts; exactly one
canonical assignment installed before entry dispatch remains required.

Fixes #3315

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 23:25:18 -07:00
1 parent 22ec8bdb2f
commit 57f30029b1
2 files changed
+72 -54

No files matched your search

@@ -48,6 +48,38 @@ describe("Electron identity through Webpack externals", () => {
expect(electronOf(source).browser_windows).toEqual([]);
});
const GETTER_CONSUMER = "const s=n.n(n(45840));new (s().BrowserWindow)({});";
it.each([
["Reflect.set", 'Reflect.set(n,"n",e=>()=>({BrowserWindow:class{}}));'],
[
"Reflect.defineProperty",
'Reflect.defineProperty(n,"n",{value:e=>()=>0});',
],
["delete", "delete n.n;"],
["a destructuring write", "({g:n.n}={g:e=>()=>0});"],
["an unknown key", "n[key]=e=>()=>0;"],
])(
"does not infer Electron after a getter write through %s",
(_name, write) => {
expect(
electronOf(bootstrap(EXTERNAL, GETTER_CONSUMER, `${write}n(100);`))
.browser_windows,
).toEqual([]);
},
);
it("keeps the getter after a reflective write to another loader key", () => {
expect(
electronOf(
bootstrap(
EXTERNAL,
GETTER_CONSUMER,
'Object.defineProperty(n,"d",{value:1});n(100);',
),
).browser_windows,
).toHaveLength(1);
});
it("follows the loader passed to a module factory", () => {
const electron = electronOf(bootstrap(EXTERNAL, CONSUMER));
expect(electron.browser_windows).toMatchObject([
@@ -2,7 +2,12 @@ import * as t from "@babel/types";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrity.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import {
semanticMemberChain,
semanticRequireOrigin,
@@ -18,7 +23,10 @@ import {
moduleFactory,
modulePropertyName,
} from "./javascriptStaticAnalysisHelpers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
/** Loader bindings and literal externals proven for one standalone bootstrap. */
interface BootstrapExternals {
@@ -142,60 +150,38 @@ const canonicalWebpackInteropBindings = (
bootstrapBody: t.BlockStatement,
state: JavaScriptSemanticAnalysisState,
): ReadonlySet<string> => {
const writes = new Map<string, t.AssignmentExpression[]>();
let reflectiveWrite = false;
traverseJavaScriptAst(state.program, {
enter: (node) => {
if (t.isAssignmentExpression(node) && t.isMemberExpression(node.left)) {
const target = node.left;
const bindingId = t.isIdentifier(target.object)
? resolveSemanticBindingState(
state,
target.object,
target.object.name,
)?.bindingId
: undefined;
if (
semanticStaticPropertyKey(target.property, target.computed) === "n" &&
bindingId !== undefined &&
loaderBindingIds.has(bindingId)
)
writes.set(bindingId, [...(writes.get(bindingId) ?? []), node]);
}
if (
!t.isCallExpression(node) ||
!t.isMemberExpression(node.callee) ||
!t.isIdentifier(node.callee.object, { name: "Object" }) ||
![
"defineProperty",
"defineProperties",
"assign",
"setPrototypeOf",
].includes(
semanticStaticPropertyKey(
node.callee.property,
node.callee.computed,
) ?? "",
) ||
!t.isIdentifier(node.arguments[0])
)
return;
reflectiveWrite ||= loaderBindingIds.has(
resolveSemanticBindingState(
state,
node.arguments[0],
node.arguments[0].name,
)?.bindingId ?? "",
);
},
});
// The shared identity-write owner covers assignment, delete, destructuring,
// and Object/Reflect mutators; any write that may select `n` counts.
const writes = new Set<t.Node>();
const interop = createSemanticModuleInterop(state);
completeSemanticSteps(
collectJavaScriptIdentityWritesSteps(
state.program,
(root, kind) => {
const node = unwrapJavaScriptExpression(root).node;
return kind === "member" &&
t.isIdentifier(node) &&
loaderBindingIds.has(
resolveSemanticBindingState(state, node, node.name)?.bindingId ??
"",
)
? [["loader"]]
: [];
},
interop.intrinsicInvocation,
interop.staticString,
interop.isFreshObject,
(node, path) => {
if (path.length < 2 || path[1] === "n" || path[1] === null)
writes.add(node);
},
),
);
const proven = new Set<string>();
if (reflectiveWrite) return proven;
const allWrites = [...writes.values()].flat();
const [write] = allWrites;
const [write] = writes;
if (
allWrites.length === 1 &&
write !== undefined &&
writes.size === 1 &&
t.isAssignmentExpression(write) &&
isInstalledBeforeEntryDispatch(
write,
bootstrapBody,