fix: match sender and origin on inspector RPC messages - #1461
Conversation
`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
left a comment
There was a problem hiding this comment.
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:
- Validates inbound — delivers only when
event.sourceis the expected iframecontentWindowandevent.originmatches - Restricts outbound —
postMessagetargets the iframe's actual origin instead of'*' - Fixes a resource leak — transports are now
dispose()d, preventing onemessagelistener from accumulating onwindowper 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.origincheck is the correctpostMessagesecurity pattern per MDN/OWASP guidance. ✅ - Outbound restriction: Targeting a specific
targetOrigininstead of'*'prevents data from leaking if the iframe navigates unexpectedly. ✅ - Origin derivation:
getIframeOriginreadsiframe.srcvia theURLconstructor, which is safe —srcis set by the host beforeinitRpcruns. ✅ - No hardcoded secrets or credentials. ✅
- No injection vectors introduced. ✅
Architecture Notes
- Subclassing
Transportis the right call — the base class is an abstract EventEmitter withsend()and nodispose(), 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. takeScreenshotrefactor — The early-return for the caller-supplied client path, withtry/finallyfor 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
Test this pull request on macos-latestDownload the correct version for your architecture:Click here if you don't know which version to downloadFor running this unsigned version of the app, you will need to run the xattr command on it:
|
Test this pull request on windows-latestDownload the correct version for your architecture: |
|
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. this.handler = async (message) => { if (this.isMessage(message)) { switch (message.type) { ...and return (value && value.id === this.id && typeof value.type === 'string'
&& messageTypes.includes(value.type) && ...
I also checked whether the guard might be filtering legitimate non- 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.
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.
Summary
MessageTransportfrom@dcl/mini-rpcuses itsoriginargument only as thepostMessagetargetOrigin. Inbound it delivers everymessageevent that reaches the window, whatever the source: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'*'.AuthenticatedMessageTransportsubclasses the exported abstractTransportand pins both directions:event.sourceis the iframe's owncontentWindowandevent.originmatchessendtargets that origin instead of'*'Both
initRpcandtakeScreenshotnow use it, replacing the last twoMessageTransportconstructions.Why the origin comes from
iframe.srcNot rebuilt from
inspectorPort:EditorPage/component.tsxhonoursVITE_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 feedstakeScreenshotdisposes the one it creates when no client is passed — restructured so the caller-supplied path returns early and the owned path usestry/finallyThe iframe is recreated on every scene open, so each undisposed transport left another live
messagelistener onwindow. Pre-existing withMessageTransport(which has nodisposeat 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,sendtargeting 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 becausereceivedis a sharedlet, 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 anafterEachthat disposes, which is the same leak this PR fixes intakeScreenshot.npm run test:unit— main 74, preload 44, renderer 136, shared 33make typecheck(0 errors),npm run lint,npm run formatwrite_fileover the storage channel)takeScreenshot, the restructured path)getEventListeners(window).message.lengthin devtools stays flat rather than growing per openVITE_INSPECTOR_PORTset to a non-default port, the inspector still connects🤖 Generated with Claude Code
https://claude.ai/code/session_01CW4SCgagWDAxR3PKJCMp5d