Validate origin and restrict child command dispatch on postMessage #19020 - #8
Conversation
… #19020
_onMessage trusted `ev.source === iframe.contentWindow` and then dispatched
`this[ev.data.methodName]`, exposing every public method on the class to the
child frame -- including setLocation, which assigns window.location.href and
is therefore a redirect / `javascript:` primitive. ev.source cannot be forged,
but it survives a navigation, so an iframe moved to a hostile origin after
load kept its trusted source. event.origin was captured once from iframe.src
at construction and never re-checked.
- CHILD_COMMANDS allowlist replaces dynamic dispatch. setLocation, exit,
callParent and callChild are no longer reachable by name from the child;
direct instance calls are unaffected. Verified the player invokes none of
the four.
- _isTrustedChildMessage re-validates ev.origin against the iframe origin on
every message (exact match, not substring), which is what catches the
post-load src mutation.
- callChildPromise's inner listener checked neither source nor origin, so any
window on the page could post a 'return' and decide what the promise
resolved to. Same guard applied.
- outgoing targetOrigin no longer '*' on the parent direction; derived from
document.referrer, falling back to '*' with a warning.
- warn when iframe.src yields no usable origin, since that silently disables
the inbound check.
Origin enforcement is warn-only by default (_enforceOrigin): mismatches are
logged but still handled, so live integrations keep working while we collect
telemetry. Follow-up ticket flips the default.
Adds jest + jsdom -- the repo had no test harness at all ("no test specified")
-- with 13 tests covering the spoofed origin, the subdomain trick, post-load
src mutation, the excluded commands, malformed data and the promise hijack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correctness:
- Derive _iframeOrigin with URL, not the old regex. That regex matched a
protocol-relative src as '//host' -- not a legal targetOrigin, so moving the
handshake onto it threw SyntaxError into the constructor's catch and killed
the integration outright. It also failed to match any host with a port, so
origin validation was silently off for every dev/staging/on-prem deployment.
Opaque origins ('null') are rejected too.
- Learn the parent's origin from ev.origin instead of document.referrer.
document.referrer is the site that linked to THIS page -- on a top-level page
an unrelated third party -- so callParent was targeting e.g. google.com and
the browser dropped every message with no error. Falls back to '*' until the
parent has identified itself.
- Gate the handshake on ev.source alone, not on origin. Under enforcement an
origin drift would leave _handshakeSucceededChild false forever: every
callChild queued indefinitely, fullscreen dead, and nothing in the log saying
so. The localStorage push that follows is protected by its targetOrigin
regardless. Fullscreen and command dispatch keep the origin check.
- callChildPromise now unbinds its listener on both outcomes. It previously
leaked one per unanswered call, and each leaked listener re-runs the origin
check on every later message.
Noise:
- Only warn about an underivable origin when there actually was an src. A
bridge is constructed for every iframe on the page, including src-less ad
slots, and those were warning about a normal state.
Adds a pr-checks workflow -- the repo had no CI job running the suite, so these
tests would have rotted silently.
README and JSDoc corrected where they overstated the guarantee: 'return' is
dropped silently, the fullscreen strings are now origin-gated, only the child
direction is origin-checked, and _isTrustedChildMessage is not a pure trust
predicate while warn-only is on.
Tests 13 -> 27.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens BBIframeBridge’s postMessage surface by enforcing safer message routing: validating event.origin per message, restricting child→parent command dispatch to an allowlist, tightening targetOrigin usage, and adding a Jest + jsdom test suite plus CI to prevent regressions.
Changes:
- Add
CHILD_COMMANDSallowlist to replace dynamicthis[methodName]dispatch from child messages. - Re-validate
event.originagainst a derived iframe origin on every child message (warn-only by default via_enforceOrigin), and learn_parentOriginfromev.originto avoid'*'targeting when possible. - Add Jest tests and a PR workflow that runs
npm teston every pull request.
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/BBIframeBridge.js | Implements origin re-validation, child command allowlist dispatch, safer targetOrigin selection, and callChildPromise listener hardening/unbinding. |
| src/BBIframeBridge.postMessage.test.js | Adds unit tests covering origin spoofing, allowlist enforcement, handshake behavior, and callChildPromise hijack prevention. |
| README.md | Documents the new child-command restrictions and origin re-validation behavior. |
| package.json | Switches npm test to run Jest and adds required dev dependencies. |
| jest.config.js | Adds Jest + jsdom configuration and coverage settings. |
| jest.babel.config.json | Adds Babel preset config for Jest transforms. |
| .github/workflows/pr-checks.yaml | Adds PR CI workflow that installs dependencies and runs the Jest suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| afterEach(() => { | ||
| if (bridge) { bridge.exit(); bridge = null; } | ||
| jest.restoreAllMocks(); | ||
| }); |
There was a problem hiding this comment.
Confirmed — real leak. In the it.each(["exit", "callParent", "callChild"]) case exit is spied with a no-op implementation, so afterEach calling bridge.exit() hit the mock, skipped removeEventListener, and left the window/document listeners bound. That matters here specifically because the callChildPromise tests dispatch real message events, so a leaked listener from an earlier test would have been running against a torn-down bridge.
Fixed in e9232d2 by restoring mocks before exit() in all six teardowns, so the real implementation runs.
| This currently runs **warn-only**: mismatches are logged but still handled, so existing integrations | ||
| keep working. Set `bridge._enforceOrigin = true` to drop them instead. An `iframe.src` that yields | ||
| no usable origin (a relative URL, or a `data:`/sandboxed frame) leaves nothing to compare against | ||
| and disables the check for that bridge — it warns at construction when that happens. |
There was a problem hiding this comment.
Right, and the docs were describing the old behaviour. The regex this PR replaced genuinely did fall back to * for a relative src; new URL(iframe.src, window.location.href) resolves it against the document, so relative and protocol-relative srcs now validate normally. I had already noticed this when writing the tests — the fixture changed from /relative/player.html to data:text/html for exactly this reason — but did not carry it back into the README or the constructor comment.
Both corrected in e9232d2: only an opaque or unparseable src disables validation now, plus a note that a src-less iframe skips it silently (a bridge is constructed for every iframe on the page, so warning there would be noise).
| // Nothing to compare against -- already warned about at construction. | ||
| if (this._iframeOrigin === '*') return true; |
There was a problem hiding this comment.
Agreed. The construction warning is gated on this._iframe.src being truthy — deliberately, since a bridge is built for every iframe on the page including src-less ad slots — so _iframeOrigin === "*" does not imply anything was logged. Comment reworded in e9232d2 to say the warning only fires when there was an src.
The first version copied standardplayer's composite actions, but bluebillywig/gh-workflows cannot be resolved from this repo -- the run failed at "Prepare all required actions" before doing anything. This repo's deploy workflows already use actions/checkout + actions/setup-node, so match them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Restore mocks before bridge.exit() in test teardown. One case spies on `exit` itself, so calling the mock skipped removeEventListener and leaked window and document listeners into later tests -- including the ones that dispatch real message events. - Correct two statements the switch to `new URL(src, location.href)` made stale: relative and protocol-relative srcs now resolve normally, so only an opaque or unparseable src disables validation. README and the constructor comment both said relative srcs disabled it. - Clarify that _iframeOrigin === '*' does not imply a construction warning was emitted; a src-less iframe reaches that branch silently by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed #19020
Found by running a real player against a real bridge, not by the unit suite.
The skin posts {methodName:'iframeReady'} to the parent on every iframe embed
(standardplayer-skin-svelte/src/lib/controllers/iframe-bridge.ts). No
iframeReady method has ever existed here, so master's dynamic dispatch dropped
it silently via its `typeof this[name] === 'function'` test. The new allowlist
turned that into a console warning on every page load, for every customer.
Not a functional regression -- the message was always ignored -- but exactly
the console noise this PR has been removing elsewhere. Grouped with 'return'
into SILENT_NON_COMMANDS: shaped like a command, but expected traffic the
bridge deliberately does not act on.
Verified end to end afterwards with the player iframed cross-origin
(localhost:1236 into a bridge host on :1238): handshake completes, iframe
origin derived correctly, callChildPromise('getDuration') round-trips 237.401
through the guarded channel, console clean.
Tests 27 -> 29.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sayahweb2-png
left a comment
There was a problem hiding this comment.
Static allowlist is the right fix, origin derivation with URL() fixes the protocol-relative and port bugs, and the warn-only rollout is the safe path. LGTM.
|
@sayahweb2-png this one looks like it just needs the last step — you approved it on 17 Aug and moved #19020 to Flagging it because of how it hides: Worth landing sooner rather than later given what's in it: origin validation on The three unresolved threads are all Copilot's and all marked outdated, so nothing looks like it's actually waiting on you — but you're the approver, so it's your call, not mine to merge. |
Ticket
#19020 — the bridge routed on
ev.sourceand never re-validatedevent.origin, and dispatchedthis[ev.data.methodName]dynamically.Summary
CHILD_COMMANDSallowlist replaces dynamic dispatch.setLocation(which assignswindow.location.href— a redirect /javascript:primitive),exit,callParentandcallChildare no longer reachable by name from the child. Direct instance calls are unaffected; verified the player invokes none of the four._isTrustedChildMessagere-validatesev.originagainst the iframe origin on every message, exact match.ev.sourcecan't be forged but it survives a navigation, so this is what catches the post-loadsrcmutation the ticket describes._iframeOriginderived withURL, not the old regex. That regex yielded'//host'for a protocol-relative src — not a legaltargetOrigin— and matched no host with a port at all, silently disabling validation for every dev/staging/on-prem deployment.ev.origin, notdocument.referrer.callChildPromise's inner listener checked neither source nor origin, so any window on the page could post areturnand decide what the promise resolved to. Now guarded, and unbound on both outcomes (it previously leaked one listener per unanswered call).targetOriginno longer'*'on the parent direction.pr-checksworkflow — the repo had no CI job running any test suite.Rollout
Origin enforcement is warn-only by default (
_enforceOrigin): mismatches are logged but still handled. The command allowlist ships enforced.The handshake is deliberately gated on
ev.sourceonly, not origin: gating it would leave_handshakeSucceededChildfalse forever under enforcement — everycallChildqueued indefinitely, fullscreen dead, and nothing in the log saying the bridge had died. The localStorage push that follows is protected by itstargetOriginregardless. Fullscreen and command dispatch keep the full check.Self-review
Ran
/code-review highplus four clean-context lenses. Findings fixed ine44d584; two were regressions the first commit introduced — thedocument.referrerparent-origin derivation (which would have made the browser drop everycallParentafter any external-link navigation) and moving the handshake onto the unvalidated_iframeOrigin.Noted, not fixed:
bootstrap'sif (match !== null && match.length > 1 || true)makes the bbvms/mainroll iframe filter a no-op, so a bridge is constructed for every iframe on the page. Pre-existing; I only stopped the new warnings from firing for those. Worth its own ticket.Test plan
npm test— 27 tests, in a jest + jsdom harness built from zero ("test"wasecho "Error: no test specified" && exit 1, with only a manualtest.html).srcmutation, protocol-relative and ported srcs, the excluded commands (setLocationspecifically), malformed data, thecallChildPromisehijack, handshake resilience under enforcement, andcallParenttargeting.CHILD_COMMANDScheck fails 5 tests.npm run build— parcel exits 0.Paired with standardplayer #1401 for #19019 — the other half of the same channel.
🤖 Generated with Claude Code