Skip to content

Validate origin and restrict child command dispatch on postMessage #19020 - #8

Merged
sayahweb2-png merged 5 commits into
masterfrom
19020-postmessage-origin-hardening
Aug 20, 2026
Merged

Validate origin and restrict child command dispatch on postMessage #19020#8
sayahweb2-png merged 5 commits into
masterfrom
19020-postmessage-origin-hardening

Conversation

@Vvanlaar

@Vvanlaar Vvanlaar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Ticket

#19020 — the bridge routed on ev.source and never re-validated event.origin, and dispatched this[ev.data.methodName] dynamically.

Summary

  • CHILD_COMMANDS allowlist replaces dynamic dispatch. setLocation (which assigns window.location.href — a redirect / javascript: primitive), 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. ev.source can't be forged but it survives a navigation, so this is what catches the post-load src mutation the ticket describes.
  • _iframeOrigin derived with URL, not the old regex. That regex yielded '//host' for a protocol-relative src — not a legal targetOrigin — and matched no host with a port at all, silently disabling validation for every dev/staging/on-prem deployment.
  • Parent origin learned from ev.origin, not document.referrer.
  • 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. Now guarded, and unbound on both outcomes (it previously leaked one listener per unanswered call).
  • Outgoing targetOrigin no longer '*' on the parent direction.
  • Adds a pr-checks workflow — 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.source only, not origin: gating it would leave _handshakeSucceededChild false forever under enforcement — every callChild queued indefinitely, fullscreen dead, and nothing in the log saying the bridge had died. The localStorage push that follows is protected by its targetOrigin regardless. Fullscreen and command dispatch keep the full check.

Self-review

Ran /code-review high plus four clean-context lenses. Findings fixed in e44d584; two were regressions the first commit introduced — the document.referrer parent-origin derivation (which would have made the browser drop every callParent after any external-link navigation) and moving the handshake onto the unvalidated _iframeOrigin.

Noted, not fixed: bootstrap's if (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 test27 tests, in a jest + jsdom harness built from zero ("test" was echo "Error: no test specified" && exit 1, with only a manual test.html).
  • Covers: spoofed origin, the subdomain-prefix trick, post-load src mutation, protocol-relative and ported srcs, the excluded commands (setLocation specifically), malformed data, the callChildPromise hijack, handshake resilience under enforcement, and callParent targeting.
  • Mutation-verified: removing the CHILD_COMMANDS check 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

Vvanlaar and others added 2 commits August 7, 2026 13:32
… #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>
Copilot AI lite review requested due to automatic review settings August 7, 2026 11:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_COMMANDS allowlist to replace dynamic this[methodName] dispatch from child messages.
  • Re-validate event.origin against a derived iframe origin on every child message (warn-only by default via _enforceOrigin), and learn _parentOrigin from ev.origin to avoid '*' targeting when possible.
  • Add Jest tests and a PR workflow that runs npm test on 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.

Comment on lines +32 to +35
afterEach(() => {
if (bridge) { bridge.exit(); bridge = null; }
jest.restoreAllMocks();
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread README.md Outdated
Comment on lines +75 to +78
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

Comment thread src/BBIframeBridge.js Outdated
Comment on lines +619 to +620
// Nothing to compare against -- already warned about at construction.
if (this._iframeOrigin === '*') return true;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Vvanlaar and others added 3 commits August 7, 2026 13:57
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 sayahweb2-png 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.

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.

@ghultink

Copy link
Copy Markdown

@sayahweb2-png this one looks like it just needs the last step — you approved it on 17 Aug and moved #19020 to Reviewed the same minute, but the PR was never merged. It's still MERGEABLE / CLEAN, so it should be a straight merge commit.

Flagging it because of how it hides: Reviewed auto-clears System.AssignedTo, so once the ticket moved it belonged to nobody, and the board reads it as done. It came up in a sweep for "Reviewed tickets whose PR is still open" — this was the only one out of 17.

Worth landing sooner rather than later given what's in it: origin validation on postMessage and restricting child command dispatch. It's been open 12 days while the ticket has read as closed out.

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.

@sayahweb2-png
sayahweb2-png merged commit 31e4c3e into master Aug 20, 2026
1 check passed
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.

4 participants