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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* coderabbit comments

* lint

* coderabbit comments

---------

Co-authored-by: seshanthS <seshanth@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Hernandez
2026-04-07 10:09:27 -07:00
committed by GitHub
parent 40f283b2db
commit f29130587b
39 changed files with 1129 additions and 765 deletions

5
.gitignore vendored
View File

@@ -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/

View File

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

View File

@@ -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<String> {
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() }
}
}

View File

@@ -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)
}

View File

@@ -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<IllegalArgumentException> {
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,
),
)
}
}

View File

@@ -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,
)

View File

@@ -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<BridgeRequest>(rawJson)

View File

@@ -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<String>()
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<String>()
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<String>()
@@ -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)

View File

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

View File

@@ -19,4 +19,8 @@ interface WebViewProvider {
fun evaluateJs(js: String)
fun getViewController(): UIViewController
fun isBridgeRequestAllowed(): Boolean
fun configureRemoteLoading(remoteWebAppBaseURL: String?) {}
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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<BridgeRequest>(rawJson)

View File

@@ -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<String>()
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<String> =
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() }
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -66,7 +66,7 @@ class MessageRouterTest {
val sent = mutableListOf<String>()
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<String>()
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<String>()
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<String>()
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])

View File

@@ -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<IllegalArgumentException> {
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,
),
)
}
}

View File

@@ -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"))
}
}

View File

@@ -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",

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Self</title>
<script type="module" crossorigin src="./assets/index-YX6AnLbA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-LqDWjDzu.css">
<script type="module" crossorigin src="/assets/index-YX6AnLbA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LqDWjDzu.css">
</head>
<body>
<div id="root"></div>

View File

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

View File

@@ -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
}

View File

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

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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<String> = ["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 {

View File

@@ -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
}
}
}

View File

@@ -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))
}
}

View File

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

View File

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

View File

@@ -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(""))
}
}

View File

@@ -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(

View File

@@ -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")
)
)
}
}

View File

@@ -27,5 +27,10 @@ let package = Package(
],
path: "Sources/SelfSdkSwift"
),
.testTarget(
name: "SelfSdkSwiftTests",
dependencies: ["SelfSdkSwift"],
path: "Tests/SelfSdkSwiftTests"
),
]
)

View File

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

View File

@@ -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")
)
)
}
}