mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
87
docs/reviews/PR-1924-review-findings.md
Normal file
87
docs/reviews/PR-1924-review-findings.md
Normal 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
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,4 +19,8 @@ interface WebViewProvider {
|
||||
fun evaluateJs(js: String)
|
||||
|
||||
fun getViewController(): UIViewController
|
||||
|
||||
fun isBridgeRequestAllowed(): Boolean
|
||||
|
||||
fun configureRemoteLoading(remoteWebAppBaseURL: String?) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,10 @@ let package = Package(
|
||||
],
|
||||
path: "Sources/SelfSdkSwift"
|
||||
),
|
||||
.testTarget(
|
||||
name: "SelfSdkSwiftTests",
|
||||
dependencies: ["SelfSdkSwift"],
|
||||
path: "Tests/SelfSdkSwiftTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user