Skip to content

fix(window): guard EventEmitter map lookups against missing keys - #2010

Open
AngelPaella wants to merge 5 commits into
mainfrom
angel/window-missing-key-guard
Open

fix(window): guard EventEmitter map lookups against missing keys#2010
AngelPaella wants to merge 5 commits into
mainfrom
angel/window-missing-key-guard

Conversation

@AngelPaella

@AngelPaella AngelPaella commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

EventEmitter.send and EventEmitter.on indexed their schema map and called .safeParse on whatever came back:

send(event, data)  { const result = this.outgoingEvents[event].safeParse(data); ... }
on(event, cb)      { const data = this.incomingEvents[event].safeParse(message.data.data); ... }

An event absent from the map threw TypeError: safeParse is not a function. That turns a map-sync gap between a hosted page and the published contract into a runtime crash whose blast radius depends on the call site: from a synchronous useEffect it reaches the nearest React error boundary and blanks the UI, and from an async continuation it becomes an unhandled rejection that strands the caller.

Found while tracing the embedded-checkout v3 event contract, where the page validates sends against a local copy of the event map that has drifted four keys ahead of the published package.

Change

Both methods test own-key presence before they trust the lookup:

const hasSchema = (map: EventMap, event: PropertyKey) => Object.prototype.hasOwnProperty.call(map, event);

A schema == null check does not cover this. outgoingEvents["constructor"] returns Object and outgoingEvents["toString"] returns a function, so both sail past a null check into .safeParse and throw the same TypeError. hasOwnProperty separates an own key from an inherited one. The repo compiles against the ES6 lib, so Object.hasOwn is out.

send throws a named error identifying the event. on reports the gap through console.error at registration and drops matching messages.

Why send throws rather than transmitting

The first revision of this PR warned and sent the event unvalidated, reasoning that a missing key is our map-sync gap rather than bad caller data, so consumers already listening should keep receiving. Two things sink that argument.

The peer does not know the event either. Version skew is symmetric. If the key is missing from our map, the frame across the postMessage boundary is the same frame whose map is out of date. Sending buys nothing and costs 30 seconds: sendAction waits out DEFAULT_EVENT_OPTIONS.timeoutMs (ncs-signer.ts:452), then WebViewParent.sendAction reads that timeout as a dead frame, calls reloadAndHandshake() (rn-webview/Parent.ts:132), tears down the live signer WebView, and replays for another 30 seconds. A user who taps sign watches the wallet spin for a minute and loses the session to a reload. Throwing rejects in milliseconds, inside sendAction's promise executor, before any retry interval exists.

Some transports target "*". PopupWindow falls back to targetOrigin: "*" when the caller passes none (windows/Popup.ts:67), and WindowTransport.send hands that to postMessage. Transmitting unvalidated converts a thrown error into a broadcast of a payload that can carry a signature, an OTP, or an auth token, to whatever document occupies that popup.

on stays non-throwing. Twenty call sites across react-ui and react-native register listeners inside mount effects, and a throw there converts a dead listener into a mount crash.

Two more fixes inside the listener

on's listener read message.data.event with no null check, so window.postMessage(null, "*") from a browser extension threw inside every registered listener. WindowTransport.addMessageListener filters on origin alone, and isTargetOrigin returns true for every origin once targetOrigin is "*", so any script on the page could fire it. Now message.data?.event.

The schema lookup also left the per-message closure. It ran on every inbound message and warned each time, so under sendAction's 100ms retry polling a single missing key produced up to 100 identical lines before the action rejected with a timeout that named the wrong problem. The maps are assigned once in the constructor and never mutated, so resolving at registration is safe.

Tests

The package had no test setup, so this adds vitest and a config. These tests drive a fake transport and never touch window, so they run under environment: "node".

#2005 adds the same config path with jsdom and an @ alias, which its WindowTransport tests do need. That is an add/add conflict for whichever of the two merges second, resolved by taking #2005's version.

Nine tests. Five fail against the pre-fix EventEmitter, four controls pass either way:

× throws a descriptive error and transmits nothing
× throws for a name inherited from Object.prototype
× reports the gap once at registration rather than per message
× reports a name inherited from Object.prototype
× ignores a message with no payload
✓ transmits a payload that satisfies the schema
✓ drops a payload that violates the schema
✓ invokes the callback with the parsed payload
✓ leaves the others receiving messages

The fake transport keys listeners in a Map instead of overwriting one variable, which is what gives the off() test something to assert.

Left out

  • HandshakeParent and HandshakeChild build their maps with ... as any satisfies IncomingEvents, and EventEmitterOptions.incomingEvents is optional while the class generic is not. That pair is how a map and its declared type drift apart without a cast at the call site. Closing it means requiring the maps when generics are supplied, which changes a public signature.
  • noUncheckedIndexedAccess would let the compiler find every unguarded lookup instead of trusting that two call sites got patched by hand. Enabling it on this package surfaces two errors: the guarded lookups here, already written in a shape that satisfies it, and windows/Popup.ts:111 (parseInt(raw[2]) on a regex match), unrelated to this PR.
  • send transmits the caller's data rather than result.data, so zod validates without sanitizing. Switching would strip fields that live signer and checkout payloads may carry, and wants protocol review first.

Notes

  • Independent of fix/eng4-360-listener-ids. That branch touches the ListenerId type, on's signature, its return, and off; this one touches send's body and the inside of on's listener. The second to merge rebases without conflict.
  • Patch changeset included.
  • Consumers on older published versions do not benefit until they bump. crossbit-main pins @crossmint/client-sdk-window at 0.2.3 against 1.1.0 on main, so that bump is separate and larger.

Merged with main

main gained an EventEmitter suite of its own in #2017, as EventEmitter.test.ts. Its three timeout cases now live in event-emitter.test.ts next to these, which is where the repo's kebab-case convention puts them, and the PascalCase file is deleted. One suite per unit, 16 tests green.

packages/client/window/vitest.config.ts arrived from both sides. Main's version wins: window-transport.test.ts drives a real window.addEventListener, so the environment has to be jsdom, and the @/utils/* imports need its alias. The tests here pass under either environment.

No local vitest devDependency: it is already a root devDependency, and pnpm puts the workspace root's .bin on a package script's PATH. Declaring it here left a lockfile importer entry that --frozen-lockfile rejected once the dependabot upgrade rewrote the resolution key.

send() and on() indexed their schema map and called .safeParse on the
result with no missing-key check, so any event absent from the map threw
a synchronous TypeError instead of degrading.

send() now warns and transmits the event unvalidated. A missing key is a
map-sync gap on our side, not bad caller data, and silently dropping the
event would reproduce the exact failure the guard exists to prevent: a
consumer that already listens for it stops receiving it with no signal.
on() warns and skips the callback.

Adds vitest to the package, which had no test setup, and covers both
guards plus the mapped-event paths they must not disturb.
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c4081cd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@crossmint/client-sdk-window Patch
@crossmint/client-sdk-base Patch
@crossmint/client-sdk-react-base Patch
@crossmint/client-sdk-rn-window Patch
@crossmint/client-sdk-react-ui Patch
@crossmint/wallets-sdk Patch
@crossmint/client-sdk-nextjs-starter Patch
@crossmint/client-sdk-auth Patch
@crossmint/client-sdk-react-native-ui Patch
@crossmint/client-sdk-verifiable-credentials Patch
@crossmint/client-sdk-smart-wallet Patch
@crossmint/common-sdk-auth Patch
@crossmint/auth-ssr-nextjs-demo Patch
@crossmint/wallets-quickstart-devkit Patch
@crossmint/wallets-playground-react Patch
@crossmint/wallets-playground-expo Patch
@crossmint/server-sdk Patch
crossmint-auth-node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@AngelPaella
AngelPaella marked this pull request as ready for review August 10, 2026 13:48
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(window): guard EventEmitter map look..." | Re-trigger Greptile

Address review on #2010.

send()/on() now test own-key presence on their schema maps instead of
indexing blind, so prototype-inherited names no longer resolve to a
non-schema value.

send() throws a named error rather than transmitting unvalidated: an
unmapped event means the peer's map is the one out of sync, and
WindowTransport may be targeting "*". on() resolves its schema once at
registration and no longer dereferences message.data unguarded.

vitest config switched to jsdom with the @ alias, matching the config
#2005 adds for the same path.
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "fix(window): check own-key presence and ..." | Re-trigger Greptile

These tests use a fake transport and never touch window, so jsdom and
the @ alias were both speculative. #2005 brings them in with the
WindowTransport tests that need them.
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (3): Last reviewed commit: "test(window): keep the vitest config on ..." | Re-trigger Greptile

@AngelPaella
AngelPaella requested a review from mPaella August 10, 2026 19:36
vitest.config.ts: main's jsdom config wins. window-transport.test.ts drives
real `window.addEventListener`, and the `@/utils/*` imports need the alias;
the EventEmitter tests this branch adds pass under jsdom either way.

#2017 landed a second EventEmitter suite as EventEmitter.test.ts. Its three
timeout cases move into event-emitter.test.ts, which is where the repo's
kebab-case convention puts them, and the PascalCase file is gone.
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviews (4): Last reviewed commit: "Merge origin/main into angel/window-miss..." | Re-trigger Greptile

vitest 3.2.6 is already a root devDependency and pnpm puts the workspace
root's .bin on a package script's PATH, which is how main runs these tests.
Declaring it here left a lockfile importer entry that --frozen-lockfile
rejected after the dependabot upgrade rewrote the resolution key.
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviews (5): Last reviewed commit: "Drop the local vitest devDependency and ..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant