mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 09:55:46 +00:00
fix(javascript): count an exposed IPC listener once
A contextBridge member walk used the exposure call as the invocation of every IPC site it reached. The exposure supplies only unknown public arguments, so the inferred copy of a directly observed listener got a distinct invocation identity and survived deduplication, doubling the renderer listener count and the channel entity. Bridge member walks now start without a seeding invocation; the first literal-argument call inside the member becomes the seed, matching the invocation identity that top-level seed walks record. Fixes #5586 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
080c0e2b2b
commit
f4b4cc4a7b
3 files changed
+109
-3
No files matched your search
@@ -384,6 +384,29 @@ it("keeps a directly observed renderer operation singular when tracing its expos
|
||||
expect.objectContaining({ entry: "desktop.ping", channel: "ping" }),
|
||||
);
|
||||
});
|
||||
it("keeps an exposed renderer listener singular and seeds its wrapper call", () => {
|
||||
const direct = analyzeJavaScriptStaticSource(`
|
||||
const {contextBridge,ipcRenderer}=require("electron");
|
||||
contextBridge.exposeInMainWorld("desktop",{onPong:callback=>ipcRenderer.on("pong",callback)});
|
||||
`);
|
||||
expect(direct.electron.ipc).toEqual([
|
||||
expect.objectContaining({ channel: "pong", operation: "on" }),
|
||||
]);
|
||||
expect(direct.electron.ipc[0]).not.toHaveProperty("resolution_basis");
|
||||
const wrapped = analyzeJavaScriptStaticSource(`
|
||||
const {contextBridge,ipcRenderer}=require("electron");
|
||||
const listen=(channel,callback)=>ipcRenderer.on(channel,callback);
|
||||
contextBridge.exposeInMainWorld("desktop",{onPong:callback=>listen("pong",callback)});
|
||||
`);
|
||||
const resolved = wrapped.electron.ipc.filter(
|
||||
({ channel }) => channel === "pong",
|
||||
);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0]).toMatchObject({
|
||||
resolution_basis: "wrapper-substitution",
|
||||
invocation_location: { start: { line: 4 } },
|
||||
});
|
||||
});
|
||||
it("does not identify overwritten Electron namespaces or shadowed require", () => {
|
||||
for (const source of [
|
||||
`const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
|
||||
@@ -73,7 +73,10 @@ const indexIpcFinding = (
|
||||
};
|
||||
|
||||
interface PathContext {
|
||||
readonly entryNode: t.Node;
|
||||
// The syntax invocation whose literal arguments seeded this walk. A bridge
|
||||
// member is called with unknown public arguments, so its exposure supplies
|
||||
// none; the first literal-argument call inside the member becomes the seed.
|
||||
readonly entryNode: t.Node | undefined;
|
||||
readonly entry: string;
|
||||
readonly entryPath?: readonly string[];
|
||||
readonly entryKind: ElectronCallPath["entry_kind"] | null;
|
||||
@@ -337,6 +340,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
const channel = first.value;
|
||||
const location = range(node);
|
||||
const invocationLocation =
|
||||
context.entryNode === undefined ||
|
||||
JSON.stringify(range(context.entryNode)) === JSON.stringify(location)
|
||||
? undefined
|
||||
: range(context.entryNode);
|
||||
@@ -408,7 +412,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
endpoint,
|
||||
channel,
|
||||
location,
|
||||
range(context.entryNode),
|
||||
context.entryNode === undefined ? null : range(context.entryNode),
|
||||
context.entryKind,
|
||||
context.entry,
|
||||
context.entryPath,
|
||||
@@ -560,7 +564,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
),
|
||||
{
|
||||
...memberContext,
|
||||
entryNode: node,
|
||||
entryNode: undefined,
|
||||
entryKind: "bridge-member",
|
||||
entry,
|
||||
entryPath: [key.value, ...path],
|
||||
@@ -589,6 +593,9 @@ export const collectElectronWrappedCallPaths = (
|
||||
args,
|
||||
{
|
||||
...branchContext,
|
||||
entryNode:
|
||||
branchContext.entryNode ??
|
||||
(t.isStringLiteral(node.arguments[0]) ? node : undefined),
|
||||
steps: [
|
||||
...branchContext.steps,
|
||||
step(node, "call", callLabel(node.callee)),
|
||||
|
||||
@@ -785,3 +785,79 @@ cliTest(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"counts one exposed renderer listener once through analysis and tracing",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-exposed-listener-",
|
||||
);
|
||||
await writeFixtureFiles(root, {
|
||||
"package.json": '{"name":"ipc-probe","version":"1.0.0","main":"main.js"}',
|
||||
"main.js": `const { app, BrowserWindow, ipcMain } = require("electron");
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({ webPreferences: { preload: "./preload.js" } });
|
||||
window.loadFile("index.html");
|
||||
}
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
app.whenReady().then(createWindow);`,
|
||||
"preload.js": `const { contextBridge, ipcRenderer } = require("electron");
|
||||
contextBridge.exposeInMainWorld("api", {
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
onPong: (callback) => ipcRenderer.on("pong", callback),
|
||||
});`,
|
||||
"index.html": "<!doctype html><html><body></body></html>",
|
||||
});
|
||||
const output = await cli.run({
|
||||
arguments: ["analyze-javascript-application", root, "--json"],
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
const { normalized_result: result } = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(output.json);
|
||||
expect(result.summary.ipc).toMatchObject({
|
||||
operations: 3,
|
||||
renderer_listeners: 1,
|
||||
});
|
||||
const pong = result.graph.nodes.filter(
|
||||
(node) =>
|
||||
node.kind === "ipc-channel" &&
|
||||
node.observations.some(
|
||||
(observation) => observation.properties.channel === "pong",
|
||||
),
|
||||
);
|
||||
expect(pong).toHaveLength(1);
|
||||
expect(pong[0]?.observations).toHaveLength(1);
|
||||
expect(pong[0]?.observations[0]?.evidence.state).toBe("observed");
|
||||
expect(result.graph.nodes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "context-bridge-api",
|
||||
observations: [
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
members: ["onPong", "ping"],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const input = join(root, "trace-input.json");
|
||||
await writeFile(
|
||||
input,
|
||||
JSON.stringify({
|
||||
application: output.json,
|
||||
seed: { kind: "channel", value: "pong" },
|
||||
direction: "incoming",
|
||||
}),
|
||||
);
|
||||
const traced = await cli.run({
|
||||
arguments: ["trace-application-feature", input, "--json"],
|
||||
});
|
||||
expect(traced.exitCode, traced.stderr).toBe(0);
|
||||
const { normalized_result: trace } = z
|
||||
.object({ normalized_result: applicationFeatureTraceResultSchema })
|
||||
.parse(traced.json);
|
||||
expect(trace.summary.matched_seeds).toBe(1);
|
||||
expect(trace.seed_matches).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user