From 7d35fc7959af5e2cbaea8c97d65d5b5827eb2bec Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 12:10:31 -0400 Subject: [PATCH 1/7] feat: incoming message origin validation for android --- .../shopify/checkoutkit/CheckoutWebView.kt | 5 + .../com/shopify/checkoutkit/Configuration.kt | 24 +++++ .../EmbeddedCheckoutProtocolBridge.kt | 36 ++++++- .../shopify/checkoutkit/OriginAllowlist.kt | 94 +++++++++++++++++++ .../checkoutkit/WebMessageTransport.kt | 8 +- .../checkoutkit/CheckoutWebViewTest.kt | 91 ++++++++++++++++++ .../checkoutkit/FakeWebMessageTransport.kt | 7 +- .../checkoutkit/OriginAllowlistTest.kt | 86 +++++++++++++++++ .../checkoutkit/WebMessageTransportTest.kt | 21 +++-- 9 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt 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..6a323c919 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 @@ -61,6 +61,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() @@ -122,6 +126,7 @@ internal class CheckoutWebView private constructor( ) loadComplete = false isPreloadRequest = isPreload + checkoutOrigin = OriginAllowlist.originFromUrl(url) Handler(Looper.getMainLooper()).post { val request = CheckoutRequest( url = CheckoutUrlDecorator.decorate(url), 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..7ab07aebd 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`), 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..b44501673 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 @@ -73,8 +73,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 +91,45 @@ 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) { + callback(RejectedMessage(origin = sourceOrigin, message = message, reason = reason)) + } 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..06f79ca34 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -0,0 +1,94 @@ +package com.shopify.checkoutkit + +/** + * 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 wildcard subdomain such as `*.example.com` trusts proper subdomains of `example.com` (not the + * apex), and requires the scheme and port to match. + * - Anything else is treated as an exact origin (`scheme://host[:port]`). + */ +internal object OriginAllowlist { + const val SHOP_APP_ORIGIN: String = "https://shop.app" + + private const val WILDCARD_ALL = "*" + private val SHOP_APP_PATTERNS = listOf(SHOP_APP_ORIGIN, "https://*.shop.app") + + private val ORIGIN_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://([^/:]+)(?::(\d+))?$""") + private val WILDCARD_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://\*\.([^/:]+)(?::(\d+))?$""") + + private data class Origin(val scheme: String, val host: String, val port: String?) + + /** + * 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 + + val patterns = mutableListOf() + if (!checkoutOrigin.isNullOrBlank()) patterns.add(checkoutOrigin) + patterns.addAll(SHOP_APP_PATTERNS) + configured.filterTo(patterns) { isValidPattern(it) } + return patterns + } + + /** Returns whether [origin] satisfies any of [patterns]. A `null` [patterns] trusts everything. */ + fun isAllowed(origin: String, patterns: List?): Boolean { + if (patterns == null) return true + return patterns.any { matches(it, origin) } + } + + /** Extracts the `scheme://host[:port]` origin from a full URL, or `null` when it cannot parse. */ + fun originFromUrl(url: String): String? = try { + val uri = java.net.URI(url) + val scheme = uri.scheme + val host = uri.host + when { + scheme == null || host == null -> null + uri.port != -1 -> "$scheme://$host:${uri.port}" + else -> "$scheme://$host" + } + } catch (_: Exception) { + null + } + + private fun isValidPattern(pattern: String): Boolean = when { + pattern == WILDCARD_ALL -> true + pattern.contains("*") -> WILDCARD_PATTERN.matches(pattern) + else -> parseOrigin(pattern) != null + } + + private fun matches(pattern: String, origin: String): Boolean { + val target = parseOrigin(origin) + return when { + pattern == WILDCARD_ALL -> true + target == null -> false + pattern.contains("*") -> matchesWildcard(pattern, target) + else -> parseOrigin(pattern) == target + } + } + + private fun matchesWildcard(pattern: String, target: Origin): Boolean { + val match = WILDCARD_PATTERN.matchEntire(pattern) ?: return false + val (scheme, suffix, port) = match.destructured + val suffixHost = suffix.lowercase() + return target.scheme.equals(scheme, ignoreCase = true) && + port.ifEmpty { null } == target.port && + target.host != suffixHost && + target.host.endsWith(".$suffixHost") + } + + private fun parseOrigin(value: String): Origin? { + val match = ORIGIN_PATTERN.matchEntire(value.trim()) ?: return null + val (scheme, host, port) = match.destructured + return Origin(scheme.lowercase(), host.lowercase(), port.ifEmpty { null }) + } +} 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..49d970c0f 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 @@ -56,6 +56,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 +275,74 @@ 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") + }) + } + // endregion @Test @@ -606,6 +676,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..eaf8f4dc0 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt @@ -0,0 +1,86 @@ +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 `invalid configured patterns are ignored`() { + val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("not a url")) + + assertThat(OriginAllowlist.isAllowed("https://not a url", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed(cartOrigin, 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/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(), ) From b7668ffef101b2ada955a7e3276d7a6c3580d666 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 13:11:54 +0200 Subject: [PATCH 2/7] fix(android): harden checkout origin validation --- platforms/android/lib/api/lib.api | 21 ++++++ .../shopify/checkoutkit/CheckoutWebView.kt | 51 +++++++++++---- .../com/shopify/checkoutkit/Configuration.kt | 4 +- .../EmbeddedCheckoutProtocolBridge.kt | 6 +- .../shopify/checkoutkit/OriginAllowlist.kt | 48 ++++++++------ .../shopify/checkoutkit/ShopifyCheckout.kt | 9 +++ .../checkoutkit/CheckoutWebViewTest.kt | 64 +++++++++++++++++++ .../checkoutkit/OriginAllowlistTest.kt | 27 ++++++++ .../checkoutkit/ShopifyCheckoutTest.kt | 24 +++++++ 9 files changed, 220 insertions(+), 34 deletions(-) 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 6a323c919..c8322e2a6 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 @@ -120,6 +120,9 @@ internal class CheckoutWebView private constructor( } fun loadCheckout(url: String, isPreload: Boolean = false) { + if (!OriginAllowlist.isHttpsUrl(url)) { + throw insecureCheckoutUrlError(url) + } log.d( LOG_TAG, "Loading checkout with url ${url.redactedUrlForLogging()}. IsPreload: $isPreload." @@ -272,18 +275,33 @@ internal class CheckoutWebView private constructor( 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})" - ) + if (uri == null) return false + + if (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})" + ) + } + return true } - return true + + if (request.isForMainFrame && uri.scheme != Scheme.HTTPS) { + val error = insecureCheckoutUrlError(uri.toString()) + preloadCache.evict( + this@CheckoutWebView, + PreloadState.Failed(PreloadState.FailureReason.NavigationFailed), + ) + resetCheckoutRequestRetryState() + listener.onCheckoutViewFailedWithError(error) + return true + } + + return false } private fun handleClientError( @@ -361,6 +379,13 @@ internal class CheckoutWebView private constructor( return null } + if (!OriginAllowlist.isHttpsUrl(url)) { + val handle = CheckoutPreload(preloadCache) + preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) + handle.listener = listener + return handle + } + return try { runOnUiThreadBlocking(activity) { val view = CheckoutWebView(activity, webMessageTransport) @@ -451,6 +476,10 @@ internal class CheckoutWebView private constructor( private const val LOG_TAG = "CheckoutWebView" +private fun insecureCheckoutUrlError(url: String): CheckoutKitException = CheckoutKitException( + errorDescription = "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 7ab07aebd..351046cbe 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 @@ -10,8 +10,8 @@ import android.content.Context * @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`), wildcard subdomains - * (`https://*.example.com`), or `"*"` to explicitly trust every origin. + * subdomains). Entries may be exact origins (`https://example.com`), scheme-qualified wildcard + * subdomains, 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. 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 b44501673..fbcbb4c05 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 @@ -124,7 +124,11 @@ internal class EmbeddedCheckoutProtocolBridge( val reason = "origin \"$sourceOrigin\" is not in the allowlist" val callback = ShopifyCheckoutKit.configuration.onMessageRejected if (callback != null) { - callback(RejectedMessage(origin = sourceOrigin, message = message, reason = reason)) + 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") } 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 index 06f79ca34..77d427e12 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -10,8 +10,8 @@ package com.shopify.checkoutkit * * Allowlist entries are origin patterns: * - `"*"` trusts every origin. - * - A wildcard subdomain such as `*.example.com` trusts proper subdomains of `example.com` (not the - * apex), and requires the scheme and port to match. + * - 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 is treated as an exact origin (`scheme://host[:port]`). */ internal object OriginAllowlist { @@ -20,10 +20,9 @@ internal object OriginAllowlist { private const val WILDCARD_ALL = "*" private val SHOP_APP_PATTERNS = listOf(SHOP_APP_ORIGIN, "https://*.shop.app") - private val ORIGIN_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://([^/:]+)(?::(\d+))?$""") private val WILDCARD_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://\*\.([^/:]+)(?::(\d+))?$""") - private data class Origin(val scheme: String, val host: String, val port: String?) + private data class Origin(val scheme: String, val host: String, val port: Int?) /** * Returns the effective allowlist patterns for the given [checkoutOrigin] (cart URL origin) and @@ -47,18 +46,9 @@ internal object OriginAllowlist { } /** Extracts the `scheme://host[:port]` origin from a full URL, or `null` when it cannot parse. */ - fun originFromUrl(url: String): String? = try { - val uri = java.net.URI(url) - val scheme = uri.scheme - val host = uri.host - when { - scheme == null || host == null -> null - uri.port != -1 -> "$scheme://$host:${uri.port}" - else -> "$scheme://$host" - } - } catch (_: Exception) { - null - } + fun originFromUrl(url: String): String? = parseOrigin(url)?.serialize() + + fun isHttpsUrl(url: String): Boolean = parseOrigin(url)?.scheme == "https" private fun isValidPattern(pattern: String): Boolean = when { pattern == WILDCARD_ALL -> true @@ -80,15 +70,33 @@ internal object OriginAllowlist { val match = WILDCARD_PATTERN.matchEntire(pattern) ?: return false val (scheme, suffix, port) = match.destructured val suffixHost = suffix.lowercase() + val normalizedScheme = scheme.lowercase() return target.scheme.equals(scheme, ignoreCase = true) && - port.ifEmpty { null } == target.port && + normalizedPort(normalizedScheme, port.toIntOrNull()) == target.port && target.host != suffixHost && target.host.endsWith(".$suffixHost") } private fun parseOrigin(value: String): Origin? { - val match = ORIGIN_PATTERN.matchEntire(value.trim()) ?: return null - val (scheme, host, port) = match.destructured - return Origin(scheme.lowercase(), host.lowercase(), port.ifEmpty { null }) + return try { + val uri = java.net.URI(value.trim()) + val scheme = uri.scheme?.lowercase() ?: return null + val host = uri.host?.removePrefix("[")?.removeSuffix("]")?.lowercase() ?: return null + if (uri.userInfo != null) return null + Origin(scheme, host, normalizedPort(scheme, uri.port.takeUnless { it == -1 })) + } catch (_: Exception) { + null + } + } + + private fun normalizedPort(scheme: String, port: Int?): Int? = when { + scheme == "https" && port == 443 -> null + scheme == "http" && port == 80 -> 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/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/CheckoutWebViewTest.kt index 49d970c0f..2dcdeafd9 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 @@ -9,6 +9,7 @@ import android.view.View.VISIBLE import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.ValueCallback +import android.webkit.WebResourceRequest import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebView import android.widget.FrameLayout @@ -343,6 +344,27 @@ class CheckoutWebViewTest { }) } + @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 @@ -388,6 +410,35 @@ 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(CheckoutKitException::class.java) + .hasMessageContaining("requires an HTTPS URL") + } + + @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(CheckoutKitException::class.java) + assertThat(it.message).contains("requires an HTTPS URL") + } + ) + } + @Test fun `loadCheckout preserves existing query params alongside ec_version`() { val view = checkoutWebView(activity) @@ -431,6 +482,19 @@ 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 `present retains cached checkout view for matching URL`() { preload("https://checkout.shopify.com/cart/123") 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 index eaf8f4dc0..313c0fbae 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt @@ -60,6 +60,33 @@ class OriginAllowlistTest { 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")) 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..fc330ef28 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(CheckoutKitException::class.java) + .extracting("errorDescription") + .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 From 9a41bb2e0003cfe42b64de55a545050d97eade5b Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 14:04:40 +0200 Subject: [PATCH 3/7] Fix Android lint violations --- .../shopify/checkoutkit/CheckoutWebView.kt | 91 +++++++++---------- .../shopify/checkoutkit/OriginAllowlist.kt | 17 ++-- .../checkoutkit/CheckoutWebViewTest.kt | 2 +- 3 files changed, 56 insertions(+), 54 deletions(-) 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 c8322e2a6..a7e916648 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 @@ -275,33 +275,32 @@ internal class CheckoutWebView private constructor( request: WebResourceRequest? ): Boolean { val uri = request?.url - if (uri == null) return false - - if (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})" - ) + 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 } - return true - } - - if (request.isForMainFrame && uri.scheme != Scheme.HTTPS) { - val error = insecureCheckoutUrlError(uri.toString()) - preloadCache.evict( - this@CheckoutWebView, - PreloadState.Failed(PreloadState.FailureReason.NavigationFailed), - ) - resetCheckoutRequestRetryState() - listener.onCheckoutViewFailedWithError(error) - return true + request.isForMainFrame && uri.scheme != Scheme.HTTPS -> { + val error = insecureCheckoutUrlError(uri.toString()) + preloadCache.evict( + this@CheckoutWebView, + PreloadState.Failed(PreloadState.FailureReason.NavigationFailed), + ) + resetCheckoutRequestRetryState() + listener.onCheckoutViewFailedWithError(error) + true + } + else -> false } - - return false } private fun handleClientError( @@ -375,32 +374,30 @@ internal class CheckoutWebView private constructor( webMessageTransport: WebMessageTransport = WebMessageListenerTransport, listener: PreloadStateListener? = null, ): CheckoutPreload? { - if (!ShopifyCheckoutKit.configuration.preloading.enabled) { - return null - } - - if (!OriginAllowlist.isHttpsUrl(url)) { - val handle = CheckoutPreload(preloadCache) - preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) - handle.listener = listener - return handle - } - - return try { - runOnUiThreadBlocking(activity) { - val view = CheckoutWebView(activity, webMessageTransport) + return when { + !ShopifyCheckoutKit.configuration.preloading.enabled -> null + !OriginAllowlist.isHttpsUrl(url) -> { 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) + preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) 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 + } } } 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 index 77d427e12..93887f794 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -18,6 +18,8 @@ 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 private val SHOP_APP_PATTERNS = listOf(SHOP_APP_ORIGIN, "https://*.shop.app") private val WILDCARD_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://\*\.([^/:]+)(?::(\d+))?$""") @@ -80,18 +82,21 @@ internal object OriginAllowlist { private fun parseOrigin(value: String): Origin? { return try { val uri = java.net.URI(value.trim()) - val scheme = uri.scheme?.lowercase() ?: return null - val host = uri.host?.removePrefix("[")?.removeSuffix("]")?.lowercase() ?: return null - if (uri.userInfo != null) return null - Origin(scheme, host, normalizedPort(scheme, uri.port.takeUnless { it == -1 })) + val scheme = uri.scheme?.lowercase() + val host = uri.host?.removePrefix("[")?.removeSuffix("]")?.lowercase() + if (scheme == null || host == null || uri.userInfo != null) { + null + } else { + Origin(scheme, host, normalizedPort(scheme, uri.port.takeUnless { it == -1 })) + } } catch (_: Exception) { null } } private fun normalizedPort(scheme: String, port: Int?): Int? = when { - scheme == "https" && port == 443 -> null - scheme == "http" && port == 80 -> null + scheme == "https" && port == HTTPS_DEFAULT_PORT -> null + scheme == "http" && port == HTTP_DEFAULT_PORT -> null else -> port } 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 2dcdeafd9..874cb131e 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 @@ -9,8 +9,8 @@ import android.view.View.VISIBLE import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.ValueCallback -import android.webkit.WebResourceRequest import android.webkit.WebChromeClient.FileChooserParams +import android.webkit.WebResourceRequest import android.webkit.WebView import android.widget.FrameLayout import androidx.activity.ComponentActivity From 55ac8cc597e265b1db814ba1a0351376b2da5772 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 15:33:28 +0200 Subject: [PATCH 4/7] fix(android): evict preloads on the main thread --- .../shopify/checkoutkit/CheckoutWebView.kt | 10 +++++--- .../checkoutkit/CheckoutWebViewTest.kt | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) 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 a7e916648..1c074ca7b 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 @@ -377,10 +377,12 @@ internal class CheckoutWebView private constructor( return when { !ShopifyCheckoutKit.configuration.preloading.enabled -> null !OriginAllowlist.isHttpsUrl(url) -> { - val handle = CheckoutPreload(preloadCache) - preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) - handle.listener = listener - handle + runOnUiThreadBlocking(activity) { + val handle = CheckoutPreload(preloadCache) + preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed)) + handle.listener = listener + handle + } } else -> try { runOnUiThreadBlocking(activity) { 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 874cb131e..650ad98f0 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 @@ -28,6 +28,7 @@ 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) @@ -495,6 +496,30 @@ class CheckoutWebViewTest { 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") From 18f108217c1128e685d2cfc5dab419ddb1b2b5fa Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 13:46:06 +0200 Subject: [PATCH 5/7] fix(android): address origin validation review --- platforms/android/README.md | 26 +++++ .../shopify/checkoutkit/CheckoutWebView.kt | 21 +++- .../com/shopify/checkoutkit/Configuration.kt | 2 +- .../shopify/checkoutkit/OriginAllowlist.kt | 110 ++++++++++-------- .../checkoutkit/CheckoutWebViewTest.kt | 31 +++++ .../checkoutkit/OriginAllowlistTest.kt | 23 +++- 6 files changed, 157 insertions(+), 56 deletions(-) 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/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/CheckoutWebView.kt index 1c074ca7b..45296bfd0 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 @@ -121,7 +122,7 @@ internal class CheckoutWebView private constructor( fun loadCheckout(url: String, isPreload: Boolean = false) { if (!OriginAllowlist.isHttpsUrl(url)) { - throw insecureCheckoutUrlError(url) + throw insecureCheckoutUrlException(url) } log.d( LOG_TAG, @@ -273,8 +274,13 @@ internal class CheckoutWebView private constructor( override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? - ): Boolean { - val uri = request?.url + ): 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() -> { @@ -289,8 +295,8 @@ internal class CheckoutWebView private constructor( } true } - request.isForMainFrame && uri.scheme != Scheme.HTTPS -> { - val error = insecureCheckoutUrlError(uri.toString()) + isMainFrame && uri.scheme != Scheme.HTTPS -> { + val error = insecureCheckoutUrlException(uri.toString()) preloadCache.evict( this@CheckoutWebView, PreloadState.Failed(PreloadState.FailureReason.NavigationFailed), @@ -412,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 { @@ -475,7 +484,7 @@ internal class CheckoutWebView private constructor( private const val LOG_TAG = "CheckoutWebView" -private fun insecureCheckoutUrlError(url: String): CheckoutKitException = CheckoutKitException( +private fun insecureCheckoutUrlException(url: String): CheckoutKitException = CheckoutKitException( errorDescription = "Checkout requires an HTTPS URL: ${url.redactedUrlForLogging()}", ) 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 351046cbe..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 @@ -11,7 +11,7 @@ import android.content.Context * 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, or `"*"` to explicitly trust every origin. + * 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. 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 index 93887f794..ef50837a7 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -1,5 +1,7 @@ package com.shopify.checkoutkit +import java.net.URI + /** * Matches incoming-message origins against a configured allowlist. * @@ -12,7 +14,8 @@ package com.shopify.checkoutkit * - `"*"` 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 is treated as an exact origin (`scheme://host[:port]`). + * - 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" @@ -20,71 +23,78 @@ internal object OriginAllowlist { private const val WILDCARD_ALL = "*" private const val HTTP_DEFAULT_PORT = 80 private const val HTTPS_DEFAULT_PORT = 443 - private val SHOP_APP_PATTERNS = listOf(SHOP_APP_ORIGIN, "https://*.shop.app") - private val WILDCARD_PATTERN = Regex("""^([a-zA-Z][\w+.\-]*)://\*\.([^/:]+)(?::(\d+))?$""") + 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 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? { + fun effectivePatterns(checkoutOrigin: String?, configured: Set): List? { if (configured.isEmpty() || configured.contains(WILDCARD_ALL)) return null - val patterns = mutableListOf() - if (!checkoutOrigin.isNullOrBlank()) patterns.add(checkoutOrigin) - patterns.addAll(SHOP_APP_PATTERNS) - configured.filterTo(patterns) { isValidPattern(it) } - return patterns + 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 { - if (patterns == null) return true - return patterns.any { matches(it, origin) } - } + 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)?.serialize() + fun originFromUrl(url: String): String? = parseOrigin(url, exact = false)?.serialize() - fun isHttpsUrl(url: String): Boolean = parseOrigin(url)?.scheme == "https" + fun isHttpsUrl(url: String): Boolean = parseOrigin(url, exact = false)?.scheme == Scheme.HTTPS - private fun isValidPattern(pattern: String): Boolean = when { - pattern == WILDCARD_ALL -> true - pattern.contains("*") -> WILDCARD_PATTERN.matches(pattern) - else -> parseOrigin(pattern) != null - } - - private fun matches(pattern: String, origin: String): Boolean { - val target = parseOrigin(origin) - return when { - pattern == WILDCARD_ALL -> true - target == null -> false - pattern.contains("*") -> matchesWildcard(pattern, target) - else -> parseOrigin(pattern) == target + 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() + OriginPattern.Wildcard( + scheme = normalizedScheme, + suffix = suffix.lowercase(), + port = normalizedPort(normalizedScheme, port.toIntOrNull()), + ) } } - private fun matchesWildcard(pattern: String, target: Origin): Boolean { - val match = WILDCARD_PATTERN.matchEntire(pattern) ?: return false - val (scheme, suffix, port) = match.destructured - val suffixHost = suffix.lowercase() - val normalizedScheme = scheme.lowercase() - return target.scheme.equals(scheme, ignoreCase = true) && - normalizedPort(normalizedScheme, port.toIntOrNull()) == target.port && - target.host != suffixHost && - target.host.endsWith(".$suffixHost") - } - - private fun parseOrigin(value: String): Origin? { - return try { - val uri = java.net.URI(value.trim()) - val scheme = uri.scheme?.lowercase() - val host = uri.host?.removePrefix("[")?.removeSuffix("]")?.lowercase() - if (scheme == null || host == null || uri.userInfo != null) { + 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 })) @@ -92,11 +102,15 @@ internal object OriginAllowlist { } 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 == "https" && port == HTTPS_DEFAULT_PORT -> null - scheme == "http" && port == HTTP_DEFAULT_PORT -> null + scheme == Scheme.HTTPS && port == HTTPS_DEFAULT_PORT -> null + scheme == Scheme.HTTP && port == HTTP_DEFAULT_PORT -> null else -> port } 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 650ad98f0..f35333e88 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 @@ -32,6 +32,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) +@Suppress("LargeClass") class CheckoutWebViewTest { private lateinit var activity: ComponentActivity @@ -420,6 +421,15 @@ class CheckoutWebViewTest { .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(CheckoutKitException::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) @@ -440,6 +450,27 @@ class CheckoutWebViewTest { ) } + @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(CheckoutKitException::class.java) + assertThat(it.message).contains("requires an HTTPS URL") + } + ) + } + @Test fun `loadCheckout preserves existing query params alongside ec_version`() { val view = checkoutWebView(activity) 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 index 313c0fbae..c7343d2aa 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt @@ -89,12 +89,33 @@ class OriginAllowlistTest { @Test fun `invalid configured patterns are ignored`() { - val patterns = OriginAllowlist.effectivePatterns(cartOrigin, setOf("not a url")) + 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", + ), + ) assertThat(OriginAllowlist.isAllowed("https://not a url", patterns)).isFalse() + assertThat(OriginAllowlist.isAllowed("https://allowed.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")) From 5b50f44ae2f9cdf9f60876b19f5f7a8875b0216a Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 14:16:08 +0200 Subject: [PATCH 6/7] fix(android): align origin validation with flattened errors --- .../java/com/shopify/checkoutkit/CheckoutWebView.kt | 5 +++-- .../checkoutkit/EmbeddedCheckoutProtocolBridge.kt | 1 + .../com/shopify/checkoutkit/CheckoutWebViewTest.kt | 10 ++++++---- .../com/shopify/checkoutkit/ShopifyCheckoutTest.kt | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) 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 45296bfd0..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 @@ -484,8 +484,9 @@ internal class CheckoutWebView private constructor( private const val LOG_TAG = "CheckoutWebView" -private fun insecureCheckoutUrlException(url: String): CheckoutKitException = CheckoutKitException( - errorDescription = "Checkout requires an HTTPS URL: ${url.redactedUrlForLogging()}", +private fun insecureCheckoutUrlException(url: String): CheckoutException = CheckoutException( + code = CheckoutErrorCode.SDK_ERROR, + message = "Checkout requires an HTTPS URL: ${url.redactedUrlForLogging()}", ) internal class CheckoutWebViewTouchHandler { 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 fbcbb4c05..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, 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 f35333e88..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 @@ -417,14 +417,14 @@ class CheckoutWebViewTest { val view = checkoutWebView(activity) assertThatThrownBy { view.loadCheckout("http://checkout.shopify.com/cart/123") } - .isInstanceOf(CheckoutKitException::class.java) + .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(CheckoutKitException::class.java) + .isInstanceOf(CheckoutException::class.java) .hasMessageContaining("requires an HTTPS URL") assertThat(webMessageTransport.attachCount).isZero() @@ -444,7 +444,8 @@ class CheckoutWebViewTest { assertThat(blocked).isTrue() verify(listener).onCheckoutViewFailedWithError( org.mockito.kotlin.check { - assertThat(it).isInstanceOf(CheckoutKitException::class.java) + assertThat(it).isInstanceOf(CheckoutException::class.java) + assertThat(it.code).isEqualTo(CheckoutErrorCode.SDK_ERROR) assertThat(it.message).contains("requires an HTTPS URL") } ) @@ -465,7 +466,8 @@ class CheckoutWebViewTest { assertThat(blocked).isTrue() verify(listener).onCheckoutViewFailedWithError( org.mockito.kotlin.check { - assertThat(it).isInstanceOf(CheckoutKitException::class.java) + assertThat(it).isInstanceOf(CheckoutException::class.java) + assertThat(it.code).isEqualTo(CheckoutErrorCode.SDK_ERROR) assertThat(it.message).contains("requires an HTTPS URL") } ) 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 fc330ef28..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 @@ -165,8 +165,8 @@ class ShopifyCheckoutTest { ShadowLooper.shadowMainLooper().runToEndOfTasks() assertThat(receivedError) - .isInstanceOf(CheckoutKitException::class.java) - .extracting("errorDescription") + .isInstanceOf(CheckoutException::class.java) + .extracting("message") .asString() .contains("requires an HTTPS URL") assertThat(view.findViewById(R.id.checkoutKitContainer).children.none { it is CheckoutWebView }) From bf8947985ba30ffea774c190563888842de74cdc Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Tue, 4 Aug 2026 13:43:58 +0200 Subject: [PATCH 7/7] fix(android): reject overflowing wildcard ports --- .../main/java/com/shopify/checkoutkit/OriginAllowlist.kt | 6 +++++- .../java/com/shopify/checkoutkit/OriginAllowlistTest.kt | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 index ef50837a7..74182bf1d 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/OriginAllowlist.kt @@ -79,10 +79,14 @@ internal object OriginAllowlist { !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, port.toIntOrNull()), + port = normalizedPort(normalizedScheme, parsedPort), ) } } 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 index c7343d2aa..db7e073be 100644 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/OriginAllowlistTest.kt @@ -97,11 +97,13 @@ class OriginAllowlistTest { "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() }