diff --git a/platforms/android/README.md b/platforms/android/README.md index 8b891889d..2edea65a1 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -293,7 +293,6 @@ ShopifyCheckoutKit.configure { | `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. | | `title` | `null` | Runtime override for the checkout sheet header title. When `null`, the SDK uses the localized `checkout_web_view_title` string resource. | | `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. | -| `onMessageRejected` | `null` | Observes messages rejected by origin validation. | ### Color schemes @@ -444,12 +443,13 @@ ShopifyCheckoutKit.configure { "https://checkout.example.com", "https://*.example.org", ) - it.onMessageRejected = { rejection -> - reportRejectedOrigin(rejection.origin, rejection.reason) - } } ``` +Messages dropped by origin validation are never silently discarded: each rejection is logged as a +warning with the message origin and the reason it was dropped. The message body is untrusted and is +not logged. + 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 diff --git a/platforms/android/lib/api/lib.api b/platforms/android/lib/api/lib.api index 5c1a46ded..f537565e7 100644 --- a/platforms/android/lib/api/lib.api +++ b/platforms/android/lib/api/lib.api @@ -494,12 +494,10 @@ public final class com/shopify/checkoutkit/Configuration { 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; @@ -508,7 +506,6 @@ public final class com/shopify/checkoutkit/Configuration { 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 @@ -682,21 +679,6 @@ 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/Configuration.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/Configuration.kt index 632fc92d7..04dc93cd9 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 @@ -12,9 +12,7 @@ import android.content.Context * 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. + * Messages dropped by origin validation are logged as warnings. */ @ConsistentCopyVisibility public data class Configuration internal constructor( @@ -25,20 +23,6 @@ public data class Configuration internal constructor( 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 2c2f3afd1..40295ffbe 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 @@ -98,7 +98,7 @@ internal class EmbeddedCheckoutProtocolBridge( } if (!isOriginAllowed(sourceOrigin)) { - rejectMessage(sourceOrigin, message) + rejectMessage(sourceOrigin) return } @@ -107,9 +107,8 @@ internal class EmbeddedCheckoutProtocolBridge( /** * 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. + * stay `"*"` and deliver every message with its verified origin. That lets the kit log drops + * with the verified origin instead of the WebView silently discarding them. */ private fun isOriginAllowed(sourceOrigin: String): Boolean { val configuration = ShopifyCheckoutKit.configuration @@ -120,18 +119,12 @@ internal class EmbeddedCheckoutProtocolBridge( 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") - } + /** + * Rejected messages are never silently dropped: each rejection is logged as a warning with the + * verified origin and reason. The message body is untrusted and intentionally not logged. + */ + private fun rejectMessage(sourceOrigin: String) { + log.w(LOG_TAG, "Dropped ECP WebMessage: origin \"$sourceOrigin\" is not in the allowlist") } internal fun receiveMessage(message: String) { 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 44512ec07..549054229 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 @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import android.net.Uri import android.os.Looper +import android.util.Log import android.view.MotionEvent import android.view.View.VISIBLE import android.webkit.GeolocationPermissions @@ -27,6 +28,7 @@ import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLog import org.robolectric.shadows.ShadowLooper import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @@ -60,7 +62,6 @@ class CheckoutWebViewTest { it.platform = initialConfiguration.platform it.logLevel = initialConfiguration.logLevel it.allowedMessageOrigins = initialConfiguration.allowedMessageOrigins - it.onMessageRejected = initialConfiguration.onMessageRejected } } @@ -315,11 +316,9 @@ class CheckoutWebViewTest { } @Test - fun `web message from an untrusted origin is dropped and reported when an allowlist is configured`() { - val rejected = mutableListOf() + fun `web message from an untrusted origin is dropped and logged when an allowlist is configured`() { 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") @@ -340,31 +339,16 @@ class CheckoutWebViewTest { 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() - } + // Drops are logged as warnings at the default log level, with the verified + // origin and reason but never the untrusted message body. + assertThat( + ShadowLog.getLogs().any { + it.type == Log.WARN && + it.msg.contains("https://evil.example.com") && + it.msg.contains("not in the allowlist") + } + ).isTrue() + assertThat(ShadowLog.getLogs().none { it.msg.contains("ec.messages.change") }).isTrue() } // endregion