fix(electron): retain scoped IPC registration provenance (#7031)

* fix(electron): retain scoped IPC registration provenance

* fix(electron): defer mutation analysis until scoped receiver admission
This commit is contained in:
Wenyu Chiou authored and GitHub committed 2026-10-25 03:57:02 +08:00
1 parent 2acdd54130
commit c7ae27807f
12 files changed
+546 -36

No files matched your search

@@ -3,6 +3,7 @@ import * as t from "@babel/types";
import type {
ElectronIpcDescriptor,
ElectronIpcFinding,
ElectronIpcReceiver,
ElectronSenderValidationFinding,
} from "./electronStaticAnalysisTypes.js";
import {
@@ -37,7 +38,10 @@ const inspectIpcCall = (
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
): void => {
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
const scoped = scopedIpcDescriptor(node, context);
const descriptor =
scoped?.descriptor ??
ipcDescriptor(electronCalleePath(node.callee, context));
if (descriptor === undefined) return;
const channelNode = argumentNode(node.arguments[0]);
const channelValue = electronStaticValue(context.source, channelNode);
@@ -88,6 +92,7 @@ const inspectIpcCall = (
context.accumulator.unknownFindings += 1;
const finding: ElectronIpcFinding = {
...findingState,
...(scoped === undefined ? {} : { receiver: scoped.receiver }),
...channelReference,
module_key: null,
location: range(node),
@@ -100,6 +105,48 @@ const inspectIpcCall = (
});
};
const scopedIpcDescriptor = (
node: t.CallExpression | t.OptionalCallExpression,
context: JavaScriptFindingContext,
):
| {
readonly descriptor: ElectronIpcDescriptor;
readonly receiver: ElectronIpcReceiver;
}
| undefined => {
if (
!t.isMemberExpression(node.callee) &&
!t.isOptionalMemberExpression(node.callee)
)
return undefined;
const operation = semanticStaticPropertyName(
node.callee.property,
node.callee.computed,
);
const descriptor = ipcDescriptor(["ipcMain", operation]);
if (descriptor === undefined) return undefined;
const receiver = context.browserWindowReceiver?.(node.callee.object, node, [
operation,
]);
if (receiver === undefined) return undefined;
const path = receiver.path;
const kind =
path.length === 3 && path[0] === "webContents" && path[1] === "ipc"
? "web-contents"
: path.length === 4 &&
path[0] === "webContents" &&
path[1] === "mainFrame" &&
path[2] === "ipc"
? "main-frame"
: undefined;
return kind === undefined
? undefined
: {
descriptor,
receiver: { kind, owner_location: range(receiver.owner) },
};
};
const inspectValidationComparison = (
node: t.BinaryExpression,
context: JavaScriptFindingContext,
@@ -0,0 +1,120 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
["webContents", "window.webContents.ipc"],
["main frame", "window.webContents.mainFrame.ipc"],
] as const)(
"retains a proven %s IPC handler beside the global control",
(_, receiver) => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow, ipcMain } = require("electron");
const window = new BrowserWindow();
ipcMain.handle("global-control", () => "global");
${receiver}.handle("scoped-channel", () => "scoped");
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.browser_windows).toHaveLength(1);
expect(analysis.electron.ipc).toContainEqual(
expect.objectContaining({
side: "main",
operation: "handle",
channel: "global-control",
handler_kind: "inline-function",
}),
);
expect(analysis.electron.ipc).toContainEqual(
expect.objectContaining({
side: "main",
operation: "handle",
channel: "scoped-channel",
handler_kind: "inline-function",
}),
);
},
);
it("does not label lookalike or shadowed receivers as Electron IPC", () => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow, ipcMain } = require("electron");
ipcMain.handle("global-control", () => "global");
const fake = { webContents: { ipc: { handle() {} } } };
fake.webContents.ipc.handle("fake", () => {});
function shadow(BrowserWindow) {
const window = new BrowserWindow();
window.webContents.ipc.handle("shadowed", () => {});
}
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.ipc.map(({ channel }) => channel)).toEqual([
"global-control",
]);
});
it.each([
[
'const contents = window.webContents; contents.ipc.handle("same", () => {});',
"web-contents",
],
[
'const frame = window.webContents.mainFrame; frame.ipc.handle("same", () => {});',
"main-frame",
],
[
'const contents = window.webContents; const frame = contents.mainFrame; const ipc = frame.ipc; ipc.handle("same", () => {});',
"main-frame",
],
])("retains a proven scoped alias: %s", (call, kind) => {
const findings =
analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron");
const window = new BrowserWindow(); ${call}`).electron.ipc;
expect(findings).toHaveLength(1);
expect(findings[0]?.receiver).toEqual({
kind,
owner_location: {
start: { line: 2, column: 19 },
end: { line: 2, column: 38 },
},
});
});
it("keeps the shared owner's conservative omission after an aliased receiver call", () => {
const findings =
analyzeJavaScriptStaticSource(`const { BrowserWindow } = require("electron");
const window = new BrowserWindow();
const ipc = window.webContents.mainFrame.ipc;
ipc.handle("first", () => {});
window.webContents.mainFrame.ipc.on("after-escape", () => {});`).electron
.ipc;
expect(findings.map(({ channel }) => channel)).toEqual(["first"]);
});
it.each([
"window.webContents = replacement;",
"window.webContents.ipc = replacement;",
"window.webContents.ipc.handle = replacement;",
"const alias = window; alias.webContents.ipc.handle = replacement;",
"const contents = window.webContents; contents.ipc = replacement;",
"BrowserWindow.prototype.webContents = replacement;",
])("omits a mutated receiver: %s", (mutation) => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow } = require("electron");
const window = new BrowserWindow();
${mutation}
window.webContents.ipc.handle("changed", () => {});
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.electron.ipc).toEqual([]);
});
it.each(["webContents.ipc", "webContents.mainFrame.ipc"])(
"does not split one dotted member key into receiver segments: %s",
(key) => {
expect(
analyzeJavaScriptStaticSource(`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window[${JSON.stringify(key)}].handle("fake", () => {});`)
.electron.ipc,
).toEqual([]);
},
);
@@ -114,7 +114,14 @@ type ElectronIpcHandler =
readonly handler_location: JavaScriptSourceRange;
};
export interface ElectronIpcReceiver {
readonly kind: "web-contents" | "main-frame";
/** Constructor syntax allocation, never a runtime frame identifier. */
readonly owner_location: JavaScriptSourceRange;
}
interface ElectronIpcFindingContext {
readonly receiver?: ElectronIpcReceiver;
/** Absent on direct syntax observations; wrappers require inferred evidence. */
readonly resolution_basis?: "wrapper-substitution";
readonly module_key: string | null;
@@ -0,0 +1,80 @@
import { expect, it } from "vitest";
import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
import { resolveElectronBrowserWindowReceiver } from "./electronStaticRenderer.js";
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import {
indexJavaScriptBindingsWithReceiversSteps,
indexJavaScriptElectronIdentitySteps,
} from "./javascriptSemanticAnalysis.js";
import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js";
import {
completeSemanticSteps as complete,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import {
createJavaScriptAnalysisAccumulator,
type JavaScriptFindingContext,
} from "./javascriptStaticAnalysisState.js";
const inspect = (source: string) => {
const file = parseJavaScriptSource(source);
if (file === null) throw new Error("Fixture must parse");
const index = complete(
indexJavaScriptBindingsWithReceiversSteps(file.program),
);
complete(index.indexAmbientWritesSteps());
const identity = complete(
indexJavaScriptElectronIdentitySteps(file.program, index.bindings),
);
let mutationIndexCalls = 0;
const original = index.indexMemberMutationFactsSteps.bind(index);
index.indexMemberMutationFactsSteps = function* () {
mutationIndexCalls += 1;
yield* original();
};
const context: JavaScriptFindingContext = {
source,
accumulator: createJavaScriptAnalysisAccumulator(source.length),
vitePreloadDependencyTable: null,
electronBindings: identity.bindings,
electronExportIntact: identity.intactExport,
rpcScan: createJavaScriptRpcScan(
source,
index.receivers.rpcClientRoots,
index,
),
expressionShapes: createSemanticExpressionShapeResolver(index.bindings),
browserWindowReceiver: (node, site, suffix) =>
resolveElectronBrowserWindowReceiver(node, site, suffix, context, index),
};
traverseJavaScriptAst(file, {
enter: (node) => inspectElectronIpcNode(node, context),
});
return { findings: context.accumulator.ipc, mutationIndexCalls };
};
it.each([
[
"ordinary EventEmitter",
`import {EventEmitter} from "node:events";
const emitter = new EventEmitter(); emitter.on("event", listener);`,
],
[
"unsupported BrowserWindow path",
`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window.on("ready", listener);`,
],
])("does not request whole-file mutation facts for %s", (_, source) => {
const result = inspect(source);
expect(result.findings).toEqual([]);
expect(result.mutationIndexCalls).toBe(0);
});
it("still checks mutations for a source-proven scoped Electron receiver", () => {
const result = inspect(`const {BrowserWindow} = require("electron");
const window = new BrowserWindow(); window.webContents.ipc.handle("channel", () => {});`);
expect(result.findings).toHaveLength(1);
expect(result.mutationIndexCalls).toBeGreaterThan(0);
});
+75 -20
View File
@@ -25,20 +25,51 @@ export const isElectronRendererLoad = (
return false;
const method = semanticStaticPropertyKey(callee.property, callee.computed);
if (method !== "loadFile" && method !== "loadURL") return false;
let receiver = unwrapJavaScriptExpression(callee.object).node;
const path: string[] = [method];
if (
t.isMemberExpression(receiver) &&
semanticStaticPropertyKey(receiver.property, receiver.computed) ===
"webContents"
) {
receiver = unwrapJavaScriptExpression(receiver.object).node;
path.unshift("webContents");
}
const receiver = resolveElectronBrowserWindowReceiver(
callee.object,
site,
[method],
context,
index,
);
return (
receiver !== undefined &&
(receiver.path.length === 1 ||
(receiver.path.length === 2 && receiver.path[0] === "webContents"))
);
};
/** Source allocation identity, not a claim about runtime window/frame identity. */
export const resolveElectronBrowserWindowReceiver = (
node: t.Node,
site: t.Node,
suffix: readonly string[],
context: JavaScriptFindingContext,
index: JavaScriptBindingIndex,
):
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
| undefined => {
let receiver = unwrapJavaScriptExpression(node).node;
const path = [...suffix];
const seen = new Set<string>();
while (t.isIdentifier(receiver)) {
const aliases: { receiver: t.Identifier; path: readonly string[] }[] = [];
for (;;) {
if (
t.isMemberExpression(receiver) ||
t.isOptionalMemberExpression(receiver)
) {
const member = semanticStaticPropertyKey(
receiver.property,
receiver.computed,
);
if (member === null) return undefined;
path.unshift(member);
receiver = unwrapJavaScriptExpression(receiver.object).node;
continue;
}
if (!t.isIdentifier(receiver)) break;
const binding = index.bindingForReferenceAt(receiver);
if (binding === undefined || seen.has(binding.bindingId)) return false;
if (binding === undefined || seen.has(binding.bindingId)) return undefined;
seen.add(binding.bindingId);
const initializer = semanticStableInitializer(binding, index.bindings);
if (
@@ -47,21 +78,45 @@ export const isElectronRendererLoad = (
!semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode)
.hasDefiniteInitializer
)
return false;
completeSemanticSteps(index.indexMemberMutationFactsSteps());
if (index.referenceMayHaveChangedAt(site, receiver, path)) return false;
return undefined;
aliases.push({ receiver, path: [...path] });
receiver = unwrapJavaScriptExpression(initializer.node).node;
}
if (!t.isNewExpression(receiver)) return false;
if (!t.isNewExpression(receiver)) return undefined;
const constructor = electronCalleePath(receiver.callee, context);
return (
constructor?.length === 1 &&
constructor[0] === "BrowserWindow" &&
if (
constructor?.length !== 1 ||
constructor[0] !== "BrowserWindow" ||
!supportedReceiverPath(path)
)
return undefined;
if (
context.electronExportIntact?.([
"electron",
"BrowserWindow",
"prototype",
...path,
]) === true
]) !== true
)
return undefined;
// Only source-proven supported receivers justify the whole-file mutation pass.
// Keep every alias's consumed path and check at the original invocation site.
completeSemanticSteps(index.indexMemberMutationFactsSteps());
for (const alias of aliases)
if (index.referenceMayHaveChangedAt(site, alias.receiver, alias.path))
return undefined;
return { owner: receiver, path };
};
const supportedReceiverPath = (path: readonly string[]): boolean => {
const operation = path.at(-1);
if (operation === "loadFile" || operation === "loadURL")
return (
path.length === 1 || (path.length === 2 && path[0] === "webContents")
);
return (
path[0] === "webContents" &&
((path.length === 3 && path[1] === "ipc") ||
(path.length === 4 && path[1] === "mainFrame" && path[2] === "ipc"))
);
};
@@ -14,7 +14,10 @@ import {
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
import { isElectronRendererLoad } from "./electronStaticRenderer.js";
import {
isElectronRendererLoad,
resolveElectronBrowserWindowReceiver,
} from "./electronStaticRenderer.js";
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import {
@@ -109,6 +112,8 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
);
const findings: JavaScriptFindingContext = {
pathIntrinsics: createStaticPathIntrinsicQuery(index),
browserWindowReceiver: (node, site, suffix) =>
resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index),
rendererLoad: (callee, site) =>
isElectronRendererLoad(callee, site, findings, index),
source,
@@ -101,6 +101,13 @@ export interface JavaScriptFindingContext {
/** Native path/URL identities established by shared lexical and mutation owners. */
readonly pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery;
readonly electronExportIntact?: (path: readonly string[]) => boolean;
readonly browserWindowReceiver?: (
node: t.Node,
site: t.Node,
suffix: readonly string[],
) =>
| { readonly owner: t.NewExpression; readonly path: readonly string[] }
| undefined;
readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean;
readonly rpcScan: JavaScriptRpcScan;
/** Shared bounded argv/nullish argument shapes for optional-slot signatures. */