Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
18 changes: 0 additions & 18 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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 <init> (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 <init> (Landroid/content/Context;Ljava/lang/String;Lcom/shopify/checkoutkit/DefaultCheckoutListener;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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://&#42;.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(
Expand All @@ -25,20 +23,6 @@ public data class Configuration internal constructor(
var preloading: Preloading = Preloading(),
var title: String? = null,
var allowedMessageOrigins: Set<String> = 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,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ internal class EmbeddedCheckoutProtocolBridge(
}

if (!isOriginAllowed(sourceOrigin)) {
rejectMessage(sourceOrigin, message)
rejectMessage(sourceOrigin)
return
}

Expand All @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -60,7 +62,6 @@ class CheckoutWebViewTest {
it.platform = initialConfiguration.platform
it.logLevel = initialConfiguration.logLevel
it.allowedMessageOrigins = initialConfiguration.allowedMessageOrigins
it.onMessageRejected = initialConfiguration.onMessageRejected
}
}

Expand Down Expand Up @@ -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<RejectedMessage>()
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")
Expand All @@ -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
Expand Down
Loading