Skip to content

fix: match sender and origin on inspector RPC messages - #1461

Merged
cyaiox merged 2 commits into
mainfrom
fix/rpc-transport-message-matching
Aug 4, 2026
Merged

fix: match sender and origin on inspector RPC messages#1461
cyaiox merged 2 commits into
mainfrom
fix/rpc-transport-message-matching

Conversation

@cyaiox

@cyaiox cyaiox commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

MessageTransport from @dcl/mini-rpc uses its origin argument only as the postMessage targetOrigin. Inbound it delivers every message event that reaches the window, whatever the source:

window.addEventListener('message', event => { if (event.data) this.emit('message', event.data) })

The RPC pairs are kept apart by channel-id strings (IframeStorage, SceneRpcOutbound), which is a naming convention rather than a check. Outbound, it posts with '*'.

AuthenticatedMessageTransport subclasses the exported abstract Transport and pins both directions:

  • inbound is delivered only when event.source is the iframe's own contentWindow and event.origin matches
  • send targets that origin instead of '*'

Both initRpc and takeScreenshot now use it, replacing the last two MessageTransport constructions.

Why the origin comes from iframe.src

Not rebuilt from inspectorPort: EditorPage/component.tsx honours VITE_INSPECTOR_PORT, so the two differ in development. Reading it off the element also keeps working if the inspector is ever served from somewhere other than localhost.

Transport disposal

Both are now closed, which they weren't before:

  • initRpc's transport is disposed alongside the clients it feeds
  • takeScreenshot disposes the one it creates when no client is passed — restructured so the caller-supplied path returns early and the owned path uses try/finally

The iframe is recreated on every scene open, so each undisposed transport left another live message listener on window. Pre-existing with MessageTransport (which has no dispose at all); this PR is what makes it fixable, since the subclass adds one.

Test plan

transport.spec.ts, 5 cases: delivery from the expected peer and origin, drops from a different window, drops from a different origin, send targeting a concrete origin, and no delivery after dispose. Removing both peer checks fails exactly the two drop cases.

Worth noting one thing the tests taught me: they weren't isolated at first. Each transport attaches to window, and because received is a shared let, a transport left over from an earlier test kept delivering into the next test's array — invisible while the source check was in place, and it produced a spurious third failure the moment I reverted the guards to check them. There's now an afterEach that disposes, which is the same leak this PR fixes in takeScreenshot.

  • npm run test:unit — main 74, preload 44, renderer 136, shared 33
  • make typecheck (0 errors), npm run lint, npm run format
  • open a scene: inspector loads, tabs select, entities render — i.e. the RPC handshake completes in both directions
  • edit and save a scene (write_file over the storage channel)
  • scene thumbnail still regenerates on save (takeScreenshot, the restructured path)
  • open several scenes in a row, then check getEventListeners(window).message.length in devtools stays flat rather than growing per open
  • with VITE_INSPECTOR_PORT set to a non-default port, the inspector still connects

🤖 Generated with Claude Code

https://claude.ai/code/session_01CW4SCgagWDAxR3PKJCMp5d

`MessageTransport` from `@dcl/mini-rpc` uses its `origin` argument only as the
`postMessage` targetOrigin. Inbound, it delivers every `message` event that
reaches the window regardless of who sent it — the RPC pairs are kept apart by
channel-id strings, which is a naming convention rather than a check. Outbound it
posts with `'*'`.

`AuthenticatedMessageTransport` subclasses `Transport` and pins both directions:
an inbound event is delivered only when `event.source` is the iframe's own window
*and* `event.origin` matches, and `send` targets that origin instead of `'*'`.

The origin comes from `iframe.src` rather than being rebuilt from the inspector
port, because `EditorPage` honours `VITE_INSPECTOR_PORT` — the two differ in
development, and reading the iframe keeps working if the inspector is ever served
from somewhere other than localhost.

Both transports are now closed as well. `initRpc`'s is disposed alongside the
clients it feeds, and `takeScreenshot` disposes the one it creates when no client
is passed — the iframe is recreated on every scene open, so each undisposed
transport left another `message` listener on `window`.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Verdict: ✅ Approve — clean, well-motivated security fix with proper tests and no blockers.

What this PR does

Replaces @dcl/mini-rpc's MessageTransport (which accepts every inbound message event regardless of source and posts to '*') with a new AuthenticatedMessageTransport that:

  1. Validates inbound — delivers only when event.source is the expected iframe contentWindow and event.origin matches
  2. Restricts outboundpostMessage targets the iframe's actual origin instead of '*'
  3. Fixes a resource leak — transports are now dispose()d, preventing one message listener from accumulating on window per scene open

Findings

No P0 (blocker) or P1 (major) issues found.

[P2] !event.data falsy coercion (transport.ts:17) — The guard if (!event.data) return would silently drop messages with data: 0, data: "", or data: false. Since @dcl/mini-rpc messages are always { id, type, payload } objects, this is not a practical concern — and is consistent with the upstream MessageTransport's own if (event.data) pattern. A semantically tighter event.data == null would be more precise, but this is a stylistic nit, not a bug.

Security Assessment

  • Origin validation: The dual event.source + event.origin check is the correct postMessage security pattern per MDN/OWASP guidance. ✅
  • Outbound restriction: Targeting a specific targetOrigin instead of '*' prevents data from leaking if the iframe navigates unexpectedly. ✅
  • Origin derivation: getIframeOrigin reads iframe.src via the URL constructor, which is safe — src is set by the host before initRpc runs. ✅
  • No hardcoded secrets or credentials. ✅
  • No injection vectors introduced. ✅

Architecture Notes

  • Subclassing Transport is the right call — the base class is an abstract EventEmitter with send() and no dispose(), so composition would just re-implement the same contract.
  • Asymmetry with the inspector iframe (which still uses MessageTransport) is correct by design — the host is the trust boundary; the iframe only talks to its parent.
  • takeScreenshot refactor — The early-return for the caller-supplied client path, with try/finally for the owned-transport path, makes resource ownership explicit and the leak impossible.
  • Test coverage — 5 focused cases covering accept, reject-by-source, reject-by-origin, outbound targeting, and post-dispose silence. Good.

ADR-6 Compliance

  • PR title fix: match sender and origin on inspector RPC messages
  • Branch fix/rpc-transport-message-matching

Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Test this pull request on macos-latest

Download the correct version for your architecture:

mac-x64
mac-arm64

Click here if you don't know which version to download

For running this unsigned version of the app, you will need to run the xattr command on it:

  1. Extract the app from the downloaded .dmg file (double-click it)
  2. Place the extracted app anywhere you like in your file system
  3. Open a terminal on the directory where the app is
  4. Run xattr -c app-name, replacing "app-name" for the actual name of the app
  5. Double-click the app ✅

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Test this pull request on windows-latest

Download the correct version for your architecture:

win-x64

@cyaiox

cyaiox commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Thanks — on the P2, I'd leave it as is, and the reason is a bit stronger than style.

The guard is redundant with mini-rpc's own check. RPC.handler runs every message through isMessage first:

this.handler = async (message) => { if (this.isMessage(message)) { switch (message.type) { ...

and isMessage begins:

return (value && value.id === this.id && typeof value.type === 'string'
        && messageTypes.includes(value.type) && ...

value && already rejects 0 / "" / false, and it additionally requires id to match this channel and type to be a known message type. So anything !event.data drops gets dropped one layer down anyway, and anything event.data == null would admit gets dropped there too — the two forms are observationally identical. Not worth a commit touching this file for zero behaviour change.

I also checked whether the guard might be filtering legitimate non-Message traffic, which would make a shape check meaningful: there's no non-RPC postMessage from the inspector to its parent, so it isn't.

The honest alternative would be deleting the line as dead code. I'd keep it — it matches upstream's pattern and avoids emitting obvious non-messages into the emitter, even though nothing downstream acts on them.

Happy to make either change if you'd rather; just say which.

The transport captured `iframe.contentWindow` at construction and compared every
inbound `event.source` against it. An element outlives the documents it hosts, so
once the frame navigated the saved window stopped matching and every reply was
dropped — each RPC call then timed out with nothing logged. It showed up under
the Bevy renderer, where the inspector reloads after its realm starts.

Hold the element and read both the window and the origin per message, so the
check always refers to whatever the frame currently hosts. The property being
enforced is unchanged: the message must come from that iframe's window, at that
iframe's origin.

An origin mismatch now warns instead of dropping silently. It is only reachable
for the frame this transport is bound to, so it should never fire — and if it
does, the alternative was a timeout with no indication of why.

The spec covers navigation: replacing `contentWindow` must not stop delivery, must
stop accepting the window it replaced, and must retarget `send`. Restoring the
captured window fails those three plus the no-window case. The original spec could
not have caught this, since it passed a plain object as the peer and a stub never
navigates.
@cyaiox
cyaiox enabled auto-merge (squash) August 4, 2026 12:50
@cyaiox
cyaiox merged commit 9e382f5 into main Aug 4, 2026
19 checks passed
@cyaiox
cyaiox deleted the fix/rpc-transport-message-matching branch August 4, 2026 12:59
cyaiox added a commit that referenced this pull request Aug 7, 2026
One conflict, in renderer/src/modules/rpc/index.ts: main replaced mini-rpc's
MessageTransport with AuthenticatedMessageTransport (#1461) on the same import
lines this branch used to add CodeParserRPC. Kept both — the code-parser
channel shares StorageRPC's transport instance, so it inherits the new
sender/origin check rather than shipping an unauthenticated channel beside a
hardened one.
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.

2 participants