fix(electron): bind each contextBridge helper invocation to its own API (#6284)

Wrapper substitution keyed contextBridge findings only by the inner
exposure call's range, so every helper invocation replaced the previous
one while unioning its members: the last key survived with every
invocation's members. Substituted members also listed only immediate
object keys, dropping nested paths that direct syntax retains.

Record substituted exposures per site and per world and key, and replace
the site's syntax observation once after traversal. Invocations exposing
one key at one site describe one API covering each alternative object; a
traced invocation whose key, object or world stays unresolved or guarded
keeps the site's own unresolved observation. Enumerate member paths
through resolved objects with the direct syntax depth bound, which also
retains the depth-limit unknown that substitution previously dropped.

Fixes #5871
Fixes #5870

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-22 01:29:39 -07:00
1 parent 002e1b68c6
commit 04fd41b288
5 files changed
+451 -49

No files matched your search

@@ -392,6 +392,11 @@ observation, because callers outside the traced paths may pass other values.
A wrapper call that is both its own entry and reached through another entry
is recorded once when both paths find the same facts; calls that supply
different handlers or channels stay distinct.
A contextBridge exposure reached through wrapper calls records one API per key
those invocations supply, each with its own bounded member paths. Invocations
that supply the same key to one exposure call describe one API covering each
alternative object. A traced invocation whose key, API object or world stays
unresolved or guarded keeps the call's own unresolved observation.
Unknown spreads, reassigned bindings, accessors,
dynamic channels and unsupported syntax do not become exact implementations.
This pass neither executes the bundle nor proves that runtime guards pass.
@@ -79,6 +79,12 @@ export function handlerKind(
return "dynamic-expression";
}
/**
* Nesting bound for dotted contextBridge member paths. Deeper objects count as
* one unknown member rather than being enumerated.
*/
export const contextBridgeMemberPathDepth = 8;
/** Collect statically visible dotted keys from one literal contextBridge API object. */
export const collectContextBridgeMembers = (
node: t.Node | null | undefined,
@@ -117,7 +123,7 @@ const collectMembersAt = (
prefix: readonly string[],
state: { members: string[]; unknown: number },
): void => {
if (prefix.length >= 8) {
if (prefix.length >= contextBridgeMemberPathDepth) {
state.unknown += 1;
return;
}
@@ -236,6 +236,138 @@ describe("wrapped contextBridge exposure methods", () => {
});
});
describe("contextBridge helper invocations", () => {
const exposures = (source: string) => {
const result = analyzeJavaScriptStaticSource(`
const { contextBridge, ipcRenderer } = require("electron");
${source}`);
return {
result,
apis: result.electron.context_bridge_apis.map(
({
api_key,
api_key_expression,
world_id,
members,
unknown_members,
}) => ({
api_key,
api_key_expression,
world_id,
members,
unknown_members,
}),
),
};
};
const literalKey = { api_key_expression: null, world_id: null };
it("binds a site-literal object to each invocation's key", () => {
const { apis } = exposures(`
function expose(key) { contextBridge.exposeInMainWorld(key, { ping() {} }); }
expose("one"); expose("two");`);
expect(apis).toEqual([
{ api_key: "one", ...literalKey, members: ["ping"], unknown_members: 0 },
{ api_key: "two", ...literalKey, members: ["ping"], unknown_members: 0 },
]);
});
it("counts a repeated identical invocation once", () => {
const { apis } = exposures(`
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
const api = { ping() {} };
expose("one", api); expose("one", api);`);
expect(apis).toEqual([
{ api_key: "one", ...literalKey, members: ["ping"], unknown_members: 0 },
]);
});
it("describes one key's alternative objects as one exposure", () => {
const { apis } = exposures(`
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
expose("api", { a() {} });
expose("api", { ...globalThis.extra, b: { c() {} } });`);
expect(apis).toEqual([
{
api_key: "api",
...literalKey,
members: ["a", "b", "b.c"],
unknown_members: 1,
},
]);
});
it("keeps one key's differing world ids unknown", () => {
const { apis, result } = exposures(`
function expose(key, id, api) { contextBridge.exposeInIsolatedWorld(id, key, api); }
expose("api", 1, { a() {} }); expose("api", 2, { b() {} });`);
expect(apis).toEqual([
{
api_key: "api",
api_key_expression: null,
world_id: { status: "dynamic", value: null, expression: "id" },
members: ["a", "b"],
unknown_members: 0,
},
]);
expect(result.parse_status).toBe("partial");
});
it("bounds nested member paths at the direct syntax depth", () => {
const deep = `{ a: { b: { c: { d: { e: { f: { g: { h: { i: {} } } } } } } } } }`;
const { apis } = exposures(`
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
expose("helper", ${deep});
contextBridge.exposeInMainWorld("direct", ${deep});`);
const paths = ["a", "a.b", "a.b.c", "a.b.c.d", "a.b.c.d.e"];
const members = [
...paths,
"a.b.c.d.e.f",
"a.b.c.d.e.f.g",
"a.b.c.d.e.f.g.h",
];
expect(apis).toEqual([
{ api_key: "helper", ...literalKey, members, unknown_members: 1 },
{ api_key: "direct", ...literalKey, members, unknown_members: 1 },
]);
});
it("keeps the site observation for a guarded invocation", () => {
const { apis } = exposures(`
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
expose("one", { ping() {} });
if (globalThis.flag) expose("two", { pong() {} });`);
expect(apis).toEqual([
{
api_key: null,
api_key_expression: "key",
world_id: null,
members: [],
unknown_members: 1,
},
{ api_key: "one", ...literalKey, members: ["ping"], unknown_members: 0 },
]);
});
it("keeps a site-literal key singular beside a guarded invocation", () => {
const { apis } = exposures(`
function expose(api) { contextBridge.exposeInMainWorld("fixed", api); }
function relay(label, api) { expose(api); }
relay("label", { ping() {} });
if (globalThis.flag) relay("label", { pong() {} });`);
// The site's own scope leaves the API unknown; the guarded object is not
// recorded as an unconditional member.
expect(apis).toEqual([
{
api_key: "fixed",
...literalKey,
members: ["ping"],
unknown_members: 1,
},
]);
});
});
it("connects exposed closures to IPC sends and text clipboard calls", () => {
const result = analyzeJavaScriptStaticSource(
bundle(`
+223 -48
View File
@@ -28,7 +28,10 @@ import {
contextBridgeExposureArguments,
type JavaScriptContextBridgeExposureArguments,
} from "./javascriptContextBridgeExposure.js";
import { boundedExpression } from "./electronStaticAnalysisValues.js";
import {
boundedExpression,
contextBridgeMemberPathDepth,
} from "./electronStaticAnalysisValues.js";
import { argumentNode } from "./javascriptStaticAnalysisHelpers.js";
import {
childNodes,
@@ -160,6 +163,55 @@ const indexIpcFinding = (
}
};
/** Substituted contextBridge exposures at one exposure call site. */
interface BridgeSite {
readonly offset: number;
/** Source of the world id argument, shared by every invocation. */
readonly worldExpression: string;
/** One exposure per world and key, the identity direct syntax uses. */
readonly exposures: Map<string, BridgeExposure>;
/**
* A traced invocation reached this site without an unconditional literal
* key and API, so the site's syntax observation still stands for it.
*/
unresolved: boolean;
}
/** One exposure and the distinct invocation facts merged into it. */
interface BridgeExposure {
value: ElectronContextBridgeFinding;
readonly contributions: Set<string>;
}
const bridgeExposureKey = (value: ElectronContextBridgeFinding): string =>
JSON.stringify([value.world, value.api_key, value.api_key_expression]);
const bridgeContributionKey = (value: ElectronContextBridgeFinding): string =>
JSON.stringify([value.world_id, value.members, value.unknown_members]);
/**
* Exposures of one key at one site from several invocations, or from an
* invocation and the site's own syntax, describe one API whose object and
* world are any of theirs: members of every alternative, and the unknown
* members of the least resolved one, which also absorbs an alternative that
* restates another's unresolved source.
*/
const mergeBridgeExposures = (
left: ElectronContextBridgeFinding,
right: ElectronContextBridgeFinding,
worldExpression: string,
): ElectronContextBridgeFinding => ({
...left,
world_id:
JSON.stringify(left.world_id) === JSON.stringify(right.world_id)
? left.world_id
: { status: "dynamic", value: null, expression: worldExpression },
members: [...new Set([...left.members, ...right.members])].sort(
compareUnicodeCodePoints,
),
unknown_members: Math.max(left.unknown_members, right.unknown_members),
});
interface PathContext {
readonly entryNode: t.Node;
/** Source ranges of the local calls this path made since its entry. */
@@ -456,6 +508,79 @@ export const collectElectronWrappedCallPaths = (
};
const hasConditions = (value: SymbolicValue): boolean =>
symbolicVariants(value).some((variant) => variant.conditions?.length);
const bridgeSites = new Map<string, BridgeSite>();
// Sites whose direct syntax already observed every fact substitution could
// recover: a literal key and an object literal without unknown members.
let completeBridgeSyntax: Set<string> | undefined;
const bridgeSiteAt = (
node: t.CallExpression | t.OptionalCallExpression,
siteKey: string,
worldNode: t.Node | undefined,
): BridgeSite | undefined => {
completeBridgeSyntax ??= new Set(
accumulator.contextBridgeApis.flatMap(({ value }) =>
value.api_key !== null &&
value.api_status === "object-literal" &&
value.unknown_members === 0
? [JSON.stringify(value.location)]
: [],
),
);
if (completeBridgeSyntax.has(siteKey)) return undefined;
let site = bridgeSites.get(siteKey);
if (site === undefined) {
site = {
offset: node.start ?? -1,
worldExpression: boundedExpression(source, worldNode),
exposures: new Map(),
unresolved: false,
};
bridgeSites.set(siteKey, site);
}
return site;
};
/**
* Dotted member paths of one exposed API value, bounded like the direct
* syntax inventory. A nested value is enumerated when it resolves to an
* unconditional object; other values remain leaf members, as in syntax.
*/
const bridgeMemberInventory = (
object: SymbolicValue,
): { readonly members: readonly string[]; readonly unknown: number } => {
const members = new Set<string>();
let unknown = 0;
const visit = (value: SymbolicValue, prefix: readonly string[]): void => {
if (prefix.length >= contextBridgeMemberPathDepth) {
unknown += 1;
return;
}
unknown += resolver.unknownMembers(value);
for (const member of resolver.members(value)) {
if (--remainingVisits < 0) {
unknown += 1;
return;
}
const path = [...prefix, member];
members.add(path.join("."));
for (const variant of symbolicVariants(
resolver.property(value, member),
))
if (
(variant.kind === "object" ||
variant.kind === "record" ||
variant.kind === "namespace") &&
!variant.conditions?.length
)
visit(variant, path);
if (remainingVisits < 0) return;
}
};
visit(object, []);
return {
members: [...members].sort(compareUnicodeCodePoints),
unknown,
};
};
function* bridgeMemberFunctions(
value: SymbolicValue,
segments: readonly string[] = [],
@@ -803,6 +928,19 @@ export const collectElectronWrappedCallPaths = (
context: PathContext,
guardedDispatch: boolean,
): void => {
const location = range(node);
const worldNode =
exposure.worldIdIndex === null
? undefined
: argumentNode(node.arguments[exposure.worldIdIndex]);
// Absent when complete direct syntax leaves substitution nothing to add.
const site = bridgeSiteAt(node, JSON.stringify(location), worldNode);
// The site evaluated in its own scope is not an invocation; a traced call
// that leaves this exposure unresolved keeps the syntax observation.
const unresolved = (): void => {
if (site !== undefined && context.calls.length > 0)
site.unresolved = true;
};
const key = exposure.apiKey;
const object = exposure.api;
const worldValue =
@@ -811,46 +949,19 @@ export const collectElectronWrappedCallPaths = (
: (exposure.worldId ?? symbolicUnknown("missing world id"));
const worldContext =
worldValue === null ? context : metadataContext(context, worldValue);
if (!worldContext) return;
if (!worldContext) return unresolved();
const worldId =
worldValue === null
? null
: electronStaticSymbolicValue(
worldValue,
exposure.worldIdIndex === null
? undefined
: argumentNode(node.arguments[exposure.worldIdIndex]),
);
: electronStaticSymbolicValue(worldValue, worldNode);
if (worldId?.status === "dynamic") accumulator.unknownFindings += 1;
if (key?.kind !== "literal" || typeof key.value !== "string" || !object) {
accumulator.unknownFindings += 1;
return;
return unresolved();
}
const keyContext = metadataContext(worldContext, key);
if (!keyContext || !metadataContext(keyContext, object)) return;
const members = [...resolver.members(object)].sort();
const location = range(node);
const existing = accumulator.contextBridgeApis.find(
({ value }) =>
JSON.stringify(value.location) === JSON.stringify(location),
);
const finding: ElectronContextBridgeFinding = {
world: exposure.world,
world_id: worldId,
api_key: key.value,
api_key_expression: null,
api_status: "dynamic" as const,
resolution_basis: "wrapper-substitution" as const,
// The direct syntax pass recursively retains literal dotted paths. The
// symbolic resolver enumerates only immediate object keys, so its
// replacement must not erase paths already established at this call.
members: [
...new Set([...(existing?.value.members ?? []), ...members]),
].sort(compareUnicodeCodePoints),
unknown_members: resolver.unknownMembers(object),
module_key: null,
location,
};
if (!keyContext || !metadataContext(keyContext, object))
return unresolved();
// This report shape has no predicate field, so a guarded world or object
// cannot be recorded as an unconditional exposure fact.
const guardedWorld = worldValue !== null && hasConditions(worldValue);
@@ -862,22 +973,49 @@ export const collectElectronWrappedCallPaths = (
guardedKey ||
guardedObject ||
guardedDispatch;
if (guarded) accumulator.unknownFindings += 1;
// Existing entries are raw syntax observations and remain valid even when
// symbolic substitution can only describe this call under a predicate.
if (!guarded && existing)
(existing.value.api_status !== "object-literal" ||
existing.value.unknown_members > 0) &&
accumulator.contextBridgeApis.splice(
accumulator.contextBridgeApis.indexOf(existing),
1,
{ offset: node.start ?? -1, value: finding },
if (guarded) {
accumulator.unknownFindings += 1;
unresolved();
} else if (site !== undefined) {
// Each invocation binds its own key and object, so one helper's exposure
// call contributes a separate API per key it is invoked with.
const inventory = bridgeMemberInventory(object);
const finding: ElectronContextBridgeFinding = {
world: exposure.world,
world_id: worldId,
api_key: key.value,
api_key_expression: null,
api_status: "dynamic",
resolution_basis: "wrapper-substitution",
members: inventory.members,
unknown_members: inventory.unknown,
module_key: null,
location,
};
const identity = bridgeExposureKey(finding);
const contribution = bridgeContributionKey(finding);
const prior = site.exposures.get(identity);
if (prior === undefined)
site.exposures.set(identity, {
value: finding,
contributions: new Set([contribution]),
});
else if (!prior.contributions.has(contribution)) {
prior.contributions.add(contribution);
const merged = mergeBridgeExposures(
prior.value,
finding,
site.worldExpression,
);
else if (!guarded)
accumulator.contextBridgeApis.push({
offset: node.start ?? -1,
value: finding,
});
if (
merged.world_id?.status === "dynamic" &&
prior.value.world_id?.status === "literal" &&
worldId?.status === "literal"
)
accumulator.unknownFindings += 1;
prior.value = merged;
}
}
for (const { name, path, fn } of bridgeMemberFunctions(object)) {
const memberContext = metadataContext(keyContext, fn);
if (!memberContext) continue;
@@ -957,6 +1095,42 @@ export const collectElectronWrappedCallPaths = (
}
}
};
/**
* Replace each site's syntax observation with the exposures its invocations
* resolved. One a traced invocation left unresolved still stands for that
* invocation; when it names a resolved key, it is that exposure's
* unresolved alternative.
*/
function recordBridgeExposures(): void {
if (bridgeSites.size === 0) return;
const sites = new Map(bridgeSites);
const resolved = (site: BridgeSite) =>
[...site.exposures.values()].map(({ value }) => ({
offset: site.offset,
value,
}));
const findings = accumulator.contextBridgeApis.flatMap((entry) => {
const siteKey = JSON.stringify(entry.value.location);
const site = sites.get(siteKey);
if (site === undefined) return [entry];
sites.delete(siteKey);
if (site.exposures.size === 0) return [entry];
if (!site.unresolved) return resolved(site);
const same = site.exposures.get(bridgeExposureKey(entry.value));
if (same === undefined) return [entry, ...resolved(site)];
same.value = mergeBridgeExposures(
same.value,
entry.value,
site.worldExpression,
);
return resolved(site);
});
// Exposures direct syntax did not identify follow in source order.
for (const site of [...sites.values()].sort((a, b) => a.offset - b.offset))
for (const finding of resolved(site)) findings.push(finding);
accumulator.contextBridgeApis.length = 0;
for (const finding of findings) accumulator.contextBridgeApis.push(finding);
}
function walk(
node: t.Node,
env: SymbolicEnvironment,
@@ -1077,6 +1251,7 @@ export const collectElectronWrappedCallPaths = (
call(node, env, context, isolatedSeed?.callee);
},
});
recordBridgeExposures();
if (remainingVisits < 0 || resolver.truncated) {
accumulator.unknownFindings += 1;
accumulator.electronCallPathLimitReached = true;
@@ -556,6 +556,90 @@ for (const surface of ["CLI", "stdio MCP"] as const) {
},
);
cliTest(
`binds each contextBridge helper invocation's own key and members through ${surface}`,
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-electron-bridge-helper-calls-",
);
await writeFixtureFiles(root, {
"preload.js": `const { contextBridge, ipcRenderer } = require("electron");
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
function relay(key, label, api) { expose(key, api); }
function named(label, key) { expose(key, { hidden: () => ipcRenderer.send("hidden") }); }
expose("one", { ping: () => ipcRenderer.send("one:ping") });
expose("two", { nested: { pong: () => ipcRenderer.send("two:pong") } });
relay("three", "label", { pang: () => ipcRenderer.send("three:pang") });
contextBridge.exposeInMainWorld("direct", { d: () => ipcRenderer.send("direct:d") });
named("label", globalThis.bridgeName);`,
});
const application = await analyzeJavaScriptDirectory(surface, cli, root);
const bridges = application.graph.nodes
.filter((node) => node.kind === "context-bridge-api")
.flatMap((node) => node.observations)
.map(({ properties }) => ({
api_key: properties.api_key,
api_key_expression: properties.api_key_expression,
members: properties.members,
unknown_members: properties.unknown_members,
resolution_basis: properties.resolution_basis,
}));
expect(bridges).toHaveLength(5);
expect(bridges).toEqual(
expect.arrayContaining([
{
api_key: "one",
api_key_expression: null,
members: ["ping"],
unknown_members: 0,
resolution_basis: "wrapper-substitution",
},
{
api_key: "two",
api_key_expression: null,
members: ["nested", "nested.pong"],
unknown_members: 0,
resolution_basis: "wrapper-substitution",
},
{
api_key: "three",
api_key_expression: null,
members: ["pang"],
unknown_members: 0,
resolution_basis: "wrapper-substitution",
},
{
api_key: "direct",
api_key_expression: null,
members: ["d"],
unknown_members: 0,
resolution_basis: "direct-syntax",
},
// The traced invocation with an unresolved key stays unknown rather
// than lending its members to a resolved key.
{
api_key: null,
api_key_expression: "key",
members: [],
unknown_members: 1,
resolution_basis: "direct-syntax",
},
]),
);
expect(application.summary.context_bridge_apis).toBe(5);
expect(application.summary.exposed_api_members).toBe(5);
const exposureEntries = new Set(
application.graph.nodes
.flatMap((node) => node.observations)
.filter(({ properties }) => properties.step_kind === "exposure")
.map(({ properties }) => properties.entry),
);
expect(exposureEntries).toEqual(
new Set(["one.ping", "two.nested.pong", "three.pang", "direct.d"]),
);
},
);
cliTest(
`preserves handler ambiguity and short-circuit factory paths through ${surface}`,
async ({ cli }) => {