mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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 <tranquil_flow@protonmail.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Array<Uri>>? = 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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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(" ", ""))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<String> = ["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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
70
packages/webview-app/src/test/sri.test.ts
Normal file
70
packages/webview-app/src/test/sri.test.ts
Normal file
@@ -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(/<script[^>]*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(/<link[^>]*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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user