[GH-ISSUE #5871] [Bug] Repeated contextBridge helper calls merge API members under the last key #3557

Closed
opened 2026-10-31 00:41:50 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5871

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

analyze-javascript-application loses the first of two literal contextBridge exposures when both go through one helper. For helper calls exposing one.ping and two.pong, the graph contains only API key two, whose member list incorrectly contains both ping and pong. The same graph still retains exposure call-path entries for one.ping and two.pong. Its API count is one; the direct-call control reports two APIs with the correct memberships.

Reproduction

From the repository root at the revision below, with the locked dependencies and compiled runtime already prepared, run this block. It creates disposable inputs, invokes the supported CLI, prints the relevant fields, and removes its inputs on exit. This block was executed successfully; setup/build commands were not rerun.

set -eu
repro_dir="$(mktemp -d)"
trap 'rm -rf "$repro_dir"' EXIT
mkdir -p "$repro_dir/helper" "$repro_dir/direct"
cat > "$repro_dir/helper/preload.js" <<'JS'
const { contextBridge, ipcRenderer } = require("electron");
function expose(key, api) { contextBridge.exposeInMainWorld(key, api); }
expose("one", { ping: () => ipcRenderer.send("one:ping") });
expose("two", { pong: () => ipcRenderer.send("two:pong") });
JS
cat > "$repro_dir/direct/preload.js" <<'JS'
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("one", { ping: () => ipcRenderer.send("one:ping") });
contextBridge.exposeInMainWorld("two", { pong: () => ipcRenderer.send("two:pong") });
JS
for mode in helper direct; do
  node scripts/rea.mjs analyze-javascript-application "$repro_dir/$mode" --json > "$repro_dir/$mode.json"
done
node --input-type=module - "$repro_dir" <<'JS'
import { readFileSync } from "node:fs";
import { join } from "node:path";
for (const mode of ["helper", "direct"]) {
  const r = JSON.parse(readFileSync(join(process.argv[2], `${mode}.json`), "utf8")).normalized_result;
  const apis = r.graph.nodes.filter(n => n.kind === "context-bridge-api")
    .flatMap(n => n.observations.map(o => ({api_key: o.properties.api_key,
      members: o.properties.members, unknown_members: o.properties.unknown_members})));
  const entries = [...new Set(r.graph.nodes.flatMap(n => n.observations)
    .filter(o => o.properties.step_kind === "exposure")
    .map(o => o.properties.entry))].sort();
  console.log(JSON.stringify({mode, context_bridge_apis: r.summary.context_bridge_apis,
    exposed_api_members: r.summary.exposed_api_members, apis, exposure_entries: entries}));
}
JS

Expected behavior

Preserve both resolved exposure candidates and associate each member with its own API key: one has ["ping"], and two has ["pong"]. The summary should count two APIs. This follows the documented bridge-key/member recovery, the documented local wrapper substitution, and the executed direct-call control. Preserve static-candidate provenance; neither fixture establishes runtime execution.

Impact and scope

P2: the reproduced bounded input silently loses one API key and attributes another API’s member to the surviving key. The command succeeds and retains both exposure call paths, allowing manual comparison. The total member count happens to remain two in this example; only the API count and per-key membership are demonstrated wrong. A practical automatic workaround that preserves the analyzed artifact was not established. Rewriting the helper into direct calls is a diagnostic control that changes the input. Verification covered the built CLI on local directories; ASAR and MCP transport were not exercised. No security boundary crossing was observed.

REA version

6.7.0, built source checkout at 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64. No Electron runtime or native analysis provider was used.

Target and artifact context

Synthetic extracted Electron application directories, each containing one CommonJS preload.js. REA parsed these files as inert source; the fixture code was not executed.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed output from the block above (both analysis commands exited 0):

{"mode":"helper","context_bridge_apis":1,"exposed_api_members":2,"apis":[{"api_key":"two","members":["ping","pong"],"unknown_members":0}],"exposure_entries":["one.ping","two.pong"]}
{"mode":"direct","context_bridge_apis":2,"exposed_api_members":2,"apis":[{"api_key":"one","members":["ping"],"unknown_members":0},{"api_key":"two","members":["pong"],"unknown_members":0}],"exposure_entries":["one.ping","two.pong"]}

The helper result has resolution_basis: "wrapper-substitution", api_status: "dynamic", and partial graph coverage with no truncation. The direct control has resolution_basis: "direct-syntax" and complete graph coverage. The claim concerns specific retained key/member facts, not a claim that the helper graph advertises globally complete coverage.

Typed outcome or recovery expected

Return successful static analysis containing both distinct literal API candidates, with correct per-key members and API count, without merging unrelated memberships. Retain the two exposure paths and their existing source evidence. Do not recover the count by adding a duplicate key or discarding path provenance.

Analysis or suspected cause

Source-derived explanation: bridge finding construction and replacement finds an existing API solely by the inner exposeInMainWorld call’s range. Each helper invocation reaches that same range. A later wrapper-substituted finding replaces the prior dynamic finding, while its member list unions the previous members. This explains why the later key survives with both memberships. The graph projection publishes those retained fields, and the summary calculation counts them. No repair was applied or tested.

Additional context

Freshly fetched origin/main at f801b2fbfd28f4e3713adefddc7bd73db44c284c has no changes to the relevant producer, member resolvers, graph projection or summary files since the tested revision. Main was checked by source comparison, not a separate build.

Related IPC reports #5586 and #5710 and PR #5810 concern duplicate IPC observations; this case loses distinct contextBridge API facts. The single-invocation nested-member omission is a separate problem: retaining invocations alone does not enumerate nested paths. No last known-good revision was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5871 Originally assigned to: @kaoru0822-kitauji on GitHub. ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `analyze-javascript-application` loses the first of two literal contextBridge exposures when both go through one helper. For helper calls exposing `one.ping` and `two.pong`, the graph contains only API key `two`, whose member list incorrectly contains both `ping` and `pong`. The same graph still retains exposure call-path entries for `one.ping` and `two.pong`. Its API count is one; the direct-call control reports two APIs with the correct memberships. ### Reproduction From the repository root at the revision below, with the locked dependencies and compiled runtime already prepared, run this block. It creates disposable inputs, invokes the supported CLI, prints the relevant fields, and removes its inputs on exit. This block was executed successfully; setup/build commands were not rerun. ```sh set -eu repro_dir="$(mktemp -d)" trap 'rm -rf "$repro_dir"' EXIT mkdir -p "$repro_dir/helper" "$repro_dir/direct" cat > "$repro_dir/helper/preload.js" <<'JS' const { contextBridge, ipcRenderer } = require("electron"); function expose(key, api) { contextBridge.exposeInMainWorld(key, api); } expose("one", { ping: () => ipcRenderer.send("one:ping") }); expose("two", { pong: () => ipcRenderer.send("two:pong") }); JS cat > "$repro_dir/direct/preload.js" <<'JS' const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("one", { ping: () => ipcRenderer.send("one:ping") }); contextBridge.exposeInMainWorld("two", { pong: () => ipcRenderer.send("two:pong") }); JS for mode in helper direct; do node scripts/rea.mjs analyze-javascript-application "$repro_dir/$mode" --json > "$repro_dir/$mode.json" done node --input-type=module - "$repro_dir" <<'JS' import { readFileSync } from "node:fs"; import { join } from "node:path"; for (const mode of ["helper", "direct"]) { const r = JSON.parse(readFileSync(join(process.argv[2], `${mode}.json`), "utf8")).normalized_result; const apis = r.graph.nodes.filter(n => n.kind === "context-bridge-api") .flatMap(n => n.observations.map(o => ({api_key: o.properties.api_key, members: o.properties.members, unknown_members: o.properties.unknown_members}))); const entries = [...new Set(r.graph.nodes.flatMap(n => n.observations) .filter(o => o.properties.step_kind === "exposure") .map(o => o.properties.entry))].sort(); console.log(JSON.stringify({mode, context_bridge_apis: r.summary.context_bridge_apis, exposed_api_members: r.summary.exposed_api_members, apis, exposure_entries: entries})); } JS ``` ### Expected behavior Preserve both resolved exposure candidates and associate each member with its own API key: `one` has `["ping"]`, and `two` has `["pong"]`. The summary should count two APIs. This follows the [documented bridge-key/member recovery](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/javascript-artifact-reconstruction.md#L253-L254), the [documented local wrapper substitution](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/javascript-artifact-reconstruction.md#L370-L376), and the executed direct-call control. Preserve static-candidate provenance; neither fixture establishes runtime execution. ### Impact and scope P2: the reproduced bounded input silently loses one API key and attributes another API’s member to the surviving key. The command succeeds and retains both exposure call paths, allowing manual comparison. The total member count happens to remain two in this example; only the API count and per-key membership are demonstrated wrong. A practical automatic workaround that preserves the analyzed artifact was not established. Rewriting the helper into direct calls is a diagnostic control that changes the input. Verification covered the built CLI on local directories; ASAR and MCP transport were not exercised. No security boundary crossing was observed. ### REA version 6.7.0, built source checkout at `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64. No Electron runtime or native analysis provider was used. ### Target and artifact context Synthetic extracted Electron application directories, each containing one CommonJS `preload.js`. REA parsed these files as inert source; the fixture code was not executed. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed output from the block above (both analysis commands exited 0): ```json {"mode":"helper","context_bridge_apis":1,"exposed_api_members":2,"apis":[{"api_key":"two","members":["ping","pong"],"unknown_members":0}],"exposure_entries":["one.ping","two.pong"]} {"mode":"direct","context_bridge_apis":2,"exposed_api_members":2,"apis":[{"api_key":"one","members":["ping"],"unknown_members":0},{"api_key":"two","members":["pong"],"unknown_members":0}],"exposure_entries":["one.ping","two.pong"]} ``` The helper result has `resolution_basis: "wrapper-substitution"`, `api_status: "dynamic"`, and partial graph coverage with no truncation. The direct control has `resolution_basis: "direct-syntax"` and complete graph coverage. The claim concerns specific retained key/member facts, not a claim that the helper graph advertises globally complete coverage. ### Typed outcome or recovery expected Return successful static analysis containing both distinct literal API candidates, with correct per-key members and API count, without merging unrelated memberships. Retain the two exposure paths and their existing source evidence. Do not recover the count by adding a duplicate key or discarding path provenance. ### Analysis or suspected cause Source-derived explanation: [bridge finding construction and replacement](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/javascript/electronWrappedCallPaths.ts#L522-L570) finds an existing API solely by the inner `exposeInMainWorld` call’s range. Each helper invocation reaches that same range. A later wrapper-substituted finding replaces the prior dynamic finding, while its member list unions the previous members. This explains why the later key survives with both memberships. The [graph projection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/ElectronBoundaryGraphWindows.ts#L198-L239) publishes those retained fields, and the [summary calculation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/ElectronBoundaryAnalysis.ts#L129-L133) counts them. No repair was applied or tested. ### Additional context Freshly fetched `origin/main` at `f801b2fbfd28f4e3713adefddc7bd73db44c284c` has no changes to the relevant producer, member resolvers, graph projection or summary files since the tested revision. Main was checked by source comparison, not a separate build. Related IPC reports #5586 and #5710 and PR #5810 concern duplicate IPC observations; this case loses distinct contextBridge API facts. The single-invocation nested-member omission is a separate problem: retaining invocations alone does not enumerate nested paths. No last known-good revision was established.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3557
No description provided.