diff --git a/platforms/android/README.md b/platforms/android/README.md index 5076f52ff..30c4d85c5 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -258,6 +258,8 @@ ShopifyCheckoutKit.configure { | `sheet` | `CheckoutSheetOptions()` | Customize native sheet presentation such as snap points, dismissal behavior, corner radius, title alignment, toolbar elevation, close icon styling, and the optional drag handle. | | `logLevel` | `LogLevel.WARN` | SDK logging verbosity. Use `LogLevel.DEBUG` during integration. | | `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. | +| `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. | +| `onMessageRejected` | `null` | Observes messages rejected by origin validation. | ### Color schemes @@ -362,6 +364,30 @@ Override `checkout_web_view_title` in your app resources: val configuration = ShopifyCheckoutKit.getConfiguration() ``` +### Incoming message origin validation + +Native checkout accepts messages from every origin by default. To restrict messages, configure one +or more exact origins or wildcard subdomains. The checkout URL's origin and `shop.app` remain +trusted automatically. + +```kotlin +ShopifyCheckoutKit.configure { + it.allowedMessageOrigins = setOf( + "https://checkout.example.com", + "https://*.example.org", + ) + it.onMessageRejected = { rejection -> + reportRejectedOrigin(rejection.origin, rejection.reason) + } +} +``` + +Exact entries accept an optional trailing slash, but not credentials, paths, queries, or fragments. +For example, `https://checkout.example.com/` is accepted, while +`https://user@checkout.example.com` and `https://checkout.example.com/path` are ignored. Wildcard +entries require the scheme and match subdomains only; `https://*.example.org` does not match +`https://example.org`. Use `"*"` to explicitly disable origin validation. + ## Checkout lifecycle Use `onFail` and `onDismiss` for checkout outcomes handled by your app. Use `CheckoutProtocol.Client` for typed checkout state, including completion. These descriptors wrap checkout protocol messages defined in the [protocol schema](../../protocol/services/shopping/embedded.openrpc.json). diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index eb75e6517..ec58624f8 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -488,16 +488,22 @@ public final class com/shopify/checkoutkit/Configuration { public final fun component4 ()Lcom/shopify/checkoutkit/LogLevel; public final fun component5 ()Lcom/shopify/checkoutkit/Preloading; public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/util/Set; + public final fun component8 ()Lkotlin/jvm/functions/Function1; public fun equals (Ljava/lang/Object;)Z + public final fun getAllowedMessageOrigins ()Ljava/util/Set; public final fun getAppearance ()Lcom/shopify/checkoutkit/CheckoutAppearance; public final fun getLogLevel ()Lcom/shopify/checkoutkit/LogLevel; + public final fun getOnMessageRejected ()Lkotlin/jvm/functions/Function1; public final fun getPlatform ()Lcom/shopify/checkoutkit/Platform; public final fun getPreloading ()Lcom/shopify/checkoutkit/Preloading; public final fun getSheet ()Lcom/shopify/checkoutkit/CheckoutSheetOptions; public final fun getTitle ()Ljava/lang/String; public fun hashCode ()I + public final fun setAllowedMessageOrigins (Ljava/util/Set;)V public final fun setAppearance (Lcom/shopify/checkoutkit/CheckoutAppearance;)V public final fun setLogLevel (Lcom/shopify/checkoutkit/LogLevel;)V + public final fun setOnMessageRejected (Lkotlin/jvm/functions/Function1;)V public final fun setPlatform (Lcom/shopify/checkoutkit/Platform;)V public final fun setPreloading (Lcom/shopify/checkoutkit/Preloading;)V public final fun setSheet (Lcom/shopify/checkoutkit/CheckoutSheetOptions;)V @@ -655,6 +661,21 @@ public final class com/shopify/checkoutkit/Preloading { public fun toString ()Ljava/lang/String; } +public final class com/shopify/checkoutkit/RejectedMessage { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/shopify/checkoutkit/RejectedMessage; + public static synthetic fun copy$default (Lcom/shopify/checkoutkit/RejectedMessage;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/shopify/checkoutkit/RejectedMessage; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getOrigin ()Ljava/lang/String; + public final fun getReason ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/shopify/checkoutkit/ShopifyCheckout : android/widget/FrameLayout { public static final field Companion Lcom/shopify/checkoutkit/ShopifyCheckout$Companion; public fun (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 90bb66624..9d4dba4a1 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt @@ -29,6 +29,7 @@ import android.webkit.WebViewClient.ERROR_HOST_LOOKUP import android.webkit.WebViewClient.ERROR_TIMEOUT import androidx.activity.ComponentActivity import androidx.annotation.MainThread +import androidx.core.net.toUri import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import com.shopify.checkoutkit.ShopifyCheckoutKit.log @@ -61,6 +62,10 @@ internal class CheckoutWebView private constructor( private var didRetryCheckoutRequest = false private val touchHandler = CheckoutWebViewTouchHandler() + /** Origin of the loaded checkout URL, trusted as a safe default for incoming-message validation. */ + internal var checkoutOrigin: String? = null + private set + init { configureWebView(::listener) webViewClient = CheckoutWebViewClient() @@ -116,12 +121,16 @@ internal class CheckoutWebView private constructor( } fun loadCheckout(url: String, isPreload: Boolean = false) { + if (!OriginAllowlist.isHttpsUrl(url)) { + throw insecureCheckoutUrlException(url) + } log.d( LOG_TAG, "Loading checkout with url ${url.redactedUrlForLogging()}. IsPreload: $isPreload." ) loadComplete = false isPreloadRequest = isPreload + checkoutOrigin = OriginAllowlist.originFromUrl(url) Handler(Looper.getMainLooper()).post { val request = CheckoutRequest( url = CheckoutUrlDecorator.decorate(url), @@ -265,20 +274,39 @@ internal class CheckoutWebView private constructor( override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? - ): Boolean { - val uri = request?.url - if (uri == null || (!uri.isContactLink() && !uri.isDeepLink())) return false - - when (val result = ExternalUriLauncher.launch(context, uri)) { - is ExternalUriLauncher.Result.Launched -> - log.d(LOG_TAG, "Deep link intercepted: ${uri.redactedForLogging()} — allowed") - is ExternalUriLauncher.Result.Rejected -> - log.d( - LOG_TAG, - "Deep link intercepted: ${uri.redactedForLogging()} — rejected (${result.reason})" + ): Boolean = handleNavigation(request?.url, request?.isForMainFrame == true) + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean = + handleNavigation(url?.toUri(), isMainFrame = true) + + private fun handleNavigation(uri: Uri?, isMainFrame: Boolean): Boolean { + return when { + uri == null -> false + uri.isContactLink() || uri.isDeepLink() -> { + when (val result = ExternalUriLauncher.launch(context, uri)) { + is ExternalUriLauncher.Result.Launched -> + log.d(LOG_TAG, "Deep link intercepted: ${uri.redactedForLogging()} — allowed") + is ExternalUriLauncher.Result.Rejected -> + log.d( + LOG_TAG, + "Deep link intercepted: ${uri.redactedForLogging()} — rejected (${result.reason})" + ) + } + true + } + isMainFrame && uri.scheme != Scheme.HTTPS -> { + val error = insecureCheckoutUrlException(uri.toString()) + preloadCache.evict( + this@CheckoutWebView, + PreloadState.Failed(PreloadState.FailureReason.NavigationFailed), ) + resetCheckoutRequestRetryState() + listener.onCheckoutViewFailedWithError(error) + true + } + else -> false } - return true } private fun handleClientError( @@ -352,25 +380,32 @@ internal class CheckoutWebView private constructor( webMessageTransport: WebMessageTransport = WebMessageListenerTransport, listener: PreloadStateListener? = null, ): CheckoutPreload? { - if (!ShopifyCheckoutKit.configuration.preloading.enabled) { - return null - } - - return try { - runOnUiThreadBlocking(activity) { - val view = CheckoutWebView(activity, webMessageTransport) - val handle = CheckoutPreload(preloadCache) - view.apply { - loadCheckout(url, isPreload = true) - log.d(LOG_TAG, "Pausing preloaded WebView.") - onPause() + return when { + !ShopifyCheckoutKit.configuration.preloading.enabled -> null + !OriginAllowlist.isHttpsUrl(url) -> { + runOnUiThreadBlocking(activity) { + val handle = CheckoutPreload(preloadCache) + preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) + handle.listener = listener + handle } - preloadCache.store(PreloadKey.forUrl(url), view, activity) - handle.listener = listener - handle } - } catch (_: UnsupportedWebViewException) { - null + else -> try { + runOnUiThreadBlocking(activity) { + val view = CheckoutWebView(activity, webMessageTransport) + val handle = CheckoutPreload(preloadCache) + view.apply { + loadCheckout(url, isPreload = true) + log.d(LOG_TAG, "Pausing preloaded WebView.") + onPause() + } + preloadCache.store(PreloadKey.forUrl(url), view, activity) + handle.listener = listener + handle + } + } catch (_: UnsupportedWebViewException) { + null + } } } @@ -383,6 +418,9 @@ internal class CheckoutWebView private constructor( check(Looper.myLooper() == Looper.getMainLooper()) { "Checkout views must be created on the main thread." } + if (!OriginAllowlist.isHttpsUrl(url)) { + throw insecureCheckoutUrlException(url) + } val cachedView = if (ShopifyCheckoutKit.configuration.preloading.enabled) { preloadCache.take(PreloadKey.forUrl(url)) } else { @@ -446,6 +484,11 @@ internal class CheckoutWebView private constructor( private const val LOG_TAG = "CheckoutWebView" +private fun insecureCheckoutUrlException(url: String): CheckoutException = CheckoutException( + code = CheckoutErrorCode.SDK_ERROR, + message = "Checkout requires an HTTPS URL: ${url.redactedUrlForLogging()}", +) + internal class CheckoutWebViewTouchHandler { private var lastTouchRawY = 0f private var touchGestureOwnerResolved = false diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt index d84696d6c..632fc92d7 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt @@ -6,6 +6,15 @@ import android.content.Context * Configuration for Shopify Checkout Kit. * * Allows specifying the colors, sheet presentation, and runtime behavior that should be used for checkout. + * + * @property allowedMessageOrigins Extra origins allowed to post incoming checkout-protocol messages. + * Native checkout is open by default: leaving this empty trusts every origin. Once populated, the + * effective allowlist is these origins plus the cart URL origin and `shop.app` (including its + * subdomains). Entries may be exact origins (`https://example.com`), scheme-qualified wildcard + * subdomains (`https://*.example.com`), or `"*"` to explicitly trust every origin. + * @property onMessageRejected Invoked when an incoming message is dropped by origin validation. When + * null, drops are logged at debug level. Treat the payload as untrusted — it was dropped precisely + * because its origin was not in the allowlist. */ @ConsistentCopyVisibility public data class Configuration internal constructor( @@ -15,6 +24,21 @@ public data class Configuration internal constructor( var logLevel: LogLevel = LogLevel.WARN, var preloading: Preloading = Preloading(), var title: String? = null, + var allowedMessageOrigins: Set = emptySet(), + var onMessageRejected: ((RejectedMessage) -> Unit)? = null, +) + +/** + * Details of an incoming message dropped by origin validation. + * + * @property origin Origin the dropped message was posted from. + * @property message Raw message payload. Treat as untrusted. + * @property reason Human-readable reason the message was dropped. + */ +public data class RejectedMessage( + val origin: String, + val message: String, + val reason: String, ) /** diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt index 91bf3542b..a35a70065 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/EmbeddedCheckoutProtocolBridge.kt @@ -36,6 +36,7 @@ internal const val ECP_LOG_TAG = "ECP" * Messages arrive through [webMessageTransport] and responses are sent back via * `window.EmbeddedCheckoutProtocol.postMessage(responseString)`. */ +@Suppress("TooManyFunctions") internal class EmbeddedCheckoutProtocolBridge( private val view: CheckoutWebView, private val webMessageTransport: WebMessageTransport, @@ -73,8 +74,8 @@ internal class EmbeddedCheckoutProtocolBridge( webView = view, jsObjectName = INTERFACE_NAME, allowedOriginRules = ALLOWED_MESSAGE_ORIGIN_RULES, - ) { message, isMainFrame -> - receiveWebMessage(message, isMainFrame) + ) { message, sourceOrigin, isMainFrame -> + receiveWebMessage(message, sourceOrigin, isMainFrame) } if (!attached) throw UnsupportedWebViewException() isTransportAttached = true @@ -91,15 +92,49 @@ internal class EmbeddedCheckoutProtocolBridge( this.client = client } - private fun receiveWebMessage(message: String, isMainFrame: Boolean) { + private fun receiveWebMessage(message: String, sourceOrigin: String, isMainFrame: Boolean) { if (!isMainFrame) { log.d(LOG_TAG, "Ignoring ECP WebMessage from a child frame.") return } + if (!isOriginAllowed(sourceOrigin)) { + rejectMessage(sourceOrigin, message) + return + } + receiveMessage(message) } + /** + * Origin validation runs here (not at the WebView layer) so [ALLOWED_MESSAGE_ORIGIN_RULES] can + * stay `"*"` and deliver every message with its verified origin. That lets the kit surface + * drops through [Configuration.onMessageRejected] instead of the WebView silently discarding + * them. + */ + private fun isOriginAllowed(sourceOrigin: String): Boolean { + val configuration = ShopifyCheckoutKit.configuration + val patterns = OriginAllowlist.effectivePatterns( + checkoutOrigin = view.checkoutOrigin, + configured = configuration.allowedMessageOrigins, + ) + return OriginAllowlist.isAllowed(sourceOrigin, patterns) + } + + private fun rejectMessage(sourceOrigin: String, message: String) { + val reason = "origin \"$sourceOrigin\" is not in the allowlist" + val callback = ShopifyCheckoutKit.configuration.onMessageRejected + if (callback != null) { + try { + callback(RejectedMessage(origin = sourceOrigin, message = message, reason = reason)) + } catch (error: Exception) { + log.e(LOG_TAG, "onMessageRejected callback threw", error) + } + } else { + log.d(LOG_TAG, "Dropped ECP WebMessage: $reason") + } + } + internal fun receiveMessage(message: String) { protocolMessageExecutor.execute { processMessage(message) diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt new file mode 100644 index 000000000..74182bf1d --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -0,0 +1,125 @@ +package com.shopify.checkoutkit + +import java.net.URI + +/** + * Matches incoming-message origins against a configured allowlist. + * + * Native checkout is **open by default**: an empty merchant allowlist trusts every origin. Once a + * merchant configures origins, the effective allowlist is those origins plus two safe defaults — + * the cart URL origin and `shop.app` (including its subdomains). `"*"` is an explicit escape hatch + * that trusts every origin. + * + * Allowlist entries are origin patterns: + * - `"*"` trusts every origin. + * - A scheme-qualified wildcard subdomain trusts proper subdomains of its suffix (not the apex), + * and requires the scheme and effective port to match. + * - Anything else must be an exact origin (`scheme://host[:port]`) with no credentials, path, + * query, or fragment. A trailing slash is accepted. + */ +internal object OriginAllowlist { + const val SHOP_APP_ORIGIN: String = "https://shop.app" + + private const val WILDCARD_ALL = "*" + private const val HTTP_DEFAULT_PORT = 80 + private const val HTTPS_DEFAULT_PORT = 443 + + sealed interface OriginPattern { + data class Exact(val origin: Origin) : OriginPattern + data class Wildcard(val scheme: String, val suffix: String, val port: Int?) : OriginPattern + } + + data class Origin(val scheme: String, val host: String, val port: Int?) + + private val WILDCARD_PATTERN = Regex("""^(https?)://\*\.([^/:]+)(?::(\d+))?/?$""", RegexOption.IGNORE_CASE) + private val SHOP_APP_PATTERNS = listOf( + OriginPattern.Exact(requireNotNull(parseOrigin(SHOP_APP_ORIGIN, exact = true))), + requireNotNull(parsePattern("https://*.shop.app")), + ) + + /** + * Returns the effective allowlist patterns for the given [checkoutOrigin] (cart URL origin) and + * merchant-[configured] origins, or `null` when validation is disabled — either because no + * origins are configured (native open-by-default) or because `"*"` is present. + */ + fun effectivePatterns(checkoutOrigin: String?, configured: Set): List? { + if (configured.isEmpty() || configured.contains(WILDCARD_ALL)) return null + + return buildList { + checkoutOrigin?.let { parseOrigin(it, exact = true) }?.let { add(OriginPattern.Exact(it)) } + addAll(SHOP_APP_PATTERNS) + configured.mapNotNullTo(this) { parsePattern(it) } + } + } + + /** Returns whether [origin] satisfies any of [patterns]. A `null` [patterns] trusts everything. */ + fun isAllowed(origin: String, patterns: List?): Boolean = + patterns?.let { allowedPatterns -> + parseOrigin(origin, exact = true)?.let { target -> + allowedPatterns.any { pattern -> + when (pattern) { + is OriginPattern.Exact -> pattern.origin == target + is OriginPattern.Wildcard -> + pattern.scheme == target.scheme && + pattern.port == target.port && + target.host != pattern.suffix && + target.host.endsWith(".${pattern.suffix}") + } + } + } ?: false + } ?: true + + /** Extracts the `scheme://host[:port]` origin from a full URL, or `null` when it cannot parse. */ + fun originFromUrl(url: String): String? = parseOrigin(url, exact = false)?.serialize() + + fun isHttpsUrl(url: String): Boolean = parseOrigin(url, exact = false)?.scheme == Scheme.HTTPS + + private fun parsePattern(pattern: String): OriginPattern? = when { + pattern == WILDCARD_ALL -> null + !pattern.contains("*") -> parseOrigin(pattern, exact = true)?.let(OriginPattern::Exact) + else -> WILDCARD_PATTERN.matchEntire(pattern)?.destructured?.let { (scheme, suffix, port) -> + val normalizedScheme = scheme.lowercase() + val parsedPort = when { + port.isEmpty() -> null + else -> port.toIntOrNull() ?: return null + } + OriginPattern.Wildcard( + scheme = normalizedScheme, + suffix = suffix.lowercase(), + port = normalizedPort(normalizedScheme, parsedPort), + ) + } + } + + private fun parseOrigin(value: String, exact: Boolean): Origin? = + try { + val uri = URI(value.trim()) + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.removeSurrounding("[", "]")?.lowercase() ?: return null + val hasSupportedScheme = scheme == Scheme.HTTP || scheme == Scheme.HTTPS + val hasOnlyOriginComponents = !exact || hasOnlyOriginComponents(uri) + if (!hasSupportedScheme || uri.userInfo != null || !hasOnlyOriginComponents) { + null + } else { + Origin(scheme, host, normalizedPort(scheme, uri.port.takeUnless { it == -1 })) + } + } catch (_: Exception) { + null + } + + private fun hasOnlyOriginComponents(uri: URI): Boolean { + val hasNoPath = uri.path.isNullOrEmpty() || uri.path == "/" + return hasNoPath && uri.query == null && uri.fragment == null + } + + private fun normalizedPort(scheme: String, port: Int?): Int? = when { + scheme == Scheme.HTTPS && port == HTTPS_DEFAULT_PORT -> null + scheme == Scheme.HTTP && port == HTTP_DEFAULT_PORT -> null + else -> port + } + + private fun Origin.serialize(): String { + val serializedHost = if (host.contains(':')) "[$host]" else host + return "$scheme://$serializedHost${port?.let { ":$it" }.orEmpty()}" + } +} diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt index f25cff1e6..dff4a8089 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckout.kt @@ -130,6 +130,15 @@ public class ShopifyCheckout @MainThread internal constructor( } } } + } catch (checkoutError: CheckoutException) { + initializationError = checkoutError + if (hostConfiguration.reportInitializationFailure) { + Handler(Looper.getMainLooper()).post { + if (!destroyed) { + hostConfiguration.onFailure(checkoutError) + } + } + } } } diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt index bbcea2855..def3a98c4 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/WebMessageTransport.kt @@ -26,7 +26,7 @@ internal interface WebMessageTransport { webView: WebView, jsObjectName: String, allowedOriginRules: Set, - onMessage: (message: String, isMainFrame: Boolean) -> Unit, + onMessage: (message: String, sourceOrigin: String, isMainFrame: Boolean) -> Unit, ): Boolean /** Removes the listener registered under [jsObjectName]. */ @@ -38,7 +38,7 @@ internal interface WebMessageTransport { /** Adapts AndroidX WebMessages to the text-only callback exposed by [WebMessageTransport]. */ internal class WebMessageListenerAdapter( - private val onMessage: (message: String, isMainFrame: Boolean) -> Unit, + private val onMessage: (message: String, sourceOrigin: String, isMainFrame: Boolean) -> Unit, ) : WebViewCompat.WebMessageListener { override fun onPostMessage( view: WebView, @@ -58,7 +58,7 @@ internal class WebMessageListenerAdapter( return } - onMessage(data, isMainFrame) + onMessage(data, sourceOrigin.toString(), isMainFrame) } } @@ -74,7 +74,7 @@ internal object WebMessageListenerTransport : WebMessageTransport { webView: WebView, jsObjectName: String, allowedOriginRules: Set, - onMessage: (message: String, isMainFrame: Boolean) -> Unit, + onMessage: (message: String, sourceOrigin: String, isMainFrame: Boolean) -> Unit, ): Boolean { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return false diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt index 52938915d..cd749deac 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt @@ -10,6 +10,7 @@ import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient.FileChooserParams +import android.webkit.WebResourceRequest import android.webkit.WebView import android.widget.FrameLayout import androidx.activity.ComponentActivity @@ -27,9 +28,11 @@ import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) +@Suppress("LargeClass") class CheckoutWebViewTest { private lateinit var activity: ComponentActivity @@ -56,6 +59,8 @@ class CheckoutWebViewTest { it.preloading = initialConfiguration.preloading it.platform = initialConfiguration.platform it.logLevel = initialConfiguration.logLevel + it.allowedMessageOrigins = initialConfiguration.allowedMessageOrigins + it.onMessageRejected = initialConfiguration.onMessageRejected } } @@ -273,6 +278,95 @@ class CheckoutWebViewTest { } } + @Test + fun `web message from any origin is accepted when no allowlist is configured`() { + val view = checkoutWebView(activity) + view.loadCheckout("https://checkout.shopify.com/cart/123") + ShadowLooper.shadowMainLooper().runToEndOfTasks() + var received = false + view.setClient( + CheckoutProtocol.Client().on(CheckoutProtocol.messagesChange) { received = true }, + ) + + webMessageTransport.dispatchMessage(ecMessagesChangeMessage(), sourceOrigin = "https://evil.example.com") + + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(received).isTrue() + } + } + + @Test + fun `web message from the cart URL origin is accepted when an allowlist is configured`() { + ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") } + assertWebMessageReceivedFrom("https://checkout.shopify.com") + } + + @Test + fun `web message from a shop app subdomain is accepted when an allowlist is configured`() { + ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") } + assertWebMessageReceivedFrom("https://checkout.shop.app") + } + + @Test + fun `web message from a configured origin is accepted`() { + ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") } + assertWebMessageReceivedFrom("https://allowed.example.com") + } + + @Test + fun `web message from an untrusted origin is dropped and reported when an allowlist is configured`() { + val rejected = mutableListOf() + ShopifyCheckoutKit.configure { + it.allowedMessageOrigins = setOf("https://allowed.example.com") + it.onMessageRejected = { rejected.add(it) } + } + val view = checkoutWebView(activity) + view.loadCheckout("https://checkout.shopify.com/cart/123") + ShadowLooper.shadowMainLooper().runToEndOfTasks() + var received = false + var sentinelReceived = false + view.setClient( + CheckoutProtocol.Client() + .on(CheckoutProtocol.messagesChange) { received = true } + .on(CheckoutProtocol.start) { sentinelReceived = true }, + ) + + webMessageTransport.dispatchMessage(ecMessagesChangeMessage(), sourceOrigin = "https://evil.example.com") + webMessageTransport.dispatchMessage(ecStartMessage(), sourceOrigin = "https://checkout.shopify.com") + + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(sentinelReceived).isTrue() + } + assertThat(received).isFalse() + assertThat(rejected).singleElement().satisfies({ + assertThat(it.origin).isEqualTo("https://evil.example.com") + assertThat(it.reason).contains("not in the allowlist") + }) + } + + @Test + fun `callback failures do not interrupt later trusted messages`() { + ShopifyCheckoutKit.configure { + it.allowedMessageOrigins = setOf("https://allowed.example.com") + it.onMessageRejected = { error("callback failed") } + } + val view = checkoutWebView(activity) + view.loadCheckout("https://checkout.shopify.com/cart/123") + ShadowLooper.shadowMainLooper().runToEndOfTasks() + var received = false + view.setClient(CheckoutProtocol.Client().on(CheckoutProtocol.start) { received = true }) + + webMessageTransport.dispatchMessage(ecMessagesChangeMessage(), sourceOrigin = "https://evil.example.com") + webMessageTransport.dispatchMessage(ecStartMessage(), sourceOrigin = "https://checkout.shopify.com") + + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(received).isTrue() + } + } + // endregion @Test @@ -318,6 +412,67 @@ class CheckoutWebViewTest { assertThat(shadowOf(view).lastLoadedUrl).contains("ec_version=${CheckoutProtocol.SPEC_VERSION}") } + @Test + fun `loadCheckout rejects non HTTPS URLs`() { + val view = checkoutWebView(activity) + + assertThatThrownBy { view.loadCheckout("http://checkout.shopify.com/cart/123") } + .isInstanceOf(CheckoutException::class.java) + .hasMessageContaining("requires an HTTPS URL") + } + + @Test + fun `checkoutViewFor rejects non HTTPS URLs before constructing a WebView`() { + assertThatThrownBy { checkoutViewFor("http://checkout.shopify.com/cart/123") } + .isInstanceOf(CheckoutException::class.java) + .hasMessageContaining("requires an HTTPS URL") + + assertThat(webMessageTransport.attachCount).isZero() + } + + @Test + fun `main frame redirect to non HTTPS URL is blocked and reported`() { + val view = checkoutWebView(activity) + val listener = mock(CheckoutWebViewListener::class.java) + val request = mock(WebResourceRequest::class.java) + whenever(request.url).thenReturn(Uri.parse("http://checkout.shopify.com/cart/123")) + whenever(request.isForMainFrame).thenReturn(true) + view.setListener(listener) + + val blocked = view.CheckoutWebViewClient().shouldOverrideUrlLoading(view, request) + + assertThat(blocked).isTrue() + verify(listener).onCheckoutViewFailedWithError( + org.mockito.kotlin.check { + assertThat(it).isInstanceOf(CheckoutException::class.java) + assertThat(it.code).isEqualTo(CheckoutErrorCode.SDK_ERROR) + assertThat(it.message).contains("requires an HTTPS URL") + } + ) + } + + @Suppress("DEPRECATION") + @Test + fun `legacy redirect callback blocks non HTTPS URLs`() { + val view = checkoutWebView(activity) + val listener = mock(CheckoutWebViewListener::class.java) + view.setListener(listener) + + val blocked = view.CheckoutWebViewClient().shouldOverrideUrlLoading( + view, + "http://checkout.shopify.com/cart/123", + ) + + assertThat(blocked).isTrue() + verify(listener).onCheckoutViewFailedWithError( + org.mockito.kotlin.check { + assertThat(it).isInstanceOf(CheckoutException::class.java) + assertThat(it.code).isEqualTo(CheckoutErrorCode.SDK_ERROR) + assertThat(it.message).contains("requires an HTTPS URL") + } + ) + } + @Test fun `loadCheckout preserves existing query params alongside ec_version`() { val view = checkoutWebView(activity) @@ -361,6 +516,43 @@ class CheckoutWebViewTest { assertThat(shadow.wasOnPauseCalled()).isTrue() } + @Test + fun `preload reports navigation failure for non HTTPS URL`() { + val preload = CheckoutWebView.preload( + "http://checkout.shopify.com/cart/123", + activity, + webMessageTransport, + ) + + assertThat(preload?.state) + .isEqualTo(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + } + + @Test + fun `preload evicts cached view on main thread for non HTTPS URL from background thread`() { + preload("https://checkout.shopify.com/cart/123") + ShadowLooper.shadowMainLooper().runToEndOfTasks() + val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!! + + val result = CompletableFuture.supplyAsync { + CheckoutWebView.preload( + "http://checkout.shopify.com/cart/456", + activity, + webMessageTransport, + ) + } + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(result.isDone).isTrue() + } + + assertThat(result.get()!!.state) + .isEqualTo(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) + assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNull() + assertThat(shadowOf(cachedView).wasDestroyCalled()).isTrue() + } + @Test fun `present retains cached checkout view for matching URL`() { preload("https://checkout.shopify.com/cart/123") @@ -606,6 +798,27 @@ class CheckoutWebViewTest { assertThat(webMessageTransport.sentMessages).isEmpty() } + /** + * Loads a checkout (so its cart origin becomes a trusted default), dispatches a protocol message + * from [origin], and asserts it reaches the client. + */ + private fun assertWebMessageReceivedFrom(origin: String) { + val view = checkoutWebView(activity) + view.loadCheckout("https://checkout.shopify.com/cart/123") + ShadowLooper.shadowMainLooper().runToEndOfTasks() + var received = false + view.setClient( + CheckoutProtocol.Client().on(CheckoutProtocol.messagesChange) { received = true }, + ) + + webMessageTransport.dispatchMessage(ecMessagesChangeMessage(), sourceOrigin = origin) + + await().pollInSameThread().atMost(2, TimeUnit.SECONDS).untilAsserted { + ShadowLooper.shadowMainLooper().runToEndOfTasks() + assertThat(received).isTrue() + } + } + private fun ecStartMessage(): String = """{"jsonrpc":"2.0","method":"ec.start","params":{"checkout":${checkoutJson()}}}""" diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt index c7b79bd1a..0a8c8b939 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/FakeWebMessageTransport.kt @@ -21,7 +21,7 @@ internal class FakeWebMessageTransport( val message: String, ) - private var onMessage: ((message: String, isMainFrame: Boolean) -> Unit)? = null + private var onMessage: ((message: String, sourceOrigin: String, isMainFrame: Boolean) -> Unit)? = null var lastAttachment: Attachment? = null private set var lastAttachAttempt: Attachment? = null @@ -38,7 +38,7 @@ internal class FakeWebMessageTransport( webView: WebView, jsObjectName: String, allowedOriginRules: Set, - onMessage: (message: String, isMainFrame: Boolean) -> Unit, + onMessage: (message: String, sourceOrigin: String, isMainFrame: Boolean) -> Unit, ): Boolean { attachCount += 1 val attachment = Attachment(webView, jsObjectName, allowedOriginRules.toSet()) @@ -62,8 +62,9 @@ internal class FakeWebMessageTransport( fun dispatchMessage( message: String, + sourceOrigin: String = "https://checkout.shopify.com", isMainFrame: Boolean = true, ) { - checkNotNull(onMessage)(message, isMainFrame) + checkNotNull(onMessage)(message, sourceOrigin, isMainFrame) } } diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt new file mode 100644 index 000000000..db7e073be --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt @@ -0,0 +1,136 @@ +package com.shopify.checkoutkit + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class OriginAllowlistTest { + + private val cartOrigin = "https://checkout.shopify.com" + + @Test + fun `no configured origins trusts every origin`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, emptySet()) + + assertThat(patterns).isNull() + assertThat(OriginAllowlist.isAllowed("https://evil.example.com", patterns)).isTrue() + } + + @Test + fun `wildcard escape hatch trusts every origin`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("*")) + + assertThat(patterns).isNull() + assertThat(OriginAllowlist.isAllowed("https://evil.example.com", patterns)).isTrue() + } + + @Test + fun `configured allowlist trusts the cart origin and shop app by default`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("https://allowed.example.com")) + + assertThat(OriginAllowlist.isAllowed(cartOrigin, patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://shop.app", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://checkout.shop.app", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://allowed.example.com", patterns)).isTrue() + } + + @Test + fun `configured allowlist rejects untrusted origins`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("https://allowed.example.com")) + + assertThat(OriginAllowlist.isAllowed("https://evil.example.com", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("http://checkout.shopify.com", patterns)).isFalse() + } + + @Test + fun `wildcard subdomain pattern matches proper subdomains only`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("https://*.example.com")) + + assertThat(OriginAllowlist.isAllowed("https://fr.example.com", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://a.b.example.com", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://example.com", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("https://notexample.com", patterns)).isFalse() + } + + @Test + fun `wildcard subdomain pattern requires matching scheme and port`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("https://*.example.com:8443")) + + assertThat(OriginAllowlist.isAllowed("https://fr.example.com:8443", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://fr.example.com", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("http://fr.example.com:8443", patterns)).isFalse() + } + + @Test + fun `default ports are normalized for exact and wildcard patterns`() { + val patterns = OriginAllowlist.effectivePatterns( + "https://checkout.shopify.com:443", + setOf("https://allowed.example.com:443", "https://*.example.org:443"), + ) + + assertThat(OriginAllowlist.isAllowed("https://checkout.shopify.com", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://allowed.example.com", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://sub.example.org", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://sub.example.org:8443", patterns)).isFalse() + } + + @Test + fun `IPv6 origins support default and explicit ports`() { + val patterns = OriginAllowlist.effectivePatterns( + "https://[2001:db8::1]:443", + setOf("https://[2001:db8::2]:8443"), + ) + + assertThat(OriginAllowlist.isAllowed("https://[2001:db8::1]", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://[2001:db8::2]:8443", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://[2001:db8::2]", patterns)).isFalse() + assertThat(OriginAllowlist.originFromUrl("https://[2001:db8::1]:443/cart")) + .isEqualTo("https://[2001:db8::1]") + } + + @Test + fun `invalid configured patterns are ignored`() { + val patterns = OriginAllowlist.effectivePatterns( + cartOrigin, + setOf( + "not a url", + "https://user@allowed.example.com", + "https://allowed.example.com/path", + "https://allowed.example.com?query=value", + "https://allowed.example.com#fragment", + "https://*.example.com:999999999999", + ), + ) + + assertThat(OriginAllowlist.isAllowed("https://not a url", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("https://allowed.example.com", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("https://sub.example.com", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed(cartOrigin, patterns)).isTrue() + } + + @Test + fun `exact and wildcard patterns accept a trailing slash`() { + val patterns = OriginAllowlist.effectivePatterns( + cartOrigin, + setOf("https://allowed.example.com/", "https://*.example.org/"), + ) + + assertThat(OriginAllowlist.isAllowed("https://allowed.example.com", patterns)).isTrue() + assertThat(OriginAllowlist.isAllowed("https://sub.example.org", patterns)).isTrue() + } + + @Test + fun `opaque origins are rejected`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("https://allowed.example.com")) + + assertThat(OriginAllowlist.isAllowed("null", patterns)).isFalse() + } + + @Test + fun `originFromUrl extracts the origin from a full URL`() { + assertThat(OriginAllowlist.originFromUrl("https://checkout.shopify.com/cart/123?foo=bar")) + .isEqualTo("https://checkout.shopify.com") + assertThat(OriginAllowlist.originFromUrl("https://checkout.shopify.com:8443/cart")) + .isEqualTo("https://checkout.shopify.com:8443") + assertThat(OriginAllowlist.originFromUrl("not a url")).isNull() + } +} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt index f21305efc..5632bc43b 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/ShopifyCheckoutTest.kt @@ -151,6 +151,30 @@ class ShopifyCheckoutTest { view.destroy() } + @Test + fun `non HTTPS checkout reports failure after construction and creates inert view`() { + var receivedError: CheckoutException? = null + + val view = ShopifyCheckout.create( + context = activity, + checkoutUrl = "http://checkout.shopify.com/cart/123", + webMessageTransport = webMessageTransport, + ) { + onFail { receivedError = it } + } + ShadowLooper.shadowMainLooper().runToEndOfTasks() + + assertThat(receivedError) + .isInstanceOf(CheckoutException::class.java) + .extracting("message") + .asString() + .contains("requires an HTTPS URL") + assertThat(view.findViewById(R.id.checkoutKitContainer).children.none { it is CheckoutWebView }) + .isTrue() + + view.destroy() + } + @Test fun `destroy suppresses pending initialization failure`() { webMessageTransport.supported = false diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/WebMessageTransportTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/WebMessageTransportTest.kt index 2e6642bd9..b3a0a31db 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/WebMessageTransportTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/WebMessageTransportTest.kt @@ -23,24 +23,32 @@ class WebMessageTransportTest { private val webView = mock() @Test - fun `listener adapter forwards string payload and frame metadata`() { + fun `listener adapter forwards string payload, origin, and frame metadata`() { var receivedMessage: String? = null + var receivedOrigin: String? = null var receivedFromMainFrame: Boolean? = null - val listener = WebMessageListenerAdapter { message, isMainFrame -> + val listener = WebMessageListenerAdapter { message, sourceOrigin, isMainFrame -> receivedMessage = message + receivedOrigin = sourceOrigin receivedFromMainFrame = isMainFrame } - dispatchMessage(listener, WebMessageCompat("hello"), isMainFrame = false) + dispatchMessage( + listener, + WebMessageCompat("hello"), + sourceOrigin = Uri.parse("https://checkout.shopify.com"), + isMainFrame = false, + ) assertThat(receivedMessage).isEqualTo("hello") + assertThat(receivedOrigin).isEqualTo("https://checkout.shopify.com") assertThat(receivedFromMainFrame).isFalse() } @Test fun `listener adapter ignores null string payload`() { var received = false - val listener = WebMessageListenerAdapter { _, _ -> received = true } + val listener = WebMessageListenerAdapter { _, _, _ -> received = true } val message: String? = null dispatchMessage(listener, WebMessageCompat(message)) @@ -51,7 +59,7 @@ class WebMessageTransportTest { @Test fun `listener adapter ignores non-string payload`() { var received = false - val listener = WebMessageListenerAdapter { _, _ -> received = true } + val listener = WebMessageListenerAdapter { _, _, _ -> received = true } dispatchMessage(listener, WebMessageCompat(byteArrayOf(1))) @@ -97,12 +105,13 @@ class WebMessageTransportTest { private fun dispatchMessage( listener: WebMessageListenerAdapter, message: WebMessageCompat, + sourceOrigin: Uri = Uri.EMPTY, isMainFrame: Boolean = true, ) { listener.onPostMessage( webView, message, - Uri.EMPTY, + sourceOrigin, isMainFrame, mock(), )