Skip to content

fix(import): extend cross-origin MessageChannel validation to import frame [INT-783][ENG-4597][ENG-4598] - #130

Merged
justinformentin merged 3 commits into
mainfrom
jf/int-783-import-frame-apply-cross-origin-messagechannel-validation
Aug 14, 2026
Merged

fix(import): extend cross-origin MessageChannel validation to import frame [INT-783][ENG-4597][ENG-4598]#130
justinformentin merged 3 commits into
mainfrom
jf/int-783-import-frame-apply-cross-origin-messagechannel-validation

Conversation

@justinformentin

@justinformentin justinformentin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Applies the INT-697 / #129 cross-origin hardening to the import frame, which #129 didn't touch.

Linear: INT-783 · ENG-4598 · ENG-4597

Changes

TURNKEY_INIT_MESSAGE_CHANNEL gate (ENG-4598) — anyone could seize the channel. Now requires event.source === window.parent, a non-opaque origin, and exactly one port.

Legacy postMessage path — the pre-MessageChannel path (iframe-stamper < 2.1.0) had no sender checks at all. Same parent/origin checks, plus it binds the first accepted origin and drops anything from a different one. Responses go to the bound origin instead of *.

Import bundle org/user binding (ENG-4597)onInjectImportBundle only console.warn'd when organizationId/userId were missing, so legacy callers skipped the binding check entirely. Both now throw.

⚠️ Breaking: iframe-stamper < v2.0.0 (or anything omitting those fields) now gets an ERROR back.

Testing

39 tests pass, lint/prettier clean, build OK. New tests cover the channel gate, the legacy origin binding, and the org/user rejections. Also added modulePaths to jest.config.js so @shared/* imports resolve their own deps.

… import frame

Mirrors the TURNKEY_INIT_MESSAGE_CHANNEL gate added to export-and-sign in
PR #129 (#129). The import frame's handler
previously only checked event.ports?.[0]; it now also validates:
- event.source === window.parent (direct parent only)
- event.origin && event.origin !== "null" (concrete, non-opaque origin)
- event.ports?.length === 1 (exactly one transferred port)

Closes the identical class of vulnerability INT-697 fixed in export-and-sign.
Pre-existing gap; not introduced by PR #129.

Also adds six Jest tests for the new gate conditions and rebuilds the
production bundle (content hash: 45de86a592cd0d80ccd0).

Linear: INT-783 https://linear.app/turnkey/issue/INT-783
@justinformentin
justinformentin force-pushed the jf/int-783-import-frame-apply-cross-origin-messagechannel-validation branch from 207de18 to f1647ae Compare August 7, 2026 19:07
@justinformentin justinformentin changed the title fix(import): extend cross-origin MessageChannel validation to import frame (INT-783) fix(import): extend cross-origin MessageChannel validation to import frame [INT-783][ENG-4597][ENG-4598] Aug 10, 2026
…ing (ENG-4597)

Previously, onInjectImportBundle() in the v1.0.0 path would only log a
console.warn when organizationId or userId was omitted by the caller,
allowing signed import bundles to be accepted without org/user binding.

This change converts both warnings into hard errors:
- Missing organizationId → throws with message indicating iframe-stamper
  >= v2.0.0 is required
- Missing userId → throws with same guidance

The existing equality checks (signedData must match the supplied values)
are preserved unchanged. The now-obsolete TODO comments and console.warn
calls are removed.

Tests: new describe block 'onInjectImportBundle org/user binding
(import frame)' in import/index.test.js covers:
- missing organizationId (undefined and empty string)
- missing userId (undefined and empty string)
- mismatched organizationId / userId still throws
- valid matching bundle succeeds (BUNDLE_INJECTED)

Bundle rebuilt: index.bundle.808edbe601034931b90e.js
@justinformentin
justinformentin force-pushed the jf/int-783-import-frame-apply-cross-origin-messagechannel-validation branch from 1a7b31a to e0a04e0 Compare August 10, 2026 15:03

@fainashalts fainashalts 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.

The MessageChannel gate itself looks correct. Left a blocking comment but otherwise looks good!

Comment thread import/src/index.js
event.data &&
event.data["type"] == "TURNKEY_INIT_MESSAGE_CHANNEL" &&
event.ports?.[0]
event.source === window.parent &&

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.

this validates only the MessageChannel handshake. The legacy listener registered in DOMContentLoaded still attaches messageEventListener directly, so until a channel is established INJECT_IMPORT_BUNDLE, EXTRACT_* and APPLY_SETTINGS accept messages from any source or origin if I'm reading this right.

The new org/user checks don't close this path because those expected values come from the same untrusted message. A non-parent sender can provide a valid signed bundle with matching IDs and overwrite the persisted target key or trigger extraction/reset operatioons.

Should mirror #129's legacy wrapper: require the direct parent, reject empty/opaque origins, bind subsequent traffic to the first accepted origin, and restrict responses to that origin. Should also add regression tests for operational messages from non-parent and opaque-origin senders.

…igin messages

Mirror PR #129's export-and-sign pattern to the import frame's legacy
window.message listener (the path used by @turnkey/iframe-stamper < 2.1.0):

  1. Require event.source === window.parent (direct parent only)
  2. Reject empty and opaque ('null') origins
  3. Bind legacyParentOrigin on the first accepted message; drop later
     messages from any other origin
  4. Call TKHQ.setParentFrameOrigin(event.origin) on first bind so
     outbound window.parent.postMessage responses are scoped to that
     origin instead of '*'
  5. Skip TURNKEY_INIT_MESSAGE_CHANNEL messages in the legacy listener
     (owned by the MessageChannel handshake handler)

Also export setParentFrameOrigin from import/src/turnkey-core.js and
add modulePaths to jest.config.js so @shared/* deps (bech32, etc.) can
be resolved from import/node_modules.

Adds 11 regression tests in 'Legacy listener cross-origin gate (import
frame)' covering: non-parent sender, opaque origin, empty origin,
second-origin-after-binding, origin binding/idempotency, and valid
parent acceptance. Rebuilds dist bundles.
@justinformentin
justinformentin force-pushed the jf/int-783-import-frame-apply-cross-origin-messagechannel-validation branch from 126d5ae to ec45a90 Compare August 11, 2026 14:46

@fainashalts fainashalts 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.

Re-reviewed at ec45a90. Legacy message path is now source/origin-gated, origin-bound, and covered by regression tests. My previous blocker is resolved; no additional blocking issues found.

@justinformentin
justinformentin merged commit f48e613 into main Aug 14, 2026
26 checks passed
@justinformentin
justinformentin deleted the jf/int-783-import-frame-apply-cross-origin-messagechannel-validation branch August 14, 2026 15:29
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