From f29130587b7ba3852d2b14eddf6a7206693b85bc Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Tue, 7 Apr 2026 10:09:27 -0700 Subject: [PATCH] Harden WebView bridge and asset serving across native shells (#1924) * security fix * more security fixes * fixes * pr feedback * Restore remote URL loading in native-shell-ios and native-shell-android Remove bundled-asset-only loading and SHA-256 integrity checks from both native shell packages. WebViews now load directly from the remote URL (default: https://self-app-alpha.vercel.app) over HTTPS, matching the pattern already implemented in kmp-sdk and self-sdk-swift. Also fixes ObjC selector mismatch in self-sdk-swift WebViewProviderImpl for configureRemoteLoading. Co-Authored-By: Claude Opus 4.6 * Restore remote URL loading in kmp-sdk and self-sdk-swift Remove bundled-asset-only loading from kmp-sdk AndroidWebViewHost and self-sdk-swift WebViewProviderImpl. Both now load directly from the remote URL (default: https://self-app-alpha.vercel.app) over HTTPS. Adds remoteWebAppBaseUrl to SelfSdkConfig and pipes it through IosWebViewHost via the new configureRemoteLoading protocol method. Co-Authored-By: Claude Opus 4.6 * coderabbit comments * lint * coderabbit comments --------- Co-authored-by: seshanthS Co-authored-by: Claude Opus 4.6 --- .gitignore | 5 +- docs/reviews/PR-1924-review-findings.md | 87 +++++ .../self/sdk/webview/AndroidWebViewHost.kt | 211 ++++++++--- .../sdk/webview/SelfVerificationActivity.kt | 10 +- .../webview/AndroidWebViewHostSecurityTest.kt | 121 ++++++ .../kotlin/xyz/self/sdk/api/SelfSdkConfig.kt | 1 + .../xyz/self/sdk/bridge/MessageRouter.kt | 9 +- .../xyz/self/sdk/bridge/MessageRouterTest.kt | 34 +- .../kotlin/xyz/self/sdk/api/SelfSdk.ios.kt | 2 +- .../xyz/self/sdk/providers/WebViewProvider.kt | 4 + .../xyz/self/sdk/webview/IosWebViewHost.kt | 8 +- .../native-shell-android/build.gradle.kts | 17 - .../main/kotlin/xyz/self/sdk/api/SelfSdk.kt | 5 +- .../kotlin/xyz/self/sdk/api/SelfSdkConfig.kt | 3 +- .../xyz/self/sdk/bridge/MessageRouter.kt | 10 +- .../self/sdk/webview/AndroidWebViewHost.kt | 349 ++++++++---------- .../sdk/webview/RemoteContentIntegrity.kt | 20 - .../sdk/webview/SelfVerificationActivity.kt | 5 +- .../xyz/self/sdk/bridge/MessageRouterTest.kt | 28 +- .../webview/AndroidWebViewHostSecurityTest.kt | 109 ++++++ .../sdk/webview/RemoteContentIntegrityTest.kt | 121 ------ packages/native-shell-ios/Package.swift | 6 +- .../Resources/self-sdk-web/index.html | 4 +- .../Sources/SelfNativeShell/API/SelfSdk.swift | 3 +- .../SelfNativeShell/API/SelfSdkConfig.swift | 3 - .../Bridge/MessageRouter.swift | 9 +- .../WebView/BundledAssetPathResolver.swift | 20 - .../WebView/RemoteContentIntegrity.swift | 21 -- .../WebView/RemoteNavigationPolicy.swift | 13 +- .../WebView/SelfWebViewHost.swift | 193 +++------- .../BundledAssetPathResolverTests.swift | 41 -- .../MessageRouterEscapeTests.swift | 3 +- .../MessageRouterTests.swift | 32 +- .../RemoteContentIntegrityTests.swift | 84 ----- .../RemoteNavigationPolicyTests.swift | 10 + .../SelfWebViewHostTests.swift | 84 +++++ packages/self-sdk-swift/Package.swift | 5 + .../Providers/WebViewProviderImpl.swift | 104 +++++- .../WebViewProviderImplTests.swift | 100 +++++ 39 files changed, 1129 insertions(+), 765 deletions(-) create mode 100644 docs/reviews/PR-1924-review-findings.md create mode 100644 packages/kmp-sdk/shared/src/androidUnitTest/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt delete mode 100644 packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt create mode 100644 packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt delete mode 100644 packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt delete mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift delete mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift delete mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift delete mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift create mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/SelfWebViewHostTests.swift create mode 100644 packages/self-sdk-swift/Tests/SelfSdkSwiftTests/WebViewProviderImplTests.swift diff --git a/.gitignore b/.gitignore index ccd9528ec..6f3e3d1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,10 @@ contracts/broadcast/ !packages/rn-sdk-test-app/react-native.config.cjs packages/native-shell-android/.gradle/ packages/native-shell-android/build/ - +# WebView bundles — built from webview-app, not checked in +packages/native-shell-android/src/main/assets/self-wallet/ +packages/native-shell-ios/Resources/self-sdk-web/ +packages/self-sdk-swift/Sources/SelfSdkSwift/Resources/self-sdk-web/ # Isolated Gradle home for format tasks .gradle-home/ diff --git a/docs/reviews/PR-1924-review-findings.md b/docs/reviews/PR-1924-review-findings.md new file mode 100644 index 000000000..0551e2361 --- /dev/null +++ b/docs/reviews/PR-1924-review-findings.md @@ -0,0 +1,87 @@ +# PR #1924 Review Findings + +**PR:** Harden WebView bridge and asset serving across native shells +**Branch:** `justin/address-wv-vulns` +**Reviewed:** 2026-04-05 +**Last updated:** 2026-04-05 + +This document reflects the substantive, non-pedantic feedback on PR #1924 from both GitHub PR comments and manual code review. +It intentionally excludes low-signal review noise such as docstring coverage, PR description nags, bot walkthrough summaries, and duplicate comments that collapse into the same work item. + +--- + +## Resolved + +### ~~1. Bundled Android entry path breaks relative asset loading~~ + +**Status:** Resolved — not a real issue. +`BundledAssetPathHandler` receives only the URL path component (e.g. `/assets/app.js`), not the full navigation URL. The `/tunnel/tour/1` initial URL never reaches the asset handler. Asset requests correctly resolve to `self-wallet/assets/...` in the Android bundle. PR comment resolved. + +### ~~6. iOS local asset server startup must fail closed~~ + +**Status:** Resolved — already addressed. +`SelfWebViewHost` (native-shell-ios) uses a custom URL scheme handler (`SelfBundledAssetSchemeHandler`), not a local asset server. `WebViewProviderImpl` (self-sdk-swift) falls back to `bundledPort = 0` on server failure, which correctly rejects trust checks downstream. PR comments resolved. + +### ~~7. SwiftPM resource path is declared but not populated by build automation~~ + +**Status:** Resolved — already addressed. +`build-webview-bundle.sh` copies the generated bundle to the self-sdk-swift resources path. The directory exists and is populated. PR comments resolved. + +### ~~2. Trust boundary is fail-open in MessageRouter APIs~~ + +**Status:** Resolved. +Removed the default `isTrustedSource = true` value from the KMP, Android, and iOS routers, and updated callers/tests to pass trust explicitly. + +### ~~3. Android bridge trust uses `webView.url` instead of callback origin~~ + +**Status:** Resolved. +Both Android hosts now evaluate bridge trust from `WebViewCompat.addWebMessageListener`'s `sourceOrigin` callback parameter rather than re-reading `webView.url`. + +### ~~4. iOS bridge trust is rechecked from `webView?.url` after the origin was already validated~~ + +**Status:** Resolved. +`SelfWebViewHost` now passes `isTrustedSource: true` after `isTrustedBridgeFrameInfo()` succeeds, removing the race-prone recheck against `webView?.url`. + +### ~~5. Bridge initialization does not fail closed when `WEB_MESSAGE_LISTENER` is unavailable~~ + +**Status:** Resolved. +Both Android hosts now fail closed with a hard `check(...)` when `WEB_MESSAGE_LISTENER` is unavailable instead of loading a broken bridge. + +### ~~8. iOS `loadHTMLString` base URL resolves relative assets against entry path~~ + +**Status:** Resolved. +`SelfWebViewHost` already loads verified remote HTML with the configured `baseURL`, not the full `/tunnel/tour/1` entry URL, so relative asset resolution is anchored correctly at the remote app base. + +### ~~9. Android allows Didit navigation in main frame; iOS restricts to subframes only~~ + +**Status:** Resolved. +The Android native-shell and KMP hosts now reject Didit in `isAllowedNavigationUrl`, aligning main-frame behavior with the iOS restriction. + +### ~~10. Duplicate constant in Android host~~ + +**Status:** Resolved. +The current native Android host uses only `BUNDLED_ASSET_HOST`; the duplicate `BUNDLED_HOST` constant is no longer present. + +### ~~11. iOS `navigationDelegate` set twice~~ + +**Status:** Resolved. +The current `SelfWebViewHost` assigns `webView.navigationDelegate = self` only once. + +--- + +## Validation + +- `cd packages/native-shell-android && ./gradlew test` — passed +- `cd packages/kmp-sdk && ./gradlew :shared:jvmTest` — passed +- `cd packages/native-shell-ios && swift test` — blocked by environment: SwiftPM cannot import `UIKit` in this shell (`no such module 'UIKit'`) + +## Current Status + +All substantive findings tracked in this review doc are now resolved in source. + +## Explicitly Excluded + +- Docstring coverage complaints +- PR description / checklist formatting comments +- Generic CodeRabbit walkthrough summaries +- Duplicate comments that collapse into the same work item diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt index 2220869f9..c520241f6 100644 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt @@ -11,7 +11,6 @@ import android.content.Context import android.content.pm.PackageManager import android.net.Uri import android.net.http.SslError -import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.SslErrorHandler import android.webkit.ValueCallback @@ -21,12 +20,16 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import xyz.self.sdk.bridge.MessageRouter class AndroidWebViewHost( private val context: Context, private val router: MessageRouter, private val isDebugMode: Boolean = false, + private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app", private val devServerUrl: String? = null, ) { private lateinit var webView: WebView @@ -54,21 +57,7 @@ class AndroidWebViewHost( override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, - ): Boolean { - val uri = request?.url ?: return true - val devHost = devServerUrl?.let { Uri.parse(it) } - val isAllowed = - (uri.scheme == "https" && uri.host == "self-app-alpha.vercel.app") || - (isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173) || - ( - isDebugMode && - devHost != null && - uri.scheme == devHost.scheme && - uri.host == devHost.host && - uri.port == devHost.port - ) - return !isAllowed - } + ): Boolean = !isAllowedNavigationUrl(request?.url?.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl) override fun onReceivedSslError( view: WebView?, @@ -88,19 +77,7 @@ class AndroidWebViewHost( request.deny() return } - val devHost = devServerUrl?.let { Uri.parse(it) } - val isTrusted = - (origin.scheme == "https" && origin.host == "self-app-alpha.vercel.app") || - (origin.scheme == "https" && origin.host == "verify.didit.me") || - (isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1" && origin.port == 5173) || - ( - isDebugMode && - devHost != null && - origin.scheme == devHost.scheme && - origin.host == devHost.host && - origin.port == devHost.port - ) - if (!isTrusted) { + if (!isTrustedPermissionOrigin(origin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl)) { request.deny() return } @@ -171,16 +148,9 @@ class AndroidWebViewHost( } } - addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid") + installBridge(webView = this) - val baseUrl = - if (isDebugMode && devServerUrl != null) { - "${devServerUrl.trimEnd('/')}/tunnel/tour/1" - } else { - "https://self-app-alpha.vercel.app/tunnel/tour/1" - } - val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl - loadUrl(url) + loadUrl(initialContentUrl(queryParams, isDebugMode, remoteWebAppBaseUrl, devServerUrl)) } return webView } @@ -195,15 +165,172 @@ class AndroidWebViewHost( webView.destroy() } - inner class BridgeJsInterface { - @JavascriptInterface - fun postMessage(json: String) { - router.onMessageReceived(json) + private fun installBridge(webView: WebView) { + check(WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + "WEB_MESSAGE_LISTENER not supported — native bridge unavailable on this device" + } + + WebViewCompat.addWebMessageListener( + webView, + "SelfNativeAndroid", + buildAllowedOriginRules(isDebugMode, remoteWebAppBaseUrl, devServerUrl), + ) { _, message: WebMessageCompat, sourceOrigin, isMainFrame, _ -> + if (!isMainFrame) { + return@addWebMessageListener + } + + val rawJson = message.data ?: return@addWebMessageListener + router.onMessageReceived( + rawJson = rawJson, + isTrustedSource = isTrustedBridgeOrigin(sourceOrigin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl), + ) } } companion object { const val FILE_CHOOSER_REQUEST_CODE = 1001 const val CAMERA_PERMISSION_REQUEST_CODE = 1002 + private const val BUNDLED_TOUR_PATH = "/tunnel/tour/1" + private const val DEBUG_HOST = "127.0.0.1" + private const val DEBUG_PORT = 5173 + private const val DIDIT_HOST = "verify.didit.me" + + internal fun initialContentUrl( + queryParams: String, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app", + devServerUrl: String? = null, + ): String { + val baseUrl = + when { + isDebugMode && devServerUrl != null -> devServerUrl.trimEnd('/') + isDebugMode -> "http://$DEBUG_HOST:$DEBUG_PORT" + else -> { + require(remoteWebAppBaseUrl.startsWith("https://")) { + "remoteWebAppBaseUrl must use HTTPS in release builds" + } + remoteWebAppBaseUrl.trimEnd('/') + } + } + return buildString { + append(baseUrl).append(BUNDLED_TOUR_PATH) + if (queryParams.isNotEmpty()) { + append("?").append(queryParams) + } + } + } + + internal fun isAllowedNavigationUrl( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + devServerUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + isDiditUrl(rawUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) || + (isDebugMode && isDevServerUrl(rawUrl, devServerUrl)) + + internal fun isTrustedPermissionOrigin( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + devServerUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + isDiditUrl(rawUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) || + (isDebugMode && isDevServerUrl(rawUrl, devServerUrl)) + + internal fun isTrustedBridgeOrigin( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + devServerUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) || + (isDebugMode && isDevServerUrl(rawUrl, devServerUrl)) + + internal fun isRemoteOrigin( + rawUrl: String?, + remoteWebAppBaseUrl: String?, + ): Boolean { + if (rawUrl == null || remoteWebAppBaseUrl == null) return false + val url = parseUri(rawUrl) ?: return false + val remote = parseUri(remoteWebAppBaseUrl) ?: return false + return url.scheme == remote.scheme && + (url.host ?: url.authority) == (remote.host ?: remote.authority) && + resolvedPort(url) == resolvedPort(remote) + } + + private fun isDiditUrl(rawUrl: String?): Boolean { + val port = uriPort(rawUrl) + return uriScheme(rawUrl) == "https" && + uriHost(rawUrl) == DIDIT_HOST && + (port == null || port == 443) + } + + private fun isDebugLocalUrl(rawUrl: String?): Boolean = + uriScheme(rawUrl) == "http" && uriHost(rawUrl) == DEBUG_HOST && uriPort(rawUrl) == DEBUG_PORT + + private fun isDevServerUrl( + rawUrl: String?, + devServerUrl: String?, + ): Boolean { + if (rawUrl == null || devServerUrl == null) return false + val url = parseUri(rawUrl) ?: return false + val dev = parseUri(devServerUrl) ?: return false + return url.scheme == dev.scheme && + (url.host ?: url.authority) == (dev.host ?: dev.authority) && + resolvedPort(url) == resolvedPort(dev) + } + + private fun buildAllowedOriginRules( + isDebugMode: Boolean, + remoteWebAppBaseUrl: String, + devServerUrl: String? = null, + ): Set { + val remote = parseUri(remoteWebAppBaseUrl) + return buildSet { + if (remote != null && remote.scheme == "https") { + val host = remote.host ?: remote.authority + val port = resolvedPort(remote) + val defaultPort = if (remote.scheme == "https") 443 else 80 + if (port != defaultPort) { + add("${remote.scheme}://$host:$port") + } else { + add("${remote.scheme}://$host") + } + } + if (isDebugMode) { + add("http://$DEBUG_HOST:$DEBUG_PORT") + devServerUrl?.let { parseUri(it) }?.let { dev -> + val host = dev.host ?: dev.authority + val port = resolvedPort(dev) + val defaultPort = if (dev.scheme == "https") 443 else 80 + if (port != defaultPort) { + add("${dev.scheme}://$host:$port") + } else { + add("${dev.scheme}://$host") + } + } + } + } + } + + private fun resolvedPort(uri: java.net.URI): Int { + val port = uri.port + if (port != -1) return port + return if (uri.scheme == "https") 443 else 80 + } + + private fun uriScheme(rawUrl: String?): String? = parseUri(rawUrl)?.scheme + + private fun uriHost(rawUrl: String?): String? = parseUri(rawUrl)?.host ?: parseUri(rawUrl)?.authority + + private fun uriPort(rawUrl: String?): Int? = parseUri(rawUrl)?.port?.takeIf { it != -1 } + + private fun parseUri(rawUrl: String?): java.net.URI? = rawUrl?.let { raw -> runCatching { java.net.URI(raw) }.getOrNull() } } } diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt index 1b6c4bf27..251a2f1be 100644 --- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt +++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt @@ -63,8 +63,16 @@ class SelfVerificationActivity : AppCompatActivity() { return } + val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}" + val remoteWebAppBaseUrl = + try { + org.json.JSONObject(configJson).optString("remoteWebAppBaseUrl", "https://self-app-alpha.vercel.app") + } catch (_: Exception) { + "https://self-app-alpha.vercel.app" + } + val devServerUrl = intent.getStringExtra(EXTRA_DEV_SERVER_URL) - webViewHost = AndroidWebViewHost(this, router, isDebugMode, devServerUrl) + webViewHost = AndroidWebViewHost(this, router, isDebugMode, remoteWebAppBaseUrl, devServerUrl) val webView = webViewHost.createWebView(queryParams) setContentView(webView) } diff --git a/packages/kmp-sdk/shared/src/androidUnitTest/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt b/packages/kmp-sdk/shared/src/androidUnitTest/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt new file mode 100644 index 000000000..f87ea757c --- /dev/null +++ b/packages/kmp-sdk/shared/src/androidUnitTest/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.sdk.webview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidWebViewHostSecurityTest { + private val remoteUrl = "https://self-app-alpha.vercel.app" + + @Test + fun `release builds launch remote content`() { + assertEquals( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = false, remoteWebAppBaseUrl = remoteUrl), + ) + } + + @Test + fun `debug builds launch localhost content`() { + assertEquals( + "http://127.0.0.1:5173/tunnel/tour/1", + AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = true, remoteWebAppBaseUrl = remoteUrl), + ) + } + + @Test + fun `navigation allows remote origin and didit`() { + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://verify.didit.me/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "http://127.0.0.1:5173/tunnel/tour/1", + isDebugMode = true, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + } + + @Test + fun `navigation rejects arbitrary origins`() { + assertFalse( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://evil.com/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + assertFalse( + AndroidWebViewHost.isAllowedNavigationUrl( + "http://example.com/test", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + } + + @Test + fun `release build rejects HTTP base URL`() { + assertFailsWith { + AndroidWebViewHost.initialContentUrl( + queryParams = "", + isDebugMode = false, + remoteWebAppBaseUrl = "http://self-app-alpha.vercel.app", + ) + } + } + + @Test + fun `didit on non-443 port is rejected`() { + assertFalse( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://verify.didit.me:8443/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + } + + @Test + fun `bridge trust is limited to remote origin in release`() { + assertTrue( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + assertFalse( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://verify.didit.me/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + assertFalse( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://evil.com/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteUrl, + ), + ) + } +} diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt index 5e3188b4a..ef1973ca6 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt @@ -34,5 +34,6 @@ data class SelfSdkConfig( val appEndpoint: String? = null, val endpointType: String? = null, val chainID: Int? = null, + val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app", val devServerUrl: String? = null, ) diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt index 8ddb5a95b..56c80bd2c 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/bridge/MessageRouter.kt @@ -27,7 +27,14 @@ class MessageRouter( handlers[handler.domain] = handler } - fun onMessageReceived(rawJson: String) { + fun onMessageReceived( + rawJson: String, + isTrustedSource: Boolean, + ) { + if (!isTrustedSource) { + return // Drop messages from untrusted WebView origins. + } + val request = try { json.decodeFromString(rawJson) diff --git a/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt index 4638aab0c..80856f9eb 100644 --- a/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt +++ b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt @@ -44,7 +44,7 @@ class MessageRouterTest { {"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123} """.trimIndent() - router.onMessageReceived(request) + router.onMessageReceived(request, isTrustedSource = true) assertEquals(1, responses.size) assertTrue(responses[0].contains("_handleResponse")) @@ -62,7 +62,7 @@ class MessageRouterTest { {"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123} """.trimIndent() - router.onMessageReceived(request) + router.onMessageReceived(request, isTrustedSource = true) assertEquals(1, responses.size) assertTrue(responses[0].contains("DOMAIN_NOT_FOUND")) @@ -95,7 +95,7 @@ class MessageRouterTest { {"type":"request","version":1,"id":"req-2","domain":"crypto","method":"sign","params":{},"timestamp":123} """.trimIndent() - router.onMessageReceived(request) + router.onMessageReceived(request, isTrustedSource = true) assertEquals(1, responses.size) assertTrue(responses[0].contains("KEY_NOT_FOUND")) @@ -117,11 +117,32 @@ class MessageRouterTest { val responses = mutableListOf() val router = MessageRouter(sendToWebView = { responses.add(it) }) - router.onMessageReceived("this is not json") + router.onMessageReceived("this is not json", isTrustedSource = true) assertEquals(0, responses.size) } + @Test + fun drops_messages_from_untrusted_origins_before_dispatch() = + runTest { + val responses = mutableListOf() + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val router = + MessageRouter( + sendToWebView = { responses.add(it) }, + scope = testScope, + ) + val handler = FakeBridgeHandler(domain = BridgeDomain.HAPTIC, response = JsonPrimitive("ok")) + router.register(handler) + + val untrustedJson = + """{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}""" + router.onMessageReceived(rawJson = untrustedJson, isTrustedSource = false) + + assertEquals(0, responses.size) + assertEquals(0, handler.invocations.size) + } + @Test fun pushEvent_sends_handleEvent_to_webview() { val responses = mutableListOf() @@ -160,6 +181,7 @@ class MessageRouterTest { repeat(3) { i -> router.onMessageReceived( """{"type":"request","version":1,"id":"req-$i","domain":"haptic","method":"trigger","params":{},"timestamp":123}""", + isTrustedSource = true, ) } @@ -188,6 +210,7 @@ class MessageRouterTest { router.onMessageReceived( """{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}""", + isTrustedSource = true, ) assertEquals(1, hapticHandler.invocations.size) @@ -214,6 +237,7 @@ class MessageRouterTest { router.onMessageReceived( """{"type":"request","version":1,"id":"req-1","domain":"nfc","method":"scan","params":{},"timestamp":123}""", + isTrustedSource = true, ) assertEquals(0, handlerA.invocations.size) @@ -235,6 +259,7 @@ class MessageRouterTest { router.onMessageReceived( """{"type":"request","version":1,"id":"my-unique-req-id","domain":"haptic","method":"trigger","params":{},"timestamp":123}""", + isTrustedSource = true, ) assertEquals(1, responses.size) @@ -275,6 +300,7 @@ class MessageRouterTest { router.onMessageReceived( """{"type":"request","version":1,"id":"req-1","domain":"crypto","method":"sign","params":{},"timestamp":123}""", + isTrustedSource = true, ) assertEquals(1, responses.size) diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt index 45d586b4c..2a8a2dda1 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt @@ -100,7 +100,7 @@ actual class SelfSdk private constructor( val queryParams = buildQueryParams(request) // Create WebView host and the web view - webViewHost = IosWebViewHost(router!!, config.debug) + webViewHost = IosWebViewHost(router!!, config.debug, remoteWebAppBaseUrl = config.remoteWebAppBaseUrl) webViewHost!!.createWebView(queryParams) // Get the ViewController from the WebView provider and present it diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt index 51efdb0b0..1d74a9558 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/providers/WebViewProvider.kt @@ -19,4 +19,8 @@ interface WebViewProvider { fun evaluateJs(js: String) fun getViewController(): UIViewController + + fun isBridgeRequestAllowed(): Boolean + + fun configureRemoteLoading(remoteWebAppBaseURL: String?) {} } diff --git a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt index da934b304..adc87c526 100644 --- a/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt +++ b/packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt @@ -14,15 +14,21 @@ import xyz.self.sdk.providers.IosProviderRegistry class IosWebViewHost( private val router: MessageRouter, private val isDebugMode: Boolean = false, + private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app", ) { fun createWebView(queryParams: String? = null): UIView { val provider = IosProviderRegistry.webView ?: throw IllegalStateException("WebView provider not configured") + provider.configureRemoteLoading(remoteWebAppBaseUrl) + return provider.createWebView( onMessageReceived = { rawJson -> - router.onMessageReceived(rawJson) + router.onMessageReceived( + rawJson = rawJson, + isTrustedSource = provider.isBridgeRequestAllowed(), + ) }, isDebugMode = isDebugMode, queryParams = queryParams, diff --git a/packages/native-shell-android/build.gradle.kts b/packages/native-shell-android/build.gradle.kts index 1f92f1dba..74ab85ee0 100644 --- a/packages/native-shell-android/build.gradle.kts +++ b/packages/native-shell-android/build.gradle.kts @@ -53,23 +53,6 @@ android { } } -tasks.register("validateWebViewBundle") { - doLast { - val bundleDir = file("src/main/assets/self-wallet") - val indexFile = file("src/main/assets/self-wallet/index.html") - if (!bundleDir.exists() || !indexFile.exists()) { - throw GradleException( - "WebView bundle not found at src/main/assets/self-wallet/index.html. " + - "Run ./scripts/build-webview-bundle.sh from the repo root first.", - ) - } - } -} - -tasks.named("preBuild") { - dependsOn("validateWebViewBundle") -} - dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.webkit:webkit:1.9.0") diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt index 44ae92ba5..974f47fc5 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt @@ -35,10 +35,7 @@ object SelfSdk { config.chainID?.let { putExtra(SelfVerificationActivity.EXTRA_CHAIN_ID, it) } config.userDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_USER_DEFINED_DATA, it) } config.selfDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_SELF_DEFINED_DATA, it) } - config.remoteWebAppBaseUrl?.let { putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_BASE_URL, it) } - config.remoteWebAppIntegritySha256?.let { - putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256, it) - } + putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_BASE_URL, config.remoteWebAppBaseUrl) } activity.startActivityForResult(intent, requestCode) } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt index ca95eb3f6..c6735ec24 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt @@ -19,8 +19,7 @@ data class SelfSdkConfig( val chainID: Int? = null, val userDefinedData: String? = null, val selfDefinedData: String? = null, - val remoteWebAppBaseUrl: String? = null, - val remoteWebAppIntegritySha256: String? = null, + val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app", ) class SelfSdkException( diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt index c041cc4fe..3ebcda787 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/bridge/MessageRouter.kt @@ -26,7 +26,15 @@ class MessageRouter( handlers[handler.domain] = handler } - fun onMessageReceived(rawJson: String) { + fun onMessageReceived( + rawJson: String, + isTrustedSource: Boolean, + ) { + if (!isTrustedSource) { + android.util.Log.w("BridgeRouter", "Dropped message from untrusted WebView origin") + return + } + val request = try { json.decodeFromString(rawJson) diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt index 7183e9b07..ff5d4509c 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt @@ -10,29 +10,26 @@ import android.content.pm.PackageManager import android.net.Uri import android.net.http.SslError import android.util.Log -import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.SslErrorHandler import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import xyz.self.sdk.bridge.MessageRouter -import java.net.HttpURLConnection import java.net.URI -import java.net.URL class AndroidWebViewHost( private val context: Context, private val router: MessageRouter, private val isDebugMode: Boolean = false, - private val remoteWebAppBaseUrl: String? = null, - private val remoteWebAppIntegritySha256: String? = null, + private val remoteWebAppBaseUrl: String = DEFAULT_REMOTE_BASE_URL, ) { private lateinit var webView: WebView @@ -44,42 +41,6 @@ class AndroidWebViewHost( @SuppressLint("SetJavaScriptEnabled") fun createWebView(queryParams: String): WebView { isDestroyed = false - val selfWalletHandler = - WebViewAssetLoader.PathHandler { rawPath -> - try { - val normalizedPath = rawPath.removePrefix("/") - val assetPath = - if (normalizedPath.isEmpty() || !normalizedPath.contains('.')) { - "self-wallet/index.html" - } else { - "self-wallet/$normalizedPath" - } - val inputStream = context.assets.open(assetPath) - val mimeType = - when { - assetPath.endsWith(".js") -> "application/javascript" - assetPath.endsWith(".css") -> "text/css" - assetPath.endsWith(".html") -> "text/html" - assetPath.endsWith(".json") -> "application/json" - assetPath.endsWith(".woff2") -> "font/woff2" - assetPath.endsWith(".woff") -> "font/woff" - assetPath.endsWith(".otf") -> "font/otf" - assetPath.endsWith(".ttf") -> "font/ttf" - assetPath.endsWith(".png") -> "image/png" - assetPath.endsWith(".svg") -> "image/svg+xml" - else -> "application/octet-stream" - } - WebResourceResponse(mimeType, "UTF-8", inputStream) - } catch (_: Exception) { - null - } - } - - val assetLoader = - WebViewAssetLoader - .Builder() - .addPathHandler("/", selfWalletHandler) - .build() webView = WebView(context).apply { @@ -97,26 +58,15 @@ class AndroidWebViewHost( webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest?, - ): WebResourceResponse? { - request ?: return null - val url = request.url - if (url.host != BUNDLED_HOST) return null - return assetLoader.shouldInterceptRequest(url) - } - override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, - ): Boolean { - val url = request?.url ?: return true - if (isBundledOrigin(url)) return false - if (isDebugMode && isDebugOrigin(url)) return false - if (isAllowedRemoteOrigin(url.toString())) return false - return true - } + ): Boolean = + !isAllowedNavigationUrl( + request?.url?.toString(), + isDebugMode, + remoteWebAppBaseUrl, + ) override fun onReceivedSslError( view: WebView?, @@ -132,14 +82,13 @@ class AndroidWebViewHost( override fun onPermissionRequest(request: PermissionRequest?) { request ?: return - val originStr = request.origin?.toString() ?: "" - val originUri = Uri.parse(originStr) - val isTrusted = - isBundledOrigin(originUri) || - (isDebugMode && isDebugOrigin(originUri)) || - isMatchingOrigin(originUri, "https", "verify.didit.me", 443) || - isAllowedRemoteOrigin(originStr) - if (!isTrusted) { + if ( + !isTrustedPermissionOrigin( + request.origin?.toString(), + isDebugMode, + remoteWebAppBaseUrl, + ) + ) { request.deny() return } @@ -150,11 +99,20 @@ class AndroidWebViewHost( return } + val allowedResources = + request.resources.filter { + it == PermissionRequest.RESOURCE_VIDEO_CAPTURE || + it == PermissionRequest.RESOURCE_AUDIO_CAPTURE + } + if (allowedResources.size != request.resources.size) { + request.deny() + return + } val neededPermissions = mutableListOf() - if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { + if (allowedResources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { neededPermissions.add(Manifest.permission.CAMERA) } - if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) { + if (allowedResources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) { neededPermissions.add(Manifest.permission.RECORD_AUDIO) } @@ -173,7 +131,7 @@ class AndroidWebViewHost( return } - request.grant(request.resources) + request.grant(allowedResources.toTypedArray()) } override fun onShowFileChooser( @@ -200,14 +158,9 @@ class AndroidWebViewHost( } } - addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid") + installBridge(webView = this) - if (isDebugMode) { - loadUrl(buildDebugUrl(queryParams)) - } else { - loadUrl(buildBundledUrl(queryParams)) - maybeLoadVerifiedRemoteContent(queryParams) - } + loadUrl(initialContentUrl(queryParams, isDebugMode, remoteWebAppBaseUrl)) } return webView } @@ -226,123 +179,145 @@ class AndroidWebViewHost( webView.destroy() } - private fun buildBundledUrl(queryParams: String): String = buildEntryUrl(BUNDLED_ORIGIN, queryParams) - - private fun buildDebugUrl(queryParams: String): String = buildEntryUrl(DEBUG_ORIGIN, queryParams) - - private fun buildRemoteUrl(queryParams: String): String? = RemoteNavigationPolicy.buildRemoteEntryUrl(remoteWebAppBaseUrl, queryParams) - - private fun buildEntryUrl( - baseUrl: String, - queryParams: String, - ): String { - val separator = if (queryParams.isEmpty()) "" else "?$queryParams" - return "$baseUrl/tunnel/tour/1$separator" - } - - private fun maybeLoadVerifiedRemoteContent(queryParams: String) { - val remoteUrl = buildRemoteUrl(queryParams) ?: return - val expectedSha256 = remoteWebAppIntegritySha256?.takeIf { it.isNotBlank() } ?: return - - Thread { - val verifiedHtml = fetchAndVerifyRemoteEntry(remoteUrl, expectedSha256) - if (verifiedHtml == null || !::webView.isInitialized || isDestroyed) { - return@Thread - } - - webView.post { - if (::webView.isInitialized && !isDestroyed) { - webView.loadDataWithBaseURL( - remoteUrl, - verifiedHtml, - "text/html", - "UTF-8", - null, - ) - } - } - }.start() - } - - private fun fetchAndVerifyRemoteEntry( - remoteUrl: String, - expectedSha256: String, - ): String? = - try { - val connection = URL(remoteUrl).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.instanceFollowRedirects = false - connection.connectTimeout = 5_000 - connection.readTimeout = 5_000 - connection.connect() - - if (connection.responseCode !in 200..299) { - Log.w("WebViewHost", "Remote web app integrity check failed with HTTP ${connection.responseCode}") - null - } else if (!RemoteContentIntegrity.isAcceptableContentType(connection.contentType)) { - Log.w("WebViewHost", "Remote web app integrity check failed due to unexpected content type ${connection.contentType}") - null - } else { - val body = - connection.inputStream.use { stream -> - val buffer = java.io.ByteArrayOutputStream() - val chunk = ByteArray(8192) - var totalRead = 0 - var bytesRead: Int - while (stream.read(chunk).also { bytesRead = it } != -1) { - totalRead += bytesRead - if (totalRead > MAX_REMOTE_ENTRY_BYTES) { - throw IllegalStateException("Remote entry response exceeded ${MAX_REMOTE_ENTRY_BYTES} bytes") - } - buffer.write(chunk, 0, bytesRead) - } - buffer.toByteArray() - } - if (sha256Hex(body) == normalizeSha256(expectedSha256)) { - String(body, Charsets.UTF_8) - } else { - Log.w("WebViewHost", "Remote web app integrity check failed: hash mismatch") - null - } - } - } catch (error: Exception) { - Log.w("WebViewHost", "Remote web app integrity check failed", error) - null + private fun installBridge(webView: WebView) { + check(WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + "WEB_MESSAGE_LISTENER not supported — native bridge unavailable on this device" } - private fun isAllowedRemoteOrigin(url: String): Boolean = RemoteNavigationPolicy.isAllowedRemoteOrigin(url, remoteWebAppBaseUrl) + WebViewCompat.addWebMessageListener( + webView, + "SelfNativeAndroid", + buildAllowedOriginRules(isDebugMode, remoteWebAppBaseUrl), + ) { _, message: WebMessageCompat, sourceOrigin, isMainFrame, _ -> + if (!isMainFrame) { + return@addWebMessageListener + } - private fun resolvePort(uri: Uri): Int = RemoteNavigationPolicy.resolvePort(URI(uri.toString())) - - private fun sha256Hex(bytes: ByteArray): String = RemoteContentIntegrity.sha256Hex(bytes) - - private fun normalizeSha256(value: String): String = RemoteContentIntegrity.normalizeSha256(value) - - inner class BridgeJsInterface { - @JavascriptInterface - fun postMessage(json: String) { - router.onMessageReceived(json) + val rawJson = message.data ?: return@addWebMessageListener + router.onMessageReceived( + rawJson = rawJson, + isTrustedSource = + isTrustedBridgeOrigin( + sourceOrigin.toString(), + isDebugMode, + remoteWebAppBaseUrl, + ), + ) } } - private fun isBundledOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "https", BUNDLED_HOST, 443) - - private fun isDebugOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "http", "127.0.0.1", 5173) - - private fun isMatchingOrigin( - uri: Uri, - scheme: String, - host: String, - port: Int, - ): Boolean = uri.scheme == scheme && uri.host == host && resolvePort(uri) == port - companion object { - private const val BUNDLED_HOST = "appassets.androidplatform.net" - private const val BUNDLED_ORIGIN = "https://$BUNDLED_HOST" - private const val DEBUG_ORIGIN = "http://127.0.0.1:5173" - const val FILE_CHOOSER_REQUEST_CODE = 1001 const val CAMERA_PERMISSION_REQUEST_CODE = 1002 - private const val MAX_REMOTE_ENTRY_BYTES = 5 * 1024 * 1024 + private const val DEFAULT_REMOTE_BASE_URL = "https://self-app-alpha.vercel.app" + private const val BUNDLED_TOUR_PATH = "/tunnel/tour/1" + private const val DEBUG_HOST = "127.0.0.1" + private const val DEBUG_PORT = 5173 + private const val DIDIT_HOST = "verify.didit.me" + + internal fun initialContentUrl( + queryParams: String, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String = DEFAULT_REMOTE_BASE_URL, + ): String = + if (isDebugMode) { + buildString { + append("http://") + .append(DEBUG_HOST) + .append(":") + .append(DEBUG_PORT) + .append(BUNDLED_TOUR_PATH) + if (queryParams.isNotEmpty()) { + append("?").append(queryParams) + } + } + } else { + require(remoteWebAppBaseUrl.startsWith("https://")) { + "remoteWebAppBaseUrl must use HTTPS in release builds" + } + buildString { + append(remoteWebAppBaseUrl.trimEnd('/')).append(BUNDLED_TOUR_PATH) + if (queryParams.isNotEmpty()) { + append("?").append(queryParams) + } + } + } + + internal fun isAllowedNavigationUrl( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + isDiditUrl(rawUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) + + internal fun isTrustedPermissionOrigin( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + isDiditUrl(rawUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) + + internal fun isTrustedBridgeOrigin( + rawUrl: String?, + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + ): Boolean = + isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) || + (isDebugMode && isDebugLocalUrl(rawUrl)) + + private fun isDiditUrl(rawUrl: String?): Boolean { + val port = uriPort(rawUrl) + return uriScheme(rawUrl) == "https" && + uriHost(rawUrl) == DIDIT_HOST && + (port == null || port == 443) + } + + private fun isDebugLocalUrl(rawUrl: String?): Boolean = + uriScheme(rawUrl) == "http" && uriHost(rawUrl) == DEBUG_HOST && uriPort(rawUrl) == DEBUG_PORT + + private fun buildAllowedOriginRules( + isDebugMode: Boolean, + remoteWebAppBaseUrl: String? = null, + ): Set = + buildSet { + remoteWebAppBaseUrl + ?.let(::buildOriginRule) + ?.let(::add) + if (isDebugMode) { + add("http://$DEBUG_HOST:$DEBUG_PORT") + } + } + + private fun buildOriginRule(rawUrl: String): String? { + val uri = parseUri(rawUrl) ?: return null + val scheme = uri.scheme ?: return null + if (scheme != "https") return null + val host = uri.host ?: return null + val port = uri.port.takeIf { it != -1 } + + return buildString { + append(scheme).append("://").append(host) + if (port != null) { + append(":").append(port) + } + } + } + + private fun isRemoteOrigin( + rawUrl: String?, + remoteWebAppBaseUrl: String?, + ): Boolean = rawUrl?.let { RemoteNavigationPolicy.isAllowedRemoteOrigin(it, remoteWebAppBaseUrl) } ?: false + + private fun uriScheme(rawUrl: String?): String? = parseUri(rawUrl)?.scheme + + private fun uriHost(rawUrl: String?): String? = parseUri(rawUrl)?.host ?: parseUri(rawUrl)?.authority + + private fun uriPort(rawUrl: String?): Int? = parseUri(rawUrl)?.port?.takeIf { it != -1 } + + private fun parseUri(rawUrl: String?): java.net.URI? = rawUrl?.let { raw -> runCatching { java.net.URI(raw) }.getOrNull() } } } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt deleted file mode 100644 index 50a318a34..000000000 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -package xyz.self.sdk.webview - -import java.security.MessageDigest - -// null contentType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate. -internal object RemoteContentIntegrity { - fun normalizeSha256(value: String): String = value.lowercase().removePrefix("sha256-").removePrefix("0x") - - fun sha256Hex(bytes: ByteArray): String = - MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { byte -> - "%02x".format(byte) - } - - fun isAcceptableContentType(rawContentType: String?): Boolean { - val normalized = rawContentType?.substringBefore(";")?.trim()?.lowercase() - return normalized == null || normalized == "text/html" - } -} diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt index 402e838b2..42a0fea25 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt @@ -36,8 +36,7 @@ class SelfVerificationActivity : AppCompatActivity() { val chainID = if (intent.hasExtra(EXTRA_CHAIN_ID)) intent.getIntExtra(EXTRA_CHAIN_ID, 0) else null val userDefinedData = intent.getStringExtra(EXTRA_USER_DEFINED_DATA) val selfDefinedData = intent.getStringExtra(EXTRA_SELF_DEFINED_DATA) - val remoteWebAppBaseUrl = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_BASE_URL) - val remoteWebAppIntegritySha256 = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256) + val remoteWebAppBaseUrl = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_BASE_URL) ?: "https://self-app-alpha.vercel.app" router = MessageRouter( @@ -68,7 +67,6 @@ class SelfVerificationActivity : AppCompatActivity() { router = router, isDebugMode = isDebugMode, remoteWebAppBaseUrl = remoteWebAppBaseUrl, - remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256, ) val queryParams = @@ -161,7 +159,6 @@ class SelfVerificationActivity : AppCompatActivity() { const val EXTRA_USER_DEFINED_DATA = "xyz.self.sdk.USER_DEFINED_DATA" const val EXTRA_SELF_DEFINED_DATA = "xyz.self.sdk.SELF_DEFINED_DATA" const val EXTRA_REMOTE_WEB_APP_BASE_URL = "xyz.self.sdk.REMOTE_WEB_APP_BASE_URL" - const val EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256 = "xyz.self.sdk.REMOTE_WEB_APP_INTEGRITY_SHA256" const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA" } } diff --git a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt index af4815c79..6cce19375 100644 --- a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt +++ b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/bridge/MessageRouterTest.kt @@ -66,7 +66,7 @@ class MessageRouterTest { val sent = mutableListOf() val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this) - router.onMessageReceived(makeRequest(version = 999)) + router.onMessageReceived(makeRequest(version = 999), isTrustedSource = true) advanceUntilIdle() assertEquals(1, sent.size) @@ -82,7 +82,7 @@ class MessageRouterTest { val sent = mutableListOf() val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this) - router.onMessageReceived(makeRequest(domain = BridgeDomain.NFC)) + router.onMessageReceived(makeRequest(domain = BridgeDomain.NFC), isTrustedSource = true) advanceUntilIdle() assertEquals(1, sent.size) @@ -98,7 +98,7 @@ class MessageRouterTest { val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this) router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("ok"))) - router.onMessageReceived(makeRequest()) + router.onMessageReceived(makeRequest(), isTrustedSource = true) advanceUntilIdle() assertEquals(1, sent.size) @@ -119,7 +119,7 @@ class MessageRouterTest { ), ) - router.onMessageReceived(makeRequest()) + router.onMessageReceived(makeRequest(), isTrustedSource = true) advanceUntilIdle() assertEquals(1, sent.size) @@ -141,7 +141,7 @@ class MessageRouterTest { ), ) - router.onMessageReceived(makeRequest()) + router.onMessageReceived(makeRequest(), isTrustedSource = true) advanceUntilIdle() assertEquals(1, sent.size) @@ -157,7 +157,21 @@ class MessageRouterTest { val sent = mutableListOf() val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this) - router.onMessageReceived("{not valid json") + router.onMessageReceived("{not valid json", isTrustedSource = true) + advanceUntilIdle() + + assertTrue(sent.isEmpty()) + } + + @Test + fun `messages from untrusted origins are dropped before dispatch`() = + runTest { + val sent = mutableListOf() + val handler = StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("ok")) + val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this) + router.register(handler) + + router.onMessageReceived(makeRequest(), isTrustedSource = false) advanceUntilIdle() assertTrue(sent.isEmpty()) @@ -171,7 +185,7 @@ class MessageRouterTest { router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("first"))) router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("second"))) - router.onMessageReceived(makeRequest()) + router.onMessageReceived(makeRequest(), isTrustedSource = true) advanceUntilIdle() val resp = parseResponse(sent[0]) diff --git a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt new file mode 100644 index 000000000..f90cf5ac7 --- /dev/null +++ b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/AndroidWebViewHostSecurityTest.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.webview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidWebViewHostSecurityTest { + @Test + fun `release builds launch remote content`() { + assertEquals( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = false), + ) + } + + @Test + fun `debug builds launch localhost`() { + assertTrue( + AndroidWebViewHost + .initialContentUrl(queryParams = "", isDebugMode = true) + .startsWith("http://127.0.0.1:5173"), + ) + } + + @Test + fun `navigation allows remote didit and debug origins`() { + val remoteBase = "https://self-app-alpha.vercel.app" + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://verify.didit.me/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + assertFalse( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://evil.example.com/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + assertTrue( + AndroidWebViewHost.isAllowedNavigationUrl( + "http://127.0.0.1:5173/tunnel/tour/1", + isDebugMode = true, + ), + ) + } + + @Test + fun `release build rejects HTTP base URL`() { + assertFailsWith { + AndroidWebViewHost.initialContentUrl( + queryParams = "", + isDebugMode = false, + remoteWebAppBaseUrl = "http://self-app-alpha.vercel.app", + ) + } + } + + @Test + fun `didit on non-443 port is rejected`() { + val remoteBase = "https://self-app-alpha.vercel.app" + assertFalse( + AndroidWebViewHost.isAllowedNavigationUrl( + "https://verify.didit.me:8443/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + } + + @Test + fun `bridge trust accepts remote rejects didit and arbitrary origins`() { + val remoteBase = "https://self-app-alpha.vercel.app" + assertTrue( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://self-app-alpha.vercel.app/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + assertFalse( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://verify.didit.me/session/123", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + assertFalse( + AndroidWebViewHost.isTrustedBridgeOrigin( + "https://evil.example.com/tunnel/tour/1", + isDebugMode = false, + remoteWebAppBaseUrl = remoteBase, + ), + ) + } +} diff --git a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt deleted file mode 100644 index 551ff25ea..000000000 --- a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -package xyz.self.sdk.webview - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class RemoteContentIntegrityTest { - // -- normalizeSha256 -- - - @Test - fun `normalizeSha256 strips sha256- prefix`() { - assertEquals( - "abcdef1234567890", - RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"), - ) - } - - @Test - fun `normalizeSha256 strips 0x prefix`() { - assertEquals( - "abcdef1234567890", - RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"), - ) - } - - @Test - fun `normalizeSha256 strips sha256- then 0x prefix`() { - assertEquals( - "abcdef", - RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"), - ) - } - - @Test - fun `normalizeSha256 lowercases input`() { - assertEquals( - "abcdef", - RemoteContentIntegrity.normalizeSha256("ABCDEF"), - ) - } - - @Test - fun `normalizeSha256 returns raw hex unchanged`() { - assertEquals( - "abcdef1234567890", - RemoteContentIntegrity.normalizeSha256("abcdef1234567890"), - ) - } - - @Test - fun `normalizeSha256 does not strip interior sha256-`() { - assertEquals( - "absha256-cd", - RemoteContentIntegrity.normalizeSha256("absha256-cd"), - ) - } - - // -- sha256Hex -- - - @Test - fun `sha256Hex produces correct hash for known input`() { - // SHA-256 of empty byte array - assertEquals( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - RemoteContentIntegrity.sha256Hex(byteArrayOf()), - ) - } - - @Test - fun `sha256Hex produces correct hash for hello`() { - assertEquals( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - RemoteContentIntegrity.sha256Hex("hello".toByteArray(Charsets.UTF_8)), - ) - } - - // -- isAcceptableContentType -- - - @Test - fun `accepts null content type`() { - assertTrue(RemoteContentIntegrity.isAcceptableContentType(null)) - } - - @Test - fun `rejects empty content type`() { - assertFalse(RemoteContentIntegrity.isAcceptableContentType("")) - } - - @Test - fun `accepts text html`() { - assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html")) - } - - @Test - fun `accepts text html with charset`() { - assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html; charset=utf-8")) - } - - @Test - fun `accepts Text HTML case insensitive`() { - assertTrue(RemoteContentIntegrity.isAcceptableContentType("Text/HTML")) - } - - @Test - fun `rejects application javascript`() { - assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/javascript")) - } - - @Test - fun `rejects application json`() { - assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/json")) - } - - @Test - fun `rejects text plain`() { - assertFalse(RemoteContentIntegrity.isAcceptableContentType("text/plain")) - } -} diff --git a/packages/native-shell-ios/Package.swift b/packages/native-shell-ios/Package.swift index 370379e83..40a79ff0b 100644 --- a/packages/native-shell-ios/Package.swift +++ b/packages/native-shell-ios/Package.swift @@ -18,10 +18,8 @@ let package = Package( .target( name: "SelfNativeShell", path: ".", - sources: ["Sources/SelfNativeShell"], - resources: [ - .copy("Resources/self-sdk-web") - ] + exclude: ["Resources"], + sources: ["Sources/SelfNativeShell"] ), .testTarget( name: "SelfNativeShellTests", diff --git a/packages/native-shell-ios/Resources/self-sdk-web/index.html b/packages/native-shell-ios/Resources/self-sdk-web/index.html index d9a1e8c9c..6cef06068 100644 --- a/packages/native-shell-ios/Resources/self-sdk-web/index.html +++ b/packages/native-shell-ios/Resources/self-sdk-web/index.html @@ -4,8 +4,8 @@ Self - - + +
diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift index 980238811..1be69da96 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift @@ -65,8 +65,7 @@ final class SelfSdkViewController: UIViewController { let host = SelfWebViewHost( router: router, isDebugMode: config.isDebugMode, - remoteWebAppBaseURL: config.remoteWebAppBaseURL, - remoteWebAppIntegritySha256: config.remoteWebAppIntegritySha256 + remoteWebAppBaseURL: config.remoteWebAppBaseURL ) self.webViewHost = host diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift index 5ab2f9d4a..3e58b1a61 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift @@ -20,7 +20,6 @@ public struct SelfSdkConfig { public let userDefinedData: String? public let selfDefinedData: String? public let remoteWebAppBaseURL: URL? - public let remoteWebAppIntegritySha256: String? public let secureStorageProvider: SecureStorageProvider public init( @@ -41,7 +40,6 @@ public struct SelfSdkConfig { userDefinedData: String? = nil, selfDefinedData: String? = nil, remoteWebAppBaseURL: URL? = nil, - remoteWebAppIntegritySha256: String? = nil, secureStorageProvider: SecureStorageProvider ) { self.verificationId = verificationId @@ -61,7 +59,6 @@ public struct SelfSdkConfig { self.userDefinedData = userDefinedData self.selfDefinedData = selfDefinedData self.remoteWebAppBaseURL = remoteWebAppBaseURL - self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256 self.secureStorageProvider = secureStorageProvider } diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/Bridge/MessageRouter.swift b/packages/native-shell-ios/Sources/SelfNativeShell/Bridge/MessageRouter.swift index d7a6f14ee..c38ab7197 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/Bridge/MessageRouter.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/Bridge/MessageRouter.swift @@ -14,7 +14,11 @@ final class MessageRouter { handlers[handler.domain] = handler } - func onMessageReceived(rawJson: String) { + func onMessageReceived(rawJson: String, isTrustedSource: Bool) { + guard isTrustedSource else { + return + } + guard let data = rawJson.data(using: .utf8) else { return } @@ -47,7 +51,10 @@ final class MessageRouter { } let params = request.params?.mapValues { $0.value } + dispatchRequest(request, handler: handler, params: params) + } + private func dispatchRequest(_ request: BridgeRequest, handler: BridgeHandler, params: [String: Any]?) { Task { do { let result = try await handler.handle(method: request.method, params: params) diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift deleted file mode 100644 index 4ef3b072e..000000000 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -import Foundation - -enum BundledAssetPathResolver { - static func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? { - let rawPath = requestURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let normalizedPath = rawPath.removingPercentEncoding ?? rawPath - let relativePath = normalizedPath.isEmpty || !normalizedPath.contains(".") ? "index.html" : normalizedPath - let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardized - let standardizedRootURL = rootURL.standardizedFileURL - let rootPath = standardizedRootURL.path.hasSuffix("/") - ? standardizedRootURL.path - : standardizedRootURL.path + "/" - guard fileURL.path.hasPrefix(rootPath) || fileURL.path == standardizedRootURL.path else { - return nil - } - return fileURL - } -} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift deleted file mode 100644 index 5df5795ad..000000000 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -import Foundation - -// nil mimeType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate. -enum RemoteContentIntegrity { - static func normalizeSha256(_ value: String) -> String { - var normalized = value.lowercased() - if normalized.hasPrefix("sha256-") { - normalized.removeFirst("sha256-".count) - } - if normalized.hasPrefix("0x") { - normalized.removeFirst(2) - } - return normalized - } - - static func isAcceptableMimeType(_ mimeType: String?) -> Bool { - mimeType == nil || mimeType == "text/html" - } -} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift index 480e748dd..bb05c32e3 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift @@ -3,8 +3,6 @@ import Foundation enum RemoteNavigationPolicy { - private static let bundledScheme = SelfWebViewHost.bundledScheme - private static let bundledHost = SelfWebViewHost.bundledHost private static let allowedSubframeHosts: Set = ["verify.didit.me"] static func makeEntryURL(baseURL: URL?, queryParams: String) -> URL? { @@ -25,11 +23,9 @@ enum RemoteNavigationPolicy { isDebugMode: Bool ) -> Bool { if isDebugMode { - return url.absoluteString.hasPrefix("http://localhost:5173") - } - - if url.scheme == bundledScheme, url.host == bundledHost { - return true + return url.scheme == "http" && + url.host == "localhost" && + resolvedPort(for: url) == 5173 } guard let remoteWebAppBaseURL, @@ -54,7 +50,8 @@ enum RemoteNavigationPolicy { guard url.scheme == "https", let host = url.host else { return false } - return allowedSubframeHosts.contains(host) + let port = resolvedPort(for: url) + return allowedSubframeHosts.contains(host) && port == 443 } static func resolvedPort(for url: URL) -> Int { diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift index 846763e31..b41cdac7d 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift @@ -1,31 +1,25 @@ // SPDX-License-Identifier: BUSL-1.1 -import CryptoKit import Foundation import UIKit import WebKit final class SelfWebViewHost: NSObject { - static let bundledScheme = "self-sdk" - static let bundledHost = "app" - fileprivate static let bundledRootFolder = "self-sdk-web" + private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")! private var webView: WKWebView? private let router: MessageRouter private let isDebugMode: Bool - private let remoteWebAppBaseURL: URL? - private let remoteWebAppIntegritySha256: String? + private let remoteWebAppBaseURL: URL init( router: MessageRouter, isDebugMode: Bool = false, - remoteWebAppBaseURL: URL? = nil, - remoteWebAppIntegritySha256: String? = nil + remoteWebAppBaseURL: URL? = nil ) { self.router = router self.isDebugMode = isDebugMode - self.remoteWebAppBaseURL = remoteWebAppBaseURL - self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256 + self.remoteWebAppBaseURL = remoteWebAppBaseURL ?? Self.defaultRemoteBaseURL super.init() } @@ -37,7 +31,6 @@ final class SelfWebViewHost: NSObject { config.preferences.javaScriptCanOpenWindowsAutomatically = false config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] - config.setURLSchemeHandler(SelfBundledAssetSchemeHandler(), forURLScheme: SelfWebViewHost.bundledScheme) let webView = WKWebView(frame: .zero, configuration: config) webView.scrollView.bounces = false @@ -49,14 +42,12 @@ final class SelfWebViewHost: NSObject { webView.isInspectable = isDebugMode } - webView.navigationDelegate = self self.webView = webView return webView } func loadContent(queryParams: String) { guard let webView = webView else { return } - if isDebugMode { let debugBase = URL(string: "http://localhost:5173") if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) { @@ -64,12 +55,10 @@ final class SelfWebViewHost: NSObject { } return } - - if let bundledURL = makeBundledEntryURL(queryParams: queryParams) { - webView.load(URLRequest(url: bundledURL)) + guard remoteWebAppBaseURL.scheme == "https" else { return } + if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams) { + webView.load(URLRequest(url: url)) } - - loadVerifiedRemoteContent(queryParams: queryParams) } func evaluateJs(_ js: String) { @@ -78,71 +67,6 @@ final class SelfWebViewHost: NSObject { } } - private func makeBundledEntryURL(queryParams: String) -> URL? { - RemoteNavigationPolicy.makeEntryURL( - baseURL: URL(string: "\(SelfWebViewHost.bundledScheme)://\(SelfWebViewHost.bundledHost)"), - queryParams: queryParams - ) - } - - private func loadVerifiedRemoteContent(queryParams: String) { - guard let baseURL = remoteWebAppBaseURL, - baseURL.scheme == "https", - baseURL.host != nil, - let expectedSha256 = remoteWebAppIntegritySha256?.trimmingCharacters(in: .whitespacesAndNewlines), - !expectedSha256.isEmpty, - let remoteURL = RemoteNavigationPolicy.makeEntryURL(baseURL: baseURL, queryParams: queryParams) else { - return - } - - Task.detached { [weak self] in - guard let self else { return } - guard let verifiedHTML = await self.fetchAndVerifyRemoteEntry( - url: remoteURL, expectedSha256: expectedSha256 - ) else { - return - } - - await MainActor.run { - self.webView?.loadHTMLString(verifiedHTML, baseURL: remoteURL) - } - } - } - - private static let maxRemoteEntryBytes = 5 * 1024 * 1024 - - private func fetchAndVerifyRemoteEntry(url: URL, expectedSha256: String) async -> String? { - do { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 5 - configuration.timeoutIntervalForResource = 5 - let session = URLSession(configuration: configuration) - let (data, response) = try await session.data(from: url) - guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { - return nil - } - guard RemoteContentIntegrity.isAcceptableMimeType(response.mimeType) else { - return nil - } - guard data.count <= SelfWebViewHost.maxRemoteEntryBytes else { - return nil - } - - let digest = SHA256.hash(data: data) - let actualHash = digest.map { String(format: "%02x", $0) }.joined() - guard actualHash == normalizeSha256(expectedSha256) else { - return nil - } - return String(data: data, encoding: .utf8) - } catch { - return nil - } - } - - private func normalizeSha256(_ value: String) -> String { - RemoteContentIntegrity.normalizeSha256(value) - } - private func isAllowedNavigation(url: URL) -> Bool { RemoteNavigationPolicy.isAllowedMainFrameNavigation( url: url, @@ -190,84 +114,63 @@ extension SelfWebViewHost: WKScriptMessageHandler { didReceive message: WKScriptMessage ) { guard message.name == "SelfNativeIOS", + message.frameInfo.isMainFrame, + isTrustedBridgeFrameInfo(message.frameInfo.securityOrigin), let body = message.body as? String else { return } - router.onMessageReceived(rawJson: body) + router.onMessageReceived(rawJson: body, isTrustedSource: true) } } -private final class SelfBundledAssetSchemeHandler: NSObject, WKURLSchemeHandler { - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - guard let requestURL = urlSchemeTask.request.url, - let rootURL = Bundle.module.resourceURL?.appendingPathComponent( - SelfWebViewHost.bundledRootFolder, - isDirectory: true - ), - let fileURL = resolveFileURL(for: requestURL, rootURL: rootURL) else { - urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) - return +private extension SelfWebViewHost { + func isTrustedBridgeOrigin(_ url: URL?) -> Bool { + guard let url else { return false } + if isDebugMode { + return url.scheme == "http" && + url.host == "localhost" && + resolvedPort(for: url) == 5173 } + return url.scheme == remoteWebAppBaseURL.scheme && + url.host == remoteWebAppBaseURL.host && + resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL) + } +} - do { - let data = try Data(contentsOf: fileURL) - let response = URLResponse( - url: requestURL, - mimeType: mimeType(for: fileURL.pathExtension), - expectedContentLength: data.count, - textEncodingName: textEncodingName(for: fileURL.pathExtension) - ) - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } catch { - urlSchemeTask.didFailWithError(error) +extension SelfWebViewHost { + func initialContentURL(queryParams: String) -> URL? { + if isDebugMode { + let debugBase = URL(string: "http://localhost:5173") + return RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) } + guard remoteWebAppBaseURL.scheme == "https" else { return nil } + return RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams) } - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} - - private func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? { - BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL) + func isAllowedNavigationURL(_ url: URL?, host: String? = nil) -> Bool { + guard let url else { return false } + return isAllowedNavigation(url: url) || isAllowedSubframeNavigation(url: url) } - private func mimeType(for pathExtension: String) -> String { - switch pathExtension.lowercased() { - case "html": - return "text/html" - case "js": - return "application/javascript" - case "css": - return "text/css" - case "json": - return "application/json" - case "svg": - return "image/svg+xml" - case "png": - return "image/png" - case "jpg", "jpeg": - return "image/jpeg" - case "woff2": - return "font/woff2" - case "woff": - return "font/woff" - case "ttf": - return "font/ttf" - case "otf": - return "font/otf" - case "wav": - return "audio/wav" - default: - return "application/octet-stream" + func isTrustedBridgeURL(_ url: URL?) -> Bool { + isTrustedBridgeOrigin(url) + } + + func isTrustedBridgeFrameInfo(_ origin: WKSecurityOrigin) -> Bool { + if isDebugMode { + return origin.protocol == "http" && origin.host == "localhost" && origin.port == 5173 } + return origin.protocol == remoteWebAppBaseURL.scheme && + origin.host == remoteWebAppBaseURL.host && + resolvedSecurityOriginPort(origin) == resolvedPort(for: remoteWebAppBaseURL) } - private func textEncodingName(for pathExtension: String) -> String? { - switch pathExtension.lowercased() { - case "html", "js", "css", "json", "svg": - return "utf-8" - default: - return nil + private func resolvedSecurityOriginPort(_ origin: WKSecurityOrigin) -> Int { + if origin.port != 0 { return origin.port } + switch origin.protocol { + case "https": return 443 + case "http": return 80 + default: return 0 } } } diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift deleted file mode 100644 index 51062e0fa..000000000 --- a/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -import Foundation -import XCTest -@testable import SelfNativeShell - -final class BundledAssetPathResolverTests: XCTestCase { - func testResolvesIndexForDirectoryStyleRequest() { - let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) - let requestURL = URL(string: "self-sdk://app/tunnel/tour/1")! - - XCTAssertEqual( - BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path, - rootURL.appendingPathComponent("index.html").path - ) - } - - func testResolvesStaticAssetInsideBundle() { - let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) - let requestURL = URL(string: "self-sdk://app/assets/app.js")! - - XCTAssertEqual( - BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path, - rootURL.appendingPathComponent("assets/app.js").path - ) - } - - func testRejectsPathTraversal() { - let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) - let requestURL = URL(string: "self-sdk://app/../../secret.txt")! - - XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)) - } - - func testRejectsPercentEncodedTraversal() { - let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) - let requestURL = URL(string: "self-sdk://app/%2E%2E/%2E%2E/secret.txt")! - - XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)) - } -} diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterEscapeTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterEscapeTests.swift index f75ec5646..2ff3e683e 100644 --- a/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterEscapeTests.swift +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterEscapeTests.swift @@ -45,7 +45,8 @@ final class MessageRouterEscapeTests: XCTestCase { router.onMessageReceived( rawJson: """ {"type":"request","version":1,"id":"req-1","domain":"secureStorage","method":"get","timestamp":1000} - """ + """, + isTrustedSource: true ) waitForExpectations(timeout: 2) diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterTests.swift index 685f963cf..f7ef05760 100644 --- a/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterTests.swift +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/MessageRouterTests.swift @@ -38,7 +38,7 @@ final class MessageRouterTests: XCTestCase { expectation.fulfill() } - router.onMessageReceived(rawJson: makeRequestJSON(version: 999)) + router.onMessageReceived(rawJson: makeRequestJSON(version: 999), isTrustedSource: true) waitForExpectations(timeout: 2) @@ -57,7 +57,7 @@ final class MessageRouterTests: XCTestCase { expectation.fulfill() } - router.onMessageReceived(rawJson: makeRequestJSON(domain: "secureStorage")) + router.onMessageReceived(rawJson: makeRequestJSON(domain: "secureStorage"), isTrustedSource: true) waitForExpectations(timeout: 2) @@ -77,7 +77,7 @@ final class MessageRouterTests: XCTestCase { } router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "abc"])) - router.onMessageReceived(rawJson: makeRequestJSON()) + router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true) waitForExpectations(timeout: 2) @@ -99,7 +99,7 @@ final class MessageRouterTests: XCTestCase { error: BridgeHandlerError.missingParam("key") )) - router.onMessageReceived(rawJson: makeRequestJSON()) + router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true) waitForExpectations(timeout: 2) @@ -120,7 +120,7 @@ final class MessageRouterTests: XCTestCase { error: NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "disk full"]) )) - router.onMessageReceived(rawJson: makeRequestJSON()) + router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true) waitForExpectations(timeout: 2) @@ -137,7 +137,7 @@ final class MessageRouterTests: XCTestCase { sentCount += 1 } - router.onMessageReceived(rawJson: "{not valid json") + router.onMessageReceived(rawJson: "{not valid json", isTrustedSource: true) // Give async code a chance to run let expectation = expectation(description: "wait") @@ -149,6 +149,24 @@ final class MessageRouterTests: XCTestCase { XCTAssertEqual(sentCount, 0) } + func testUntrustedOriginMessagesAreDroppedBeforeDispatch() { + var sentCount = 0 + let router = MessageRouter { _ in + sentCount += 1 + } + router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "abc"])) + + router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: false) + + let expectation = expectation(description: "wait") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + expectation.fulfill() + } + waitForExpectations(timeout: 2) + + XCTAssertEqual(sentCount, 0) + } + // MARK: - Registration func testRegisterReplacesExistingHandler() { @@ -162,7 +180,7 @@ final class MessageRouterTests: XCTestCase { router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "first"])) router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "second"])) - router.onMessageReceived(rawJson: makeRequestJSON()) + router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true) waitForExpectations(timeout: 2) diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift deleted file mode 100644 index 0a5cc4087..000000000 --- a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -import XCTest -@testable import SelfNativeShell - -final class RemoteContentIntegrityTests: XCTestCase { - - // MARK: - normalizeSha256 - - func testStripsShaPrefixOnly() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"), - "abcdef1234567890" - ) - } - - func testStrips0xPrefixOnly() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"), - "abcdef1234567890" - ) - } - - func testStripsSha256Then0xPrefix() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"), - "abcdef" - ) - } - - func testLowercasesInput() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("ABCDEF"), - "abcdef" - ) - } - - func testRawHexPassesThrough() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("abcdef1234567890"), - "abcdef1234567890" - ) - } - - func testDoesNotStripInteriorSha256() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("absha256-cd"), - "absha256-cd" - ) - } - - func testDoesNotStripInterior0x() { - XCTAssertEqual( - RemoteContentIntegrity.normalizeSha256("ab0xcd"), - "ab0xcd" - ) - } - - // MARK: - isAcceptableMimeType - - func testAcceptsNilMimeType() { - XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType(nil)) - } - - func testAcceptsTextHtml() { - XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType("text/html")) - } - - func testRejectsApplicationJavascript() { - XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/javascript")) - } - - func testRejectsApplicationJson() { - XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/json")) - } - - func testRejectsTextPlain() { - XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("text/plain")) - } - - func testRejectsEmptyString() { - XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("")) - } -} diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift index c1001be10..5e2a94de1 100644 --- a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift @@ -73,6 +73,16 @@ final class RemoteNavigationPolicyTests: XCTestCase { ) } + func testSubframeRejectsDiditOnNonStandardPort() { + XCTAssertFalse( + RemoteNavigationPolicy.isAllowedSubframeNavigation( + url: URL(string: "https://verify.didit.me:8443/flow")!, + remoteWebAppBaseURL: nil, + isDebugMode: false + ) + ) + } + func testMakeEntryURLAppendsHostedPathAndQuery() { XCTAssertEqual( RemoteNavigationPolicy.makeEntryURL( diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/SelfWebViewHostTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/SelfWebViewHostTests.swift new file mode 100644 index 000000000..543cd0cee --- /dev/null +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/SelfWebViewHostTests.swift @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import XCTest +@testable import SelfNativeShell + +final class SelfWebViewHostTests: XCTestCase { + func testReleaseBuildUsesRemoteOrigin() throws { + let router = MessageRouter(sendToWebView: { _ in }) + let host = SelfWebViewHost(router: router, isDebugMode: false) + _ = host.createWebView() + + let url = try XCTUnwrap(host.initialContentURL(queryParams: "")) + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "self-app-alpha.vercel.app") + XCTAssertTrue(url.path.contains("/tunnel/tour/1")) + } + + func testDebugBuildUsesLocalhost() throws { + let router = MessageRouter(sendToWebView: { _ in }) + let host = SelfWebViewHost(router: router, isDebugMode: true) + _ = host.createWebView() + + let url = try XCTUnwrap(host.initialContentURL(queryParams: "")) + XCTAssertEqual(url.scheme, "http") + XCTAssertEqual(url.host, "localhost") + XCTAssertTrue(url.absoluteString.hasPrefix("http://localhost:5173")) + } + + func testAllowedNavigationAcceptsRemoteAlphaOrigin() { + let router = MessageRouter(sendToWebView: { _ in }) + let host = SelfWebViewHost(router: router, isDebugMode: false) + _ = host.createWebView() + + XCTAssertTrue( + host.isAllowedNavigationURL( + URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1") + ) + ) + XCTAssertTrue( + host.isAllowedNavigationURL( + URL(string: "https://verify.didit.me/session/123") + ) + ) + XCTAssertFalse( + host.isAllowedNavigationURL( + URL(string: "https://evil.example.com/tunnel/tour/1") + ) + ) + } + + func testHttpBaseURLProducesNilInRelease() { + let router = MessageRouter(sendToWebView: { _ in }) + let host = SelfWebViewHost( + router: router, + isDebugMode: false, + remoteWebAppBaseURL: URL(string: "http://self-app-alpha.vercel.app") + ) + _ = host.createWebView() + + XCTAssertNil(host.initialContentURL(queryParams: "")) + } + + func testBridgeTrustAcceptsRemoteRejectsDidit() { + let router = MessageRouter(sendToWebView: { _ in }) + let host = SelfWebViewHost(router: router, isDebugMode: false) + _ = host.createWebView() + + XCTAssertTrue( + host.isTrustedBridgeURL( + URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1") + ) + ) + XCTAssertFalse( + host.isTrustedBridgeURL( + URL(string: "https://verify.didit.me/session/123") + ) + ) + XCTAssertFalse( + host.isTrustedBridgeURL( + URL(string: "https://evil.example.com/tunnel/tour/1") + ) + ) + } +} diff --git a/packages/self-sdk-swift/Package.swift b/packages/self-sdk-swift/Package.swift index 968839e9f..f84872bc5 100644 --- a/packages/self-sdk-swift/Package.swift +++ b/packages/self-sdk-swift/Package.swift @@ -27,5 +27,10 @@ let package = Package( ], path: "Sources/SelfSdkSwift" ), + .testTarget( + name: "SelfSdkSwiftTests", + dependencies: ["SelfSdkSwift"], + path: "Tests/SelfSdkSwiftTests" + ), ] ) diff --git a/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift b/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift index 2b59444ef..588cfe5d0 100644 --- a/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift +++ b/packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift @@ -9,11 +9,16 @@ import WebKit /// Swift implementation of WebViewProvider using WKWebView. /// Handles message passing between the WebView and the KMP bridge. public class WebViewProviderImpl: NSObject { + static let loopbackHost = "127.0.0.1" + static let diditHost = "verify.didit.me" + static let debugPort: UInt16 = 5173 + private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")! private var webView: WKWebView? private var viewController: UIViewController? private var onMessageReceived: ((String) -> Void)? private var isDebugMode: Bool = false + private var remoteWebAppBaseURL: URL = WebViewProviderImpl.defaultRemoteBaseURL /// Weak proxy to avoid retain cycles with WKScriptMessageHandler private var messageProxy: WeakScriptMessageProxy? @@ -33,7 +38,7 @@ public class WebViewProviderImpl: NSObject { self.webView = nil self.viewController = nil } - + self.onMessageReceived = onMessageReceived // Create message proxy to avoid retain cycle @@ -64,12 +69,8 @@ public class WebViewProviderImpl: NSObject { wv.navigationDelegate = self self.webView = wv - var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1" - if let params = queryParams, !params.isEmpty { - urlString += "?\(params)" - } - guard let url = URL(string: urlString) else { - NSLog("SelfSDK-WebView: Failed to construct URL from: %@", urlString) + guard let url = initialContentURL(queryParams: queryParams) else { + NSLog("SelfSDK-WebView: Failed to construct bundled URL") return wv } wv.load(URLRequest(url: url)) @@ -99,6 +100,16 @@ public class WebViewProviderImpl: NSObject { self.viewController = vc return vc } + + @objc public func isBridgeRequestAllowed() -> Bool { + isTrustedBridgeURL(webView?.url) + } + + @objc(configureRemoteLoadingRemoteWebAppBaseURL:) + public func configureRemoteLoading(remoteWebAppBaseURL: String?) { + self.remoteWebAppBaseURL = remoteWebAppBaseURL.flatMap { URL(string: $0) } + ?? Self.defaultRemoteBaseURL + } } // MARK: - Host VC that embeds the WKWebView with proper Auto Layout @@ -145,10 +156,8 @@ extension WebViewProviderImpl: WKNavigationDelegate { decisionHandler(.cancel) return } - let isTrusted = - (url.scheme == "https" && host == "self-app-alpha.vercel.app") || - (isDebugMode && url.scheme == "http" && host == "127.0.0.1") - decisionHandler(isTrusted ? .allow : .cancel) + let isAllowed = isAllowedNavigationURL(url, host: host) + decisionHandler(isAllowed ? .allow : .cancel) } } @@ -159,7 +168,10 @@ extension WebViewProviderImpl: WKScriptMessageHandler { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { - guard message.name == "SelfNativeIOS" else { return } + guard message.name == "SelfNativeIOS", + message.frameInfo.isMainFrame, + isTrustedBridgeFrameInfo(message.frameInfo.securityOrigin), + isBridgeRequestAllowed() else { return } if let body = message.body as? String { onMessageReceived?(body) @@ -171,6 +183,74 @@ extension WebViewProviderImpl: WKScriptMessageHandler { } } +extension WebViewProviderImpl { + func initialContentURL(queryParams: String?) -> URL? { + if isDebugMode { + var components = URLComponents() + components.scheme = "http" + components.host = Self.loopbackHost + components.port = Int(Self.debugPort) + components.path = "/tunnel/tour/1" + if let queryParams, !queryParams.isEmpty { + components.percentEncodedQuery = queryParams + } + return components.url + } + + guard remoteWebAppBaseURL.scheme == "https" else { return nil } + var components = URLComponents() + components.scheme = remoteWebAppBaseURL.scheme + components.host = remoteWebAppBaseURL.host + if let port = remoteWebAppBaseURL.port { components.port = port } + components.path = "/tunnel/tour/1" + if let queryParams, !queryParams.isEmpty { + components.percentEncodedQuery = queryParams + } + return components.url + } + + func isAllowedNavigationURL(_ url: URL?, host: String? = nil) -> Bool { + guard let url else { return false } + let resolvedHost = host ?? url.host + return isTrustedBridgeURL(url) || + (url.scheme == "https" && resolvedHost == Self.diditHost && resolvedPort(for: url) == 443) + } + + func isTrustedBridgeURL(_ url: URL?) -> Bool { + guard let url else { return false } + if isDebugMode { + return url.scheme == "http" && url.host == Self.loopbackHost && url.port == Int(Self.debugPort) + } + return url.scheme == remoteWebAppBaseURL.scheme && + url.host == remoteWebAppBaseURL.host && + resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL) + } + + func isTrustedBridgeFrameInfo(_ origin: WKSecurityOrigin) -> Bool { + if isDebugMode { + return origin.protocol == "http" && origin.host == Self.loopbackHost && origin.port == Int(Self.debugPort) + } + let expectedPort = resolvedPort(for: remoteWebAppBaseURL) + return origin.protocol == remoteWebAppBaseURL.scheme && + origin.host == remoteWebAppBaseURL.host && + resolvedSecurityOriginPort(origin) == expectedPort + } + + private func resolvedSecurityOriginPort(_ origin: WKSecurityOrigin) -> Int { + if origin.port != 0 { return origin.port } + switch origin.protocol { + case "https": return 443 + case "http": return 80 + default: return 0 + } + } + + private func resolvedPort(for url: URL) -> Int { + if let port = url.port { return port } + return url.scheme == "https" ? 443 : 80 + } +} + // MARK: - Weak proxy to break WKScriptMessageHandler retain cycle /// WKUserContentController retains its message handler strongly. diff --git a/packages/self-sdk-swift/Tests/SelfSdkSwiftTests/WebViewProviderImplTests.swift b/packages/self-sdk-swift/Tests/SelfSdkSwiftTests/WebViewProviderImplTests.swift new file mode 100644 index 000000000..a222a0101 --- /dev/null +++ b/packages/self-sdk-swift/Tests/SelfSdkSwiftTests/WebViewProviderImplTests.swift @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import XCTest +@testable import SelfSdkSwift + +final class WebViewProviderImplTests: XCTestCase { + func testReleaseBuildUsesRemoteOrigin() throws { + let provider = WebViewProviderImpl() + + let url = try XCTUnwrap(provider.initialContentURL(queryParams: nil)) + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "self-app-alpha.vercel.app") + XCTAssertTrue(url.path.contains("/tunnel/tour/1")) + } + + func testDebugBuildUsesLocalhost() throws { + let provider = WebViewProviderImpl() + _ = provider.createWebView(onMessageReceived: { _ in }, isDebugMode: true) + + let url = try XCTUnwrap(provider.initialContentURL(queryParams: nil)) + XCTAssertEqual(url.scheme, "http") + XCTAssertEqual(url.host, "127.0.0.1") + XCTAssertEqual(url.port, 5173) + XCTAssertTrue(url.path.contains("/tunnel/tour/1")) + } + + func testHttpBaseURLProducesNilInRelease() { + let provider = WebViewProviderImpl() + provider.configureRemoteLoading(remoteWebAppBaseURL: "http://self-app-alpha.vercel.app") + + XCTAssertNil(provider.initialContentURL(queryParams: nil)) + } + + func testAllowedNavigationAcceptsRemoteAlphaAndDidit() { + let provider = WebViewProviderImpl() + + XCTAssertTrue( + provider.isAllowedNavigationURL( + URL(string: "https://verify.didit.me/session/123") + ) + ) + XCTAssertTrue( + provider.isAllowedNavigationURL( + URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1") + ) + ) + } + + func testDiditOnNonStandardPortIsRejected() { + let provider = WebViewProviderImpl() + + XCTAssertFalse( + provider.isAllowedNavigationURL( + URL(string: "https://verify.didit.me:8443/session/123") + ) + ) + } + + func testAllowedNavigationRejectsArbitraryOrigins() { + let provider = WebViewProviderImpl() + + XCTAssertFalse( + provider.isAllowedNavigationURL( + URL(string: "https://evil.com/tunnel/tour/1") + ) + ) + XCTAssertFalse( + provider.isAllowedNavigationURL( + URL(string: "http://example.com/test") + ) + ) + } + + func testBridgeTrustAcceptsRemoteOrigin() { + let provider = WebViewProviderImpl() + + XCTAssertTrue( + provider.isTrustedBridgeURL( + URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1") + ) + ) + } + + func testBridgeTrustRejectsDiditAndArbitrary() { + let provider = WebViewProviderImpl() + + XCTAssertFalse( + provider.isTrustedBridgeURL( + URL(string: "https://verify.didit.me/session/123") + ) + ) + XCTAssertFalse( + provider.isTrustedBridgeURL( + URL(string: "https://evil.com/tunnel/tour/1") + ) + ) + } +}