From 9b8e0814356eb2a017d135edbc2f8eb215cf3912 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Fri, 3 Apr 2026 20:55:31 -0700 Subject: [PATCH] Add remote webview integrity checks (#1907) * Add remote webview integrity checks * fixes * feedback * update tests; fix pipelines * fix ci * feat(webview): add subresource integrity (SRI) to build output The SHA-256 remote integrity check only covers the entry HTML document. Sub-resources (JS, CSS) loaded by that HTML were fetched without integrity verification, allowing a compromised CDN to swap bundles. Add a custom Vite plugin that injects SRI sha384 hashes into all script and link tags in the built index.html. The browser natively enforces these hashes, blocking any tampered sub-resources. Includes tests verifying integrity attributes are present and that hashes match the actual file contents on disk. --------- Co-authored-by: Tranquil-Flow --- .../main/kotlin/xyz/self/sdk/api/SelfSdk.kt | 4 + .../kotlin/xyz/self/sdk/api/SelfSdkConfig.kt | 2 + .../self/sdk/webview/AndroidWebViewHost.kt | 184 ++++++++++++--- .../sdk/webview/RemoteContentIntegrity.kt | 20 ++ .../sdk/webview/RemoteNavigationPolicy.kt | 56 +++++ .../sdk/webview/SelfVerificationActivity.kt | 13 +- .../sdk/webview/RemoteContentIntegrityTest.kt | 121 ++++++++++ .../sdk/webview/RemoteNavigationPolicyTest.kt | 89 ++++++++ .../Sources/SelfNativeShell/API/SelfSdk.swift | 7 +- .../SelfNativeShell/API/SelfSdkConfig.swift | 6 + .../WebView/BundledAssetPathResolver.swift | 20 ++ .../WebView/RemoteContentIntegrity.swift | 21 ++ .../WebView/RemoteNavigationPolicy.swift | 73 ++++++ .../WebView/SelfWebViewHost.swift | 209 ++++++++++++++++-- .../BundledAssetPathResolverTests.swift | 41 ++++ .../RemoteContentIntegrityTests.swift | 84 +++++++ .../RemoteNavigationPolicyTests.swift | 85 +++++++ packages/webview-app/src/test/sri.test.ts | 70 ++++++ packages/webview-app/vite.config.ts | 53 ++++- 19 files changed, 1112 insertions(+), 46 deletions(-) create mode 100644 packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt create mode 100644 packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicy.kt create mode 100644 packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt create mode 100644 packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicyTest.kt create mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift create mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift create mode 100644 packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift create mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift create mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift create mode 100644 packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift create mode 100644 packages/webview-app/src/test/sri.test.ts diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt index c765d5fbf..44ae92ba5 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdk.kt @@ -35,6 +35,10 @@ 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) + } } activity.startActivityForResult(intent, requestCode) } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt index 9b88eeef8..ca95eb3f6 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt @@ -19,6 +19,8 @@ data class SelfSdkConfig( val chainID: Int? = null, val userDefinedData: String? = null, val selfDefinedData: String? = null, + val remoteWebAppBaseUrl: String? = null, + val remoteWebAppIntegritySha256: String? = null, ) class SelfSdkException( diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt index a1b26f129..7183e9b07 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt @@ -9,6 +9,7 @@ import android.content.Context 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 @@ -22,39 +23,54 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.webkit.WebViewAssetLoader 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 lateinit var webView: WebView + + @Volatile + private var isDestroyed = false var fileUploadCallback: ValueCallback>? = null var pendingPermissionRequest: PermissionRequest? = null @SuppressLint("SetJavaScriptEnabled") fun createWebView(queryParams: String): WebView { + isDestroyed = false val selfWalletHandler = - WebViewAssetLoader.PathHandler { path -> + WebViewAssetLoader.PathHandler { rawPath -> try { - val assetPath = "self-wallet/$path" + 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 { - path.endsWith(".js") -> "application/javascript" - path.endsWith(".css") -> "text/css" - path.endsWith(".html") -> "text/html" - path.endsWith(".json") -> "application/json" - path.endsWith(".woff2") -> "font/woff2" - path.endsWith(".woff") -> "font/woff" - path.endsWith(".otf") -> "font/otf" - path.endsWith(".ttf") -> "font/ttf" - path.endsWith(".png") -> "image/png" - path.endsWith(".svg") -> "image/svg+xml" + 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 (e: Exception) { + } catch (_: Exception) { null } } @@ -87,7 +103,7 @@ class AndroidWebViewHost( ): WebResourceResponse? { request ?: return null val url = request.url - if (url.host != "appassets.androidplatform.net") return null + if (url.host != BUNDLED_HOST) return null return assetLoader.shouldInterceptRequest(url) } @@ -95,10 +111,10 @@ class AndroidWebViewHost( view: WebView?, request: WebResourceRequest?, ): Boolean { - val url = request?.url?.toString() ?: return true - if (url.startsWith("https://appassets.androidplatform.net/")) return false - if (url.startsWith("https://self-app-alpha.vercel.app/")) return false - if (isDebugMode && url.startsWith("http://127.0.0.1:5173")) return false + 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 } @@ -116,13 +132,13 @@ class AndroidWebViewHost( override fun onPermissionRequest(request: PermissionRequest?) { request ?: return - // Only allow permissions from trusted origins - val origin = request.origin?.toString() ?: "" + val originStr = request.origin?.toString() ?: "" + val originUri = Uri.parse(originStr) val isTrusted = - origin.startsWith("https://appassets.androidplatform.net") || - origin.startsWith("https://self-app-alpha.vercel.app") || - origin.startsWith("https://verify.didit.me") || - (isDebugMode && origin.startsWith("http://127.0.0.1")) + isBundledOrigin(originUri) || + (isDebugMode && isDebugOrigin(originUri)) || + isMatchingOrigin(originUri, "https", "verify.didit.me", 443) || + isAllowedRemoteOrigin(originStr) if (!isTrusted) { request.deny() return @@ -134,7 +150,6 @@ class AndroidWebViewHost( return } - // Collect required Android permissions val neededPermissions = mutableListOf() if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { neededPermissions.add(Manifest.permission.CAMERA) @@ -143,7 +158,6 @@ class AndroidWebViewHost( neededPermissions.add(Manifest.permission.RECORD_AUDIO) } - // Check if any runtime permissions are missing val missingPermissions = neededPermissions.filter { ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED @@ -178,7 +192,7 @@ class AndroidWebViewHost( } try { activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE) - } catch (e: Exception) { + } catch (_: Exception) { fileUploadCallback = null return false } @@ -189,9 +203,10 @@ class AndroidWebViewHost( addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid") if (isDebugMode) { - loadUrl("http://127.0.0.1:5173/tunnel/tour/1?$queryParams") + loadUrl(buildDebugUrl(queryParams)) } else { - loadUrl("https://self-app-alpha.vercel.app/tunnel/tour/1?$queryParams") + loadUrl(buildBundledUrl(queryParams)) + maybeLoadVerifiedRemoteContent(queryParams) } } return webView @@ -199,7 +214,7 @@ class AndroidWebViewHost( fun evaluateJs(js: String) { if (!::webView.isInitialized) { - android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized") + Log.e("WebViewHost", "evaluateJs called but webView not initialized") return } webView.evaluateJavascript(js, null) @@ -207,9 +222,102 @@ class AndroidWebViewHost( fun destroy() { if (!::webView.isInitialized) return + isDestroyed = true 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 isAllowedRemoteOrigin(url: String): Boolean = RemoteNavigationPolicy.isAllowedRemoteOrigin(url, remoteWebAppBaseUrl) + + 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) { @@ -217,8 +325,24 @@ class AndroidWebViewHost( } } + 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 } } diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt new file mode 100644 index 000000000..50a318a34 --- /dev/null +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteContentIntegrity.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.webview + +import java.security.MessageDigest + +// null contentType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate. +internal object RemoteContentIntegrity { + fun normalizeSha256(value: String): String = value.lowercase().removePrefix("sha256-").removePrefix("0x") + + fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { byte -> + "%02x".format(byte) + } + + fun isAcceptableContentType(rawContentType: String?): Boolean { + val normalized = rawContentType?.substringBefore(";")?.trim()?.lowercase() + return normalized == null || normalized == "text/html" + } +} diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicy.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicy.kt new file mode 100644 index 000000000..ff11ee8ba --- /dev/null +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicy.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.webview + +import java.net.URI + +internal object RemoteNavigationPolicy { + private const val REMOTE_SCHEME = "https" + + fun buildRemoteEntryUrl( + baseUrl: String?, + queryParams: String, + ): String? { + val normalizedBaseUrl = baseUrl?.takeIf { it.isNotBlank() } ?: return null + val uri = parseUri(normalizedBaseUrl) ?: return null + if (uri.scheme != REMOTE_SCHEME || uri.host.isNullOrBlank()) return null + return buildEntryUrl(normalizedBaseUrl.trimEnd('/'), queryParams) + } + + fun isAllowedRemoteOrigin( + candidateUrl: String, + baseUrl: String?, + ): Boolean { + val normalizedBaseUrl = baseUrl?.takeIf { it.isNotBlank() } ?: return false + val baseUri = parseUri(normalizedBaseUrl) ?: return false + if (baseUri.scheme != REMOTE_SCHEME || baseUri.host.isNullOrBlank()) return false + val candidateUri = parseUri(candidateUrl) ?: return false + + return baseUri.scheme == candidateUri.scheme && + baseUri.host == candidateUri.host && + resolvePort(baseUri) == resolvePort(candidateUri) + } + + fun resolvePort(uri: URI): Int = + when { + uri.port != -1 -> uri.port + uri.scheme == REMOTE_SCHEME -> 443 + uri.scheme == "http" -> 80 + else -> -1 + } + + private fun parseUri(value: String): URI? = + try { + URI(value) + } catch (_: IllegalArgumentException) { + null + } + + private fun buildEntryUrl( + baseUrl: String, + queryParams: String, + ): String { + val separator = if (queryParams.isEmpty()) "" else "?$queryParams" + return "$baseUrl/tunnel/tour/1$separator" + } +} diff --git a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt index 896c2d297..402e838b2 100644 --- a/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt +++ b/packages/native-shell-android/src/main/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt @@ -36,6 +36,8 @@ 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) router = MessageRouter( @@ -60,7 +62,14 @@ class SelfVerificationActivity : AppCompatActivity() { router.register(CryptoHandler()) router.register(LifecycleHandler(this)) - webViewHost = AndroidWebViewHost(this, router, isDebugMode) + webViewHost = + AndroidWebViewHost( + context = this, + router = router, + isDebugMode = isDebugMode, + remoteWebAppBaseUrl = remoteWebAppBaseUrl, + remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256, + ) val queryParams = buildString { @@ -151,6 +160,8 @@ class SelfVerificationActivity : AppCompatActivity() { const val EXTRA_CHAIN_ID = "xyz.self.sdk.CHAIN_ID" const val EXTRA_USER_DEFINED_DATA = "xyz.self.sdk.USER_DEFINED_DATA" const val EXTRA_SELF_DEFINED_DATA = "xyz.self.sdk.SELF_DEFINED_DATA" + const val EXTRA_REMOTE_WEB_APP_BASE_URL = "xyz.self.sdk.REMOTE_WEB_APP_BASE_URL" + const val EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256 = "xyz.self.sdk.REMOTE_WEB_APP_INTEGRITY_SHA256" const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA" } } diff --git a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt new file mode 100644 index 000000000..551ff25ea --- /dev/null +++ b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteContentIntegrityTest.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.webview + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RemoteContentIntegrityTest { + // -- normalizeSha256 -- + + @Test + fun `normalizeSha256 strips sha256- prefix`() { + assertEquals( + "abcdef1234567890", + RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"), + ) + } + + @Test + fun `normalizeSha256 strips 0x prefix`() { + assertEquals( + "abcdef1234567890", + RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"), + ) + } + + @Test + fun `normalizeSha256 strips sha256- then 0x prefix`() { + assertEquals( + "abcdef", + RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"), + ) + } + + @Test + fun `normalizeSha256 lowercases input`() { + assertEquals( + "abcdef", + RemoteContentIntegrity.normalizeSha256("ABCDEF"), + ) + } + + @Test + fun `normalizeSha256 returns raw hex unchanged`() { + assertEquals( + "abcdef1234567890", + RemoteContentIntegrity.normalizeSha256("abcdef1234567890"), + ) + } + + @Test + fun `normalizeSha256 does not strip interior sha256-`() { + assertEquals( + "absha256-cd", + RemoteContentIntegrity.normalizeSha256("absha256-cd"), + ) + } + + // -- sha256Hex -- + + @Test + fun `sha256Hex produces correct hash for known input`() { + // SHA-256 of empty byte array + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + RemoteContentIntegrity.sha256Hex(byteArrayOf()), + ) + } + + @Test + fun `sha256Hex produces correct hash for hello`() { + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + RemoteContentIntegrity.sha256Hex("hello".toByteArray(Charsets.UTF_8)), + ) + } + + // -- isAcceptableContentType -- + + @Test + fun `accepts null content type`() { + assertTrue(RemoteContentIntegrity.isAcceptableContentType(null)) + } + + @Test + fun `rejects empty content type`() { + assertFalse(RemoteContentIntegrity.isAcceptableContentType("")) + } + + @Test + fun `accepts text html`() { + assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html")) + } + + @Test + fun `accepts text html with charset`() { + assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html; charset=utf-8")) + } + + @Test + fun `accepts Text HTML case insensitive`() { + assertTrue(RemoteContentIntegrity.isAcceptableContentType("Text/HTML")) + } + + @Test + fun `rejects application javascript`() { + assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/javascript")) + } + + @Test + fun `rejects application json`() { + assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/json")) + } + + @Test + fun `rejects text plain`() { + assertFalse(RemoteContentIntegrity.isAcceptableContentType("text/plain")) + } +} diff --git a/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicyTest.kt b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicyTest.kt new file mode 100644 index 000000000..ae380fff8 --- /dev/null +++ b/packages/native-shell-android/src/test/kotlin/xyz/self/sdk/webview/RemoteNavigationPolicyTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: BUSL-1.1 + +package xyz.self.sdk.webview + +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RemoteNavigationPolicyTest { + @Test + fun `resolvePort defaults https to 443`() { + assertEquals(443, RemoteNavigationPolicy.resolvePort(URI("https://verify.self.xyz"))) + } + + @Test + fun `resolvePort defaults http to 80`() { + assertEquals(80, RemoteNavigationPolicy.resolvePort(URI("http://127.0.0.1"))) + } + + @Test + fun `resolvePort preserves explicit port`() { + assertEquals(8443, RemoteNavigationPolicy.resolvePort(URI("https://verify.self.xyz:8443"))) + } + + @Test + fun `isAllowedRemoteOrigin accepts matching https origin with implicit default port`() { + assertTrue( + RemoteNavigationPolicy.isAllowedRemoteOrigin( + candidateUrl = "https://verify.self.xyz/tunnel/tour/1", + baseUrl = "https://verify.self.xyz:443", + ), + ) + } + + @Test + fun `isAllowedRemoteOrigin rejects different host`() { + assertFalse( + RemoteNavigationPolicy.isAllowedRemoteOrigin( + candidateUrl = "https://evil.self.xyz/tunnel/tour/1", + baseUrl = "https://verify.self.xyz", + ), + ) + } + + @Test + fun `isAllowedRemoteOrigin rejects different scheme`() { + assertFalse( + RemoteNavigationPolicy.isAllowedRemoteOrigin( + candidateUrl = "http://verify.self.xyz/tunnel/tour/1", + baseUrl = "https://verify.self.xyz", + ), + ) + } + + @Test + fun `isAllowedRemoteOrigin rejects different port`() { + assertFalse( + RemoteNavigationPolicy.isAllowedRemoteOrigin( + candidateUrl = "https://verify.self.xyz:8443/tunnel/tour/1", + baseUrl = "https://verify.self.xyz", + ), + ) + } + + @Test + fun `isAllowedRemoteOrigin rejects blank or invalid base url`() { + assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", "")) + assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", "http://verify.self.xyz")) + assertFalse(RemoteNavigationPolicy.isAllowedRemoteOrigin("https://verify.self.xyz", "https:///missing-host")) + } + + @Test + fun `buildRemoteEntryUrl appends hosted entry path and query`() { + assertEquals( + "https://verify.self.xyz/tunnel/tour/1?foo=bar", + RemoteNavigationPolicy.buildRemoteEntryUrl("https://verify.self.xyz/", "foo=bar"), + ) + } + + @Test + fun `buildRemoteEntryUrl rejects non https and blank host`() { + assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl("http://verify.self.xyz", "")) + assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl("https:///missing-host", "")) + assertNull(RemoteNavigationPolicy.buildRemoteEntryUrl(" ", "")) + } +} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift index 0019d2398..980238811 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdk.swift @@ -62,7 +62,12 @@ final class SelfSdkViewController: UIViewController { router.register(handler: CryptoHandler()) router.register(handler: lifecycleHandler) - let host = SelfWebViewHost(router: router, isDebugMode: config.isDebugMode) + let host = SelfWebViewHost( + router: router, + isDebugMode: config.isDebugMode, + remoteWebAppBaseURL: config.remoteWebAppBaseURL, + remoteWebAppIntegritySha256: config.remoteWebAppIntegritySha256 + ) self.webViewHost = host let webView = host.createWebView() diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift index 12f130d5c..5ab2f9d4a 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/API/SelfSdkConfig.swift @@ -19,6 +19,8 @@ public struct SelfSdkConfig { public let chainID: Int? public let userDefinedData: String? public let selfDefinedData: String? + public let remoteWebAppBaseURL: URL? + public let remoteWebAppIntegritySha256: String? public let secureStorageProvider: SecureStorageProvider public init( @@ -38,6 +40,8 @@ public struct SelfSdkConfig { chainID: Int? = nil, userDefinedData: String? = nil, selfDefinedData: String? = nil, + remoteWebAppBaseURL: URL? = nil, + remoteWebAppIntegritySha256: String? = nil, secureStorageProvider: SecureStorageProvider ) { self.verificationId = verificationId @@ -56,6 +60,8 @@ public struct SelfSdkConfig { self.chainID = chainID self.userDefinedData = userDefinedData self.selfDefinedData = selfDefinedData + self.remoteWebAppBaseURL = remoteWebAppBaseURL + self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256 self.secureStorageProvider = secureStorageProvider } diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift new file mode 100644 index 000000000..4ef3b072e --- /dev/null +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/BundledAssetPathResolver.swift @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +enum BundledAssetPathResolver { + static func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? { + let rawPath = requestURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let normalizedPath = rawPath.removingPercentEncoding ?? rawPath + let relativePath = normalizedPath.isEmpty || !normalizedPath.contains(".") ? "index.html" : normalizedPath + let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardized + let standardizedRootURL = rootURL.standardizedFileURL + let rootPath = standardizedRootURL.path.hasSuffix("/") + ? standardizedRootURL.path + : standardizedRootURL.path + "/" + guard fileURL.path.hasPrefix(rootPath) || fileURL.path == standardizedRootURL.path else { + return nil + } + return fileURL + } +} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift new file mode 100644 index 000000000..5df5795ad --- /dev/null +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteContentIntegrity.swift @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +// nil mimeType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate. +enum RemoteContentIntegrity { + static func normalizeSha256(_ value: String) -> String { + var normalized = value.lowercased() + if normalized.hasPrefix("sha256-") { + normalized.removeFirst("sha256-".count) + } + if normalized.hasPrefix("0x") { + normalized.removeFirst(2) + } + return normalized + } + + static func isAcceptableMimeType(_ mimeType: String?) -> Bool { + mimeType == nil || mimeType == "text/html" + } +} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift new file mode 100644 index 000000000..480e748dd --- /dev/null +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/RemoteNavigationPolicy.swift @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation + +enum RemoteNavigationPolicy { + private static let bundledScheme = SelfWebViewHost.bundledScheme + private static let bundledHost = SelfWebViewHost.bundledHost + private static let allowedSubframeHosts: Set = ["verify.didit.me"] + + static func makeEntryURL(baseURL: URL?, queryParams: String) -> URL? { + guard let baseURL else { return nil } + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + let basePath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + components.path = "/" + [basePath, "tunnel", "tour", "1"].filter { !$0.isEmpty }.joined(separator: "/") + components.percentEncodedQuery = queryParams.isEmpty ? nil : queryParams + return components.url + } + + static func isAllowedMainFrameNavigation( + url: URL, + remoteWebAppBaseURL: URL?, + isDebugMode: Bool + ) -> Bool { + if isDebugMode { + return url.absoluteString.hasPrefix("http://localhost:5173") + } + + if url.scheme == bundledScheme, url.host == bundledHost { + return true + } + + guard let remoteWebAppBaseURL, + remoteWebAppBaseURL.scheme == "https", + remoteWebAppBaseURL.host != nil else { + return false + } + + return url.scheme == remoteWebAppBaseURL.scheme && + url.host == remoteWebAppBaseURL.host && + resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL) + } + + static func isAllowedSubframeNavigation( + url: URL, + remoteWebAppBaseURL: URL?, + isDebugMode: Bool + ) -> Bool { + if isAllowedMainFrameNavigation(url: url, remoteWebAppBaseURL: remoteWebAppBaseURL, isDebugMode: isDebugMode) { + return true + } + guard url.scheme == "https", let host = url.host else { + return false + } + return allowedSubframeHosts.contains(host) + } + + static func resolvedPort(for url: URL) -> Int { + if let port = url.port { + return port + } + switch url.scheme { + case "https": + return 443 + case "http": + return 80 + default: + return -1 + } + } +} diff --git a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift index 4c68f651f..846763e31 100644 --- a/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift +++ b/packages/native-shell-ios/Sources/SelfNativeShell/WebView/SelfWebViewHost.swift @@ -1,17 +1,31 @@ // 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 var webView: WKWebView? private let router: MessageRouter private let isDebugMode: Bool + private let remoteWebAppBaseURL: URL? + private let remoteWebAppIntegritySha256: String? - init(router: MessageRouter, isDebugMode: Bool = false) { + init( + router: MessageRouter, + isDebugMode: Bool = false, + remoteWebAppBaseURL: URL? = nil, + remoteWebAppIntegritySha256: String? = nil + ) { self.router = router self.isDebugMode = isDebugMode + self.remoteWebAppBaseURL = remoteWebAppBaseURL + self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256 super.init() } @@ -23,11 +37,13 @@ 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 webView.isOpaque = false webView.backgroundColor = .clear + webView.navigationDelegate = self if #available(iOS 16.4, *) { webView.isInspectable = isDebugMode @@ -41,15 +57,19 @@ final class SelfWebViewHost: NSObject { func loadContent(queryParams: String) { guard let webView = webView else { return } - var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1" - if !queryParams.isEmpty { - urlString += "?\(queryParams)" - } - guard let url = URL(string: urlString) else { - NSLog("SelfWebViewHost: Failed to construct URL from: %@", urlString) + if isDebugMode { + let debugBase = URL(string: "http://localhost:5173") + if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) { + webView.load(URLRequest(url: url)) + } return } - webView.load(URLRequest(url: url)) + + if let bundledURL = makeBundledEntryURL(queryParams: queryParams) { + webView.load(URLRequest(url: bundledURL)) + } + + loadVerifiedRemoteContent(queryParams: queryParams) } func evaluateJs(_ js: String) { @@ -57,6 +77,91 @@ final class SelfWebViewHost: NSObject { self?.webView?.evaluateJavaScript(js, completionHandler: nil) } } + + 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, + remoteWebAppBaseURL: remoteWebAppBaseURL, + isDebugMode: isDebugMode + ) + } + + private func resolvedPort(for url: URL) -> Int { + RemoteNavigationPolicy.resolvedPort(for: url) + } + + private func isAllowedSubframeNavigation(url: URL) -> Bool { + RemoteNavigationPolicy.isAllowedSubframeNavigation( + url: url, + remoteWebAppBaseURL: remoteWebAppBaseURL, + isDebugMode: isDebugMode + ) + } } extension SelfWebViewHost: WKNavigationDelegate { @@ -65,14 +170,17 @@ extension SelfWebViewHost: WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - guard let url = navigationAction.request.url, let host = url.host else { + guard let url = navigationAction.request.url else { 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) + + if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame { + decisionHandler(isAllowedSubframeNavigation(url: url) ? .allow : .cancel) + return + } + + decisionHandler(isAllowedNavigation(url: url) ? .allow : .cancel) } } @@ -89,6 +197,81 @@ extension SelfWebViewHost: WKScriptMessageHandler { } } +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 + } + + 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) + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + + private func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? { + BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL) + } + + 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" + } + } + + private func textEncodingName(for pathExtension: String) -> String? { + switch pathExtension.lowercased() { + case "html", "js", "css", "json", "svg": + return "utf-8" + default: + return nil + } + } +} + // Prevents WKWebView retain cycle with WKScriptMessageHandler private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler { private weak var handler: WKScriptMessageHandler? diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift new file mode 100644 index 000000000..51062e0fa --- /dev/null +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/BundledAssetPathResolverTests.swift @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import XCTest +@testable import SelfNativeShell + +final class BundledAssetPathResolverTests: XCTestCase { + func testResolvesIndexForDirectoryStyleRequest() { + let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) + let requestURL = URL(string: "self-sdk://app/tunnel/tour/1")! + + XCTAssertEqual( + BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path, + rootURL.appendingPathComponent("index.html").path + ) + } + + func testResolvesStaticAssetInsideBundle() { + let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) + let requestURL = URL(string: "self-sdk://app/assets/app.js")! + + XCTAssertEqual( + BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path, + rootURL.appendingPathComponent("assets/app.js").path + ) + } + + func testRejectsPathTraversal() { + let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) + let requestURL = URL(string: "self-sdk://app/../../secret.txt")! + + XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)) + } + + func testRejectsPercentEncodedTraversal() { + let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true) + let requestURL = URL(string: "self-sdk://app/%2E%2E/%2E%2E/secret.txt")! + + XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)) + } +} diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift new file mode 100644 index 000000000..0a5cc4087 --- /dev/null +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteContentIntegrityTests.swift @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import XCTest +@testable import SelfNativeShell + +final class RemoteContentIntegrityTests: XCTestCase { + + // MARK: - normalizeSha256 + + func testStripsShaPrefixOnly() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"), + "abcdef1234567890" + ) + } + + func testStrips0xPrefixOnly() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"), + "abcdef1234567890" + ) + } + + func testStripsSha256Then0xPrefix() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"), + "abcdef" + ) + } + + func testLowercasesInput() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("ABCDEF"), + "abcdef" + ) + } + + func testRawHexPassesThrough() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("abcdef1234567890"), + "abcdef1234567890" + ) + } + + func testDoesNotStripInteriorSha256() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("absha256-cd"), + "absha256-cd" + ) + } + + func testDoesNotStripInterior0x() { + XCTAssertEqual( + RemoteContentIntegrity.normalizeSha256("ab0xcd"), + "ab0xcd" + ) + } + + // MARK: - isAcceptableMimeType + + func testAcceptsNilMimeType() { + XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType(nil)) + } + + func testAcceptsTextHtml() { + XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType("text/html")) + } + + func testRejectsApplicationJavascript() { + XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/javascript")) + } + + func testRejectsApplicationJson() { + XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/json")) + } + + func testRejectsTextPlain() { + XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("text/plain")) + } + + func testRejectsEmptyString() { + XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("")) + } +} diff --git a/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift new file mode 100644 index 000000000..c1001be10 --- /dev/null +++ b/packages/native-shell-ios/Tests/SelfNativeShellTests/RemoteNavigationPolicyTests.swift @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BUSL-1.1 + +import Foundation +import XCTest +@testable import SelfNativeShell + +final class RemoteNavigationPolicyTests: XCTestCase { + func testResolvedPortDefaultsHTTPS() { + XCTAssertEqual( + RemoteNavigationPolicy.resolvedPort(for: URL(string: "https://verify.self.xyz")!), + 443 + ) + } + + func testResolvedPortDefaultsHTTP() { + XCTAssertEqual( + RemoteNavigationPolicy.resolvedPort(for: URL(string: "http://localhost")!), + 80 + ) + } + + func testResolvedPortPreservesExplicitPort() { + XCTAssertEqual( + RemoteNavigationPolicy.resolvedPort(for: URL(string: "https://verify.self.xyz:8443")!), + 8443 + ) + } + + func testMainFrameAllowsMatchingRemoteOriginWithDefaultPort() { + XCTAssertTrue( + RemoteNavigationPolicy.isAllowedMainFrameNavigation( + url: URL(string: "https://verify.self.xyz/tunnel/tour/1")!, + remoteWebAppBaseURL: URL(string: "https://verify.self.xyz:443"), + isDebugMode: false + ) + ) + } + + func testMainFrameRejectsDifferentRemotePort() { + XCTAssertFalse( + RemoteNavigationPolicy.isAllowedMainFrameNavigation( + url: URL(string: "https://verify.self.xyz:8443/tunnel/tour/1")!, + remoteWebAppBaseURL: URL(string: "https://verify.self.xyz"), + isDebugMode: false + ) + ) + } + + func testMainFrameRejectsInvalidRemoteBaseURL() { + XCTAssertFalse( + RemoteNavigationPolicy.isAllowedMainFrameNavigation( + url: URL(string: "https://verify.self.xyz/tunnel/tour/1")!, + remoteWebAppBaseURL: URL(string: "http://verify.self.xyz"), + isDebugMode: false + ) + ) + } + + func testSubframeAllowsDiditHostOnlyOverHTTPS() { + XCTAssertTrue( + RemoteNavigationPolicy.isAllowedSubframeNavigation( + url: URL(string: "https://verify.didit.me/flow")!, + remoteWebAppBaseURL: nil, + isDebugMode: false + ) + ) + XCTAssertFalse( + RemoteNavigationPolicy.isAllowedSubframeNavigation( + url: URL(string: "http://verify.didit.me/flow")!, + remoteWebAppBaseURL: nil, + isDebugMode: false + ) + ) + } + + func testMakeEntryURLAppendsHostedPathAndQuery() { + XCTAssertEqual( + RemoteNavigationPolicy.makeEntryURL( + baseURL: URL(string: "https://verify.self.xyz/"), + queryParams: "foo=bar" + )?.absoluteString, + "https://verify.self.xyz/tunnel/tour/1?foo=bar" + ) + } +} diff --git a/packages/webview-app/src/test/sri.test.ts b/packages/webview-app/src/test/sri.test.ts new file mode 100644 index 000000000..6427813b9 --- /dev/null +++ b/packages/webview-app/src/test/sri.test.ts @@ -0,0 +1,70 @@ +// 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 { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const DIST_DIR = join(__dirname, '../../dist'); + +function readDistFile(path: string): Buffer { + return readFileSync(join(DIST_DIR, path)); +} + +function sha384(content: Buffer): string { + return createHash('sha384').update(content).digest('base64'); +} + +describe('subresource integrity', () => { + let html: string; + + try { + html = readFileSync(join(DIST_DIR, 'index.html'), 'utf-8'); + } catch { + // Build output not available — skip gracefully + html = ''; + } + + it('index.html contains integrity attributes on script tags', () => { + if (!html) return; // no build output + const scripts = html.match(/]*src="[^"]*"[^>]*>/g) ?? []; + expect(scripts.length).toBeGreaterThan(0); + for (const tag of scripts) { + expect(tag).toContain('integrity="sha384-'); + } + }); + + it('index.html contains integrity attributes on stylesheet links', () => { + if (!html) return; + const links = html.match(/]*rel="stylesheet"[^>]*>/g) ?? []; + expect(links.length).toBeGreaterThan(0); + for (const tag of links) { + expect(tag).toContain('integrity="sha384-'); + } + }); + + it('script integrity hashes match file contents', () => { + if (!html) return; + const matches = [...html.matchAll(/src="([^"]+)"[^>]*integrity="sha384-([^"]+)"/g)]; + expect(matches.length).toBeGreaterThan(0); + for (const [, src, expectedHash] of matches) { + const fileContent = readDistFile(src); + const actualHash = sha384(fileContent); + expect(actualHash).toBe(expectedHash); + } + }); + + it('stylesheet integrity hashes match file contents', () => { + if (!html) return; + const matches = [...html.matchAll(/href="([^"]+)"[^>]*integrity="sha384-([^"]+)"/g)]; + expect(matches.length).toBeGreaterThan(0); + for (const [, href, expectedHash] of matches) { + const fileContent = readDistFile(href); + const actualHash = sha384(fileContent); + expect(actualHash).toBe(expectedHash); + } + }); +}); diff --git a/packages/webview-app/vite.config.ts b/packages/webview-app/vite.config.ts index fdbe50e0c..b31be1b04 100644 --- a/packages/webview-app/vite.config.ts +++ b/packages/webview-app/vite.config.ts @@ -2,14 +2,65 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { defineConfig } from 'vite'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; +/** + * Adds Subresource Integrity (SRI) hashes to script/link tags in HTML output. + * Runs after all files are written to disk so hashes match the final bytes + * (including sourcemap comments appended by Rollup). + */ +function subresourceIntegrity(): Plugin { + let outDir = 'dist'; + return { + name: 'subresource-integrity', + enforce: 'post', + apply: 'build', + configResolved(config) { + outDir = config.build.outDir; + }, + closeBundle() { + const htmlPath = join(outDir, 'index.html'); + let html: string; + try { + html = readFileSync(htmlPath, 'utf-8'); + } catch { + return; + } + + const updated = html.replace( + /(<(?:script|link)[^>]*(?:src|href)="([^"]+)"[^>]*)(\/?>)/g, + (match, before, assetPath, close) => { + if (match.includes('integrity=')) return match; + + const filePath = join(outDir, assetPath); + try { + const content = readFileSync(filePath); + const hash = createHash('sha384').update(content).digest('base64'); + return `${before} integrity="sha384-${hash}"${close}`; + } catch { + return match; + } + }, + ); + + if (updated !== html) { + writeFileSync(htmlPath, updated); + } + }, + }; +} + export default defineConfig({ base: '/', plugins: [ react(), + subresourceIntegrity(), { name: 'serve-public-files', configureServer(server) {