mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 03:02:56 +00:00
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:
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);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user