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:
parent
ed485a3dc8
commit
1c37914306
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>> = {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue