[GH-ISSUE #4513] [Bug] Electron raw listener metadata mutation can defeat canonical removal through app decorators #1792

Closed
opened 2026-10-16 21:10:18 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @N0zoM1z0 on GitHub (Oct 16, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4513

Before submitting

  • Searched open and closed reports for rawListeners and listener mutation.
  • Evidence contains no credentials or sensitive target data.

Area

Process capture or runtime observation

Observed behavior

Native Node control removes the callback; the hook passes its recording wrapper to an app removal decorator, which rejects it and leaves one listener. This requires mutating the writable .listener property of an entry returned by rawListeners() and enabling Node's removal-meta-event traversal.

Reproduction

Ran once on Linux x86_64 / Node 24.18.0 at 514b73b78e76f7dc4eeeaca3c708e0667e9a052c, using the existing test's stub Electron module and the production hook. This is a Node boundary reproduction, not a real Electron application claim.

const { EventEmitter } = require('node:events');
const { ipcMain } = require('electron');
function scenario(emitter) {
 const f=()=>{}; const nativeRemove=emitter.removeListener; let failure=null;const seen=[];
 emitter.on('removeListener',()=>{});emitter.on('ch',f);
 emitter.rawListeners('ch')[0].listener=()=>{};
 emitter.removeListener=function(channel,listener){if(channel==='ch'){seen.push(listener===f);if(listener!==f)throw new Error('unexpected callback');}return Reflect.apply(nativeRemove,this,arguments);};
 try{emitter.removeAllListeners('ch');}catch(e){failure=e.message;}
 return {seen,remaining:emitter.listenerCount('ch'),failure};
}
console.log(JSON.stringify({plain:scenario(new EventEmitter()),hooked:scenario(ipcMain)}));

Load with node -r ./electron-active-hook.cjs probe.cjs, staging the hook and its sibling boundary module beside the existing test fixture's stub electron package.

Expected behavior

An observation hook should preserve callback identity and removal, including a clearly defined policy for raw wrapper mutation.

Impact and scope

Observed deterministic callback identity and cleanup divergence under this metaprogramming pattern. Real-app frequency, CLI/MCP end-to-end impact and platform scope: NOT_ENOUGH_INFO; no affected real app identified.

REA version

6.5.0; candidate revision above, during PR #4389 / #4390.

Runtime and platform

Node 24.18.0, Linux x86_64.

Target and artifact context

Existing module fixture plus native Node EventEmitter; no CTF or demo application used as real-app validation.

Interface

Other — production hook boundary.

Earliest failure stage

Capture or observation.

Relevant output or evidence

{"plain":{"seen":[true],"remaining":0,"failure":null},"hooked":{"seen":[false],"remaining":1,"failure":"unexpected callback"}}

Typed outcome or recovery expected

Keep original-function removal and explicit coverage limits; do not claim complete JavaScript introspection transparency.

Analysis or suspected cause

Source-derived: the weak registry retains canonical identity, but translating a changed raw data link makes native removal fail to match the actual registered wrapper. Merely removing the descriptor guard does not fix deletion; restoring public metadata temporarily can introduce further app-visible mutations. Address the ownership/exposure boundary instead of adding another snapshot-removal algorithm.

Additional context

Discovered in PR review. Related #4390 and umbrella #4027. Ordinary original-function removal and once recording are fixed separately; custom .listener introspection was already explicitly limited. This report preserves the exact remaining raw-mutation case for a deliberate design follow-up.

Originally created by @N0zoM1z0 on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4513 ### Before submitting - [x] Searched open and closed reports for `rawListeners` and listener mutation. - [x] Evidence contains no credentials or sensitive target data. ### Area Process capture or runtime observation ### Observed behavior Native Node control removes the callback; the hook passes its recording wrapper to an app removal decorator, which rejects it and leaves one listener. This requires mutating the writable `.listener` property of an entry returned by `rawListeners()` and enabling Node's removal-meta-event traversal. ### Reproduction Ran once on Linux x86_64 / Node 24.18.0 at `514b73b78e76f7dc4eeeaca3c708e0667e9a052c`, using the existing test's stub Electron module and the production hook. This is a Node boundary reproduction, not a real Electron application claim. ```js const { EventEmitter } = require('node:events'); const { ipcMain } = require('electron'); function scenario(emitter) { const f=()=>{}; const nativeRemove=emitter.removeListener; let failure=null;const seen=[]; emitter.on('removeListener',()=>{});emitter.on('ch',f); emitter.rawListeners('ch')[0].listener=()=>{}; emitter.removeListener=function(channel,listener){if(channel==='ch'){seen.push(listener===f);if(listener!==f)throw new Error('unexpected callback');}return Reflect.apply(nativeRemove,this,arguments);}; try{emitter.removeAllListeners('ch');}catch(e){failure=e.message;} return {seen,remaining:emitter.listenerCount('ch'),failure}; } console.log(JSON.stringify({plain:scenario(new EventEmitter()),hooked:scenario(ipcMain)})); ``` Load with `node -r ./electron-active-hook.cjs probe.cjs`, staging the hook and its sibling boundary module beside the existing test fixture's stub `electron` package. ### Expected behavior An observation hook should preserve callback identity and removal, including a clearly defined policy for raw wrapper mutation. ### Impact and scope Observed deterministic callback identity and cleanup divergence under this metaprogramming pattern. Real-app frequency, CLI/MCP end-to-end impact and platform scope: NOT_ENOUGH_INFO; no affected real app identified. ### REA version 6.5.0; candidate revision above, during PR #4389 / #4390. ### Runtime and platform Node 24.18.0, Linux x86_64. ### Target and artifact context Existing module fixture plus native Node EventEmitter; no CTF or demo application used as real-app validation. ### Interface Other — production hook boundary. ### Earliest failure stage Capture or observation. ### Relevant output or evidence ```json {"plain":{"seen":[true],"remaining":0,"failure":null},"hooked":{"seen":[false],"remaining":1,"failure":"unexpected callback"}} ``` ### Typed outcome or recovery expected Keep original-function removal and explicit coverage limits; do not claim complete JavaScript introspection transparency. ### Analysis or suspected cause Source-derived: the weak registry retains canonical identity, but translating a changed raw data link makes native removal fail to match the actual registered wrapper. Merely removing the descriptor guard does not fix deletion; restoring public metadata temporarily can introduce further app-visible mutations. Address the ownership/exposure boundary instead of adding another snapshot-removal algorithm. ### Additional context Discovered in [PR review](https://github.com/morluto/rea/pull/4389#discussion_r4296101319). Related #4390 and umbrella #4027. Ordinary original-function removal and once recording are fixed separately; custom `.listener` introspection was already explicitly limited. This report preserves the exact remaining raw-mutation case for a deliberate design follow-up.
Author
Owner

@morluto commented on GitHub (Oct 19, 2026):

Fixed on main in dfe8378cf.

The root cause was using writable public .listener metadata as the removal identity. Private registration ownership now determines the canonical callback and exact native match argument in current reverse registration order. Node still owns traversal, mutation, getter/error timing, and removal meta-events. This also fixes changed/deleted metadata through direct removeListener/off and preserves borrowed emitter ownership.

The decorator contract has an explicit boundary: registrations whose methods differ from the captured Node baseline, or whose receiver is another emitter, stay unwrapped with a listener-registration gap. Supported registrations retain actual per-callback observations. Opaque live state delegates to native removal with a listener-removal gap. No dispatch-only evidence substitution or post-call count repair was adopted. PR #4595 was reviewed but not merged: its count-based repair misses registration-order and chained-match counterexamples.

Validation: unchanged d6ca7b3 produced 21 failures in the final 101-case hook suite; the fix passes all 101 on Node 22.22.2 and 24.18.0. The broader focused run passed 199 tests across 11 files, including CLI/MCP consumers and cleanup. Static/architecture checks, documentation checks, and test discovery passed. Six completed controlled hook workflows retained the same semantic event oracle and estimated full-snapshot token count; this is not a performance improvement claim.

Scope: controlled production-hook tests using the existing stub Electron module and real Node EventEmitter on macOS arm64. No real Electron application/runtime verification. The docs retain the limits for removal aliases bypassing the hook, opaque state, and a globally decorated EventEmitter prototype before hook initialization. This fixes the reported boundary reproduction without claiming complete JavaScript introspection equivalence.

<!-- gh-comment-id:6257954273 --> @morluto commented on GitHub (Oct 19, 2026): Fixed on main in [`dfe8378cf`](https://github.com/morluto/rea/commit/dfe8378cf5a727e3d93d47a5986233175c6ef898). The root cause was using writable public `.listener` metadata as the removal identity. Private registration ownership now determines the canonical callback and exact native match argument in current reverse registration order. Node still owns traversal, mutation, getter/error timing, and removal meta-events. This also fixes changed/deleted metadata through direct `removeListener`/`off` and preserves borrowed emitter ownership. The decorator contract has an explicit boundary: registrations whose methods differ from the captured Node baseline, or whose receiver is another emitter, stay unwrapped with a `listener-registration` gap. Supported registrations retain actual per-callback observations. Opaque live state delegates to native removal with a `listener-removal` gap. No dispatch-only evidence substitution or post-call count repair was adopted. PR #4595 was reviewed but not merged: its count-based repair misses registration-order and chained-match counterexamples. Validation: unchanged d6ca7b3 produced 21 failures in the final 101-case hook suite; the fix passes all 101 on Node 22.22.2 and 24.18.0. The broader focused run passed 199 tests across 11 files, including CLI/MCP consumers and cleanup. Static/architecture checks, documentation checks, and test discovery passed. Six completed controlled hook workflows retained the same semantic event oracle and estimated full-snapshot token count; this is not a performance improvement claim. Scope: controlled production-hook tests using the existing stub Electron module and real Node EventEmitter on macOS arm64. No real Electron application/runtime verification. The docs retain the limits for removal aliases bypassing the hook, opaque state, and a globally decorated EventEmitter prototype before hook initialization. This fixes the reported boundary reproduction without claiming complete JavaScript introspection equivalence.
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#1792
No description provided.