fix(javascript): retain wrapped isolated-world bridge exposures

Share contextBridge argument ownership between direct and wrapped analysis, seed intact isolated-world calls, and preserve dynamic world IDs and conditional metadata as unknown. Keep callee evaluation scoped to each invocation.

Validation: 99 focused source tests and bundled isolated-world acceptance through dedicated and generic CLI routes. Removed duplicate literal source regression in favor of public workflow coverage. Fixes #2174.
This commit is contained in:
morluto 2026-10-13 03:47:25 +08:00
parent ed485a3dc8
commit 1c37914306
5 changed files with 243 additions and 28 deletions

View File

@ -9,6 +9,7 @@ import type {
ElectronUtilityProcessFinding,
ElectronWebPreference,
} from "./electronStaticAnalysisTypes.js";
import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposure.js";
import {
boundedExpression,
collectContextBridgeMembers,
@ -166,18 +167,19 @@ const inspectContextBridge = (
context: JavaScriptFindingContext,
): void => {
const path = electronCalleePath(node.callee, context);
const exposureMethod =
path?.length === 2 && path[0] === "contextBridge" ? path[1] : undefined;
const main = exposureMethod === "exposeInMainWorld";
const isolated = exposureMethod === "exposeInIsolatedWorld";
if (!main && !isolated) return;
const keyNode = argumentNode(node.arguments[isolated ? 1 : 0]);
const apiNode = argumentNode(node.arguments[isolated ? 2 : 1]);
const exposure =
path?.length === 2 && path[0] === "contextBridge"
? contextBridgeExposureArguments(path[1] ?? "", node.arguments)
: undefined;
if (exposure === undefined) return;
const keyNode = argumentNode(exposure.apiKey);
const apiNode = argumentNode(exposure.api);
const key = electronStaticValue(context.source, keyNode);
const api = collectContextBridgeMembers(apiNode);
const worldId = isolated
? electronStaticValue(context.source, argumentNode(node.arguments[0]))
: null;
const worldId =
exposure.worldId === null
? null
: electronStaticValue(context.source, argumentNode(exposure.worldId));
const unknown =
(key.status === "dynamic" ? 1 : 0) +
(worldId?.status === "dynamic" ? 1 : 0) +
@ -191,7 +193,7 @@ const inspectContextBridge = (
: { api_key: null, api_key_expression: null };
const finding: ElectronContextBridgeFinding = {
...apiKey,
world: isolated ? "isolated" : "main",
world: exposure.world,
world_id: worldId,
api_status: api.status,
members: api.members,
@ -201,7 +203,7 @@ const inspectContextBridge = (
};
addLocatedFinding(context, {
collection: context.accumulator.contextBridgeApis,
key: `context-bridge\0${isolated ? "isolated" : "main"}\0${finding.api_key ?? finding.api_key_expression ?? "[missing]"}`,
key: `context-bridge\0${exposure.world}\0${finding.api_key ?? finding.api_key_expression ?? "[missing]"}`,
node,
value: finding,
});

View File

@ -140,6 +140,60 @@ it("preserves an observed empty contextBridge API object", () => {
expect(result.parse_status).toBe("complete");
});
describe("wrapped contextBridge exposure methods", () => {
it("retains the original expression for a dynamic isolated world id", () => {
const result = analyzeJavaScriptStaticSource(
bundle(
`const worldId = selectWorld(); electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`,
),
);
expect(result.electron.context_bridge_apis).toContainEqual(
expect.objectContaining({
world: "isolated",
world_id: {
status: "dynamic",
value: null,
expression: "worldId",
},
api_key: "isolatedApi",
members: ["ping"],
resolution_basis: "wrapper-substitution",
}),
);
expect(result.parse_status).toBe("partial");
});
it("does not report a conditionally selected world as unconditional", () => {
const result = analyzeJavaScriptStaticSource(
bundle(
`if (useIsolated) electron.contextBridge.exposeInIsolatedWorld(1001, "isolatedApi", {ping: true});`,
),
);
expect(result.electron.context_bridge_apis).not.toContainEqual(
expect.objectContaining({
api_key: "isolatedApi",
resolution_basis: "wrapper-substitution",
}),
);
expect(result.parse_status).toBe("partial");
});
it("does not report conditionally resolved world ids as unconditional", () => {
const result = analyzeJavaScriptStaticSource(
bundle(
`const worldId = useFirstWorld ? 1001 : 1002; electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`,
),
);
expect(result.electron.context_bridge_apis).not.toContainEqual(
expect.objectContaining({
api_key: "isolatedApi",
resolution_basis: "wrapper-substitution",
}),
);
expect(result.parse_status).toBe("partial");
});
});
it("connects exposed closures to IPC sends and text clipboard calls", () => {
const result = analyzeJavaScriptStaticSource(
bundle(`

View File

@ -14,9 +14,17 @@ import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisSt
import type {
ElectronCallPath,
ElectronCallPathStep,
ElectronContextBridgeFinding,
ElectronIpcDescriptor,
ElectronIpcFinding,
ElectronStaticValue,
} from "./electronStaticAnalysisTypes.js";
import {
contextBridgeExposureArguments,
type JavaScriptContextBridgeExposureArguments,
} from "./javascriptContextBridgeExposure.js";
import { boundedExpression } from "./electronStaticAnalysisValues.js";
import { argumentNode } from "./javascriptStaticAnalysisHelpers.js";
import {
childNodes,
traverseJavaScriptAst,
@ -114,6 +122,41 @@ export const collectElectronWrappedCallPaths = (
};
const hasConditions = (value: SymbolicValue): boolean =>
symbolicVariants(value).some((variant) => variant.conditions?.length);
const isolatedBridgeCall = (
node: t.CallExpression | t.OptionalCallExpression,
):
| { readonly env: SymbolicEnvironment; readonly callee: SymbolicValue }
| undefined => {
const env = resolver.environmentAt(node);
const callee = resolver.evaluate(node.callee, env);
return symbolicVariants(callee).some(
(callee) =>
callee.kind === "external" &&
identity.intactExport(callee.path) &&
callee.path.slice(1).join(".") ===
"contextBridge.exposeInIsolatedWorld",
)
? { env, callee }
: undefined;
};
const electronStaticSymbolicValue = (
value: SymbolicValue | undefined,
node: t.Node | undefined,
): ElectronStaticValue => {
const variants = value === undefined ? [] : symbolicVariants(value);
const variant = variants.length === 1 ? variants[0] : undefined;
if (variant?.kind === "literal" && variant.value !== undefined)
return {
status: "literal",
value: variant.value,
expression: null,
};
return {
status: "dynamic",
value: null,
expression: boundedExpression(source, node),
};
};
const emit = (
context: PathContext,
node: t.Node,
@ -288,17 +331,34 @@ export const collectElectronWrappedCallPaths = (
};
const bridge = (
node: t.CallExpression | t.OptionalCallExpression,
args: readonly SymbolicValue[],
exposure: JavaScriptContextBridgeExposureArguments<SymbolicValue>,
context: PathContext,
guardedDispatch: boolean,
): void => {
const key = args[0];
const object = args[1];
const key = exposure.apiKey;
const object = exposure.api;
const worldValue =
exposure.worldId === null
? null
: (exposure.worldId ?? symbolicUnknown("missing world id"));
const worldContext =
worldValue === null ? context : metadataContext(context, worldValue);
if (!worldContext) return;
const worldId =
worldValue === null
? null
: electronStaticSymbolicValue(
worldValue,
exposure.worldIdIndex === null
? undefined
: argumentNode(node.arguments[exposure.worldIdIndex]),
);
if (worldId?.status === "dynamic") accumulator.unknownFindings += 1;
if (key?.kind !== "literal" || typeof key.value !== "string" || !object) {
accumulator.unknownFindings += 1;
return;
}
const keyContext = metadataContext(context, key);
const keyContext = metadataContext(worldContext, key);
if (!keyContext || !metadataContext(keyContext, object)) return;
const members = [...resolver.members(object)].sort();
const location = range(node);
@ -306,9 +366,9 @@ export const collectElectronWrappedCallPaths = (
({ value }) =>
JSON.stringify(value.location) === JSON.stringify(location),
);
const finding = {
world: "main" as const,
world_id: null,
const finding: ElectronContextBridgeFinding = {
world: exposure.world,
world_id: worldId,
api_key: key.value,
api_key_expression: null,
api_status: "dynamic" as const,
@ -320,10 +380,12 @@ export const collectElectronWrappedCallPaths = (
};
// 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);
const guardedKey = hasConditions(key);
const guardedObject = hasConditions(object);
const guarded =
context.conditions.length > 0 ||
guardedWorld ||
guardedKey ||
guardedObject ||
guardedDispatch;
@ -370,8 +432,9 @@ export const collectElectronWrappedCallPaths = (
node: t.CallExpression | t.OptionalCallExpression,
env: SymbolicEnvironment,
context: PathContext,
evaluatedCallee?: SymbolicValue,
): void => {
const value = resolver.evaluate(node.callee, env);
const value = evaluatedCallee ?? resolver.evaluate(node.callee, env);
const args = resolver.arguments(node, env);
for (const callee of symbolicVariants(value)) {
const branchContext = metadataContext(context, callee);
@ -394,7 +457,13 @@ export const collectElectronWrappedCallPaths = (
const endpoint = path.join(".");
if (identity.intactExport(path)) {
const descriptor = ipcDescriptor(path.slice(1));
if (descriptor)
const exposure =
path.length === 3 && path[1] === "contextBridge"
? contextBridgeExposureArguments(path[2] ?? "", args)
: undefined;
if (exposure)
bridge(node, exposure, branchContext, hasConditions(callee));
else if (descriptor)
ipc(
node,
descriptor,
@ -403,10 +472,6 @@ export const collectElectronWrappedCallPaths = (
endpoint,
hasConditions(callee),
);
else if (
path.slice(1).join(".") === "contextBridge.exposeInMainWorld"
)
bridge(node, args, branchContext, hasConditions(callee));
else if (path[1] === "clipboard") emit(branchContext, node, endpoint);
} else if (
(path[0] === "navigator" && path[1] === "clipboard") ||
@ -488,8 +553,12 @@ export const collectElectronWrappedCallPaths = (
enter: (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
if (!t.isStringLiteral(node.arguments[0])) return;
const env = resolver.environmentAt(node);
const isolatedSeed = t.isStringLiteral(node.arguments[0])
? undefined
: isolatedBridgeCall(node);
if (!t.isStringLiteral(node.arguments[0]) && isolatedSeed === undefined)
return;
const env = isolatedSeed?.env ?? resolver.environmentAt(node);
let context: PathContext = {
entryNode: node,
entry: "",
@ -536,7 +605,7 @@ export const collectElectronWrappedCallPaths = (
return;
}
}
call(node, env, context);
call(node, env, context, isolatedSeed?.callee);
},
});
if (remainingVisits < 0 || resolver.truncated) {

View File

@ -0,0 +1,35 @@
/** One contextBridge exposure method and its API argument positions. */
export interface JavaScriptContextBridgeExposureArguments<Argument> {
readonly world: "main" | "isolated";
readonly worldId: Argument | null | undefined;
readonly worldIdIndex: number | null;
readonly apiKey: Argument | undefined;
readonly api: Argument | undefined;
}
/**
* Select the semantic arguments for a contextBridge exposure method.
* Callers retain authority over callee identity and interpretation of values.
*/
export const contextBridgeExposureArguments = <Argument>(
method: string,
arguments_: readonly Argument[],
): JavaScriptContextBridgeExposureArguments<Argument> | undefined => {
if (method === "exposeInMainWorld")
return {
world: "main",
worldId: null,
worldIdIndex: null,
apiKey: arguments_[0],
api: arguments_[1],
};
if (method === "exposeInIsolatedWorld")
return {
world: "isolated",
worldId: arguments_[0],
worldIdIndex: 0,
apiKey: arguments_[1],
api: arguments_[2],
};
return undefined;
};

View File

@ -18,6 +18,7 @@ import {
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
} from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
javascriptApplicationAnalysisResultSchema,
type JavaScriptApplicationAnalysisResult,
@ -1530,6 +1531,60 @@ const analyzeComparisonSources = async (
);
};
it("retains bundled isolated-world contextBridge APIs through dedicated and generic CLI routes", async () => {
const root = await createTestTempDirectory(
"rea-context-bridge-isolated-cli-",
);
await writeFile(
join(root, "preload.js"),
`(() => {
const modules = {
2(module) { module.exports = require("electron"); },
5(module, exports, load) {
const electron = load(2);
electron.contextBridge.exposeInIsolatedWorld(1001, "isolatedApi", { ping: true });
},
};
function load(id) {
const module = { exports: {} };
modules[id].call(module.exports, module, module.exports, load);
return module.exports;
}
load(5);
})();`,
);
for (const arguments_ of [
["analyze-javascript-application", root, "--json"],
["analyze", root, "--format", "json"],
]) {
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(await runCli(arguments_)).normalized_result,
);
expect(result.summary.context_bridge_apis).toBe(1);
const bridge = result.graph.nodes.find(
({ kind }) => kind === "context-bridge-api",
);
expect(bridge?.observations).toContainEqual(
expect.objectContaining({
properties: expect.objectContaining({
world: "isolated",
world_id: { status: "literal", value: 1001, expression: null },
api_key: "isolatedApi",
members: ["ping"],
resolution_basis: "wrapper-substitution",
}),
evidence: expect.objectContaining({
location: expect.objectContaining({
available: true,
value: expect.objectContaining({ source: "preload.js" }),
}),
}),
}),
);
}
});
const runCli = async (
arguments_: readonly string[],
environment: Readonly<Record<string, string>> = {},