Skip to content
Open
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
18 changes: 15 additions & 3 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,25 @@ ShopifyCheckoutKit.configure {
"https://checkout.example.com",
"https://*.example.org",
)
it.onMessageRejected = { rejection ->
reportRejectedOrigin(rejection.origin, rejection.reason)
}

val diagnosticSubscription = ShopifyCheckoutKit.diagnostics.subscribe { event ->
when (event) {
is CheckoutDiagnosticEvent.MessageRejected -> {
val rejection = event.rejection
reportRejectedOrigin(rejection.origin, rejection.reason)
}
}
}

// Cancel when the observing application component is destroyed.
diagnosticSubscription.cancel()
```

Diagnostics are process-wide, hot events and do not replay. Subscribe before preloading if the
application needs to observe rejections from a background checkout WebView. Rejected message bodies
are intentionally omitted because they are untrusted and may contain sensitive data.

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
72 changes: 54 additions & 18 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ public final class com/shopify/checkoutkit/CheckoutAppearance$Storefront$Compani
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public abstract interface class com/shopify/checkoutkit/CheckoutDiagnosticEvent {
}

public final class com/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected : com/shopify/checkoutkit/CheckoutDiagnosticEvent {
public fun <init> (Lcom/shopify/checkoutkit/CheckoutMessageRejection;)V
public final fun component1 ()Lcom/shopify/checkoutkit/CheckoutMessageRejection;
public final fun copy (Lcom/shopify/checkoutkit/CheckoutMessageRejection;)Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected;Lcom/shopify/checkoutkit/CheckoutMessageRejection;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent$MessageRejected;
public fun equals (Ljava/lang/Object;)Z
public final fun getRejection ()Lcom/shopify/checkoutkit/CheckoutMessageRejection;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/checkoutkit/CheckoutDiagnostics {
public final fun subscribe (Lcom/shopify/checkoutkit/CheckoutDiagnostics$Listener;)Lcom/shopify/checkoutkit/CheckoutDiagnostics$Subscription;
}

public abstract interface class com/shopify/checkoutkit/CheckoutDiagnostics$Listener {
public abstract fun onDiagnosticEvent (Lcom/shopify/checkoutkit/CheckoutDiagnosticEvent;)V
}

public abstract interface class com/shopify/checkoutkit/CheckoutDiagnostics$Subscription : java/lang/AutoCloseable {
public abstract fun cancel ()V
public abstract fun close ()V
}

public final class com/shopify/checkoutkit/CheckoutDiagnostics$Subscription$DefaultImpls {
public static fun close (Lcom/shopify/checkoutkit/CheckoutDiagnostics$Subscription;)V
}

public final class com/shopify/checkoutkit/CheckoutErrorCode : java/lang/Enum {
public static final field CART_COMPLETED Lcom/shopify/checkoutkit/CheckoutErrorCode;
public static final field CART_EXPIRED Lcom/shopify/checkoutkit/CheckoutErrorCode;
Expand Down Expand Up @@ -107,6 +138,28 @@ public abstract interface class com/shopify/checkoutkit/CheckoutListener {
public abstract fun onShowFileChooser (Landroid/webkit/WebView;Landroid/webkit/ValueCallback;Landroid/webkit/WebChromeClient$FileChooserParams;)Z
}

public final class com/shopify/checkoutkit/CheckoutMessageRejection {
public fun <init> (Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public final fun copy (Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;)Lcom/shopify/checkoutkit/CheckoutMessageRejection;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/CheckoutMessageRejection;Ljava/lang/String;Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;ILjava/lang/Object;)Lcom/shopify/checkoutkit/CheckoutMessageRejection;
public fun equals (Ljava/lang/Object;)Z
public final fun getOrigin ()Ljava/lang/String;
public final fun getReason ()Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/shopify/checkoutkit/CheckoutMessageRejection$Reason : java/lang/Enum {
public static final field CHILD_FRAME Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public static final field ORIGIN_NOT_ALLOWED Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public static final field UNSUPPORTED_PORT Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
public static fun values ()[Lcom/shopify/checkoutkit/CheckoutMessageRejection$Reason;
}

public final class com/shopify/checkoutkit/CheckoutPreload {
public final fun getListener ()Lcom/shopify/checkoutkit/PreloadStateListener;
public final fun getState ()Lcom/shopify/checkoutkit/PreloadState;
Expand Down Expand Up @@ -494,12 +547,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 +559,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 +732,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 All @@ -712,6 +747,7 @@ public final class com/shopify/checkoutkit/ShopifyCheckout$Companion {
public final class com/shopify/checkoutkit/ShopifyCheckoutKit {
public static final field INSTANCE Lcom/shopify/checkoutkit/ShopifyCheckoutKit;
public static final field VERSION Ljava/lang/String;
public static final field diagnostics Lcom/shopify/checkoutkit/CheckoutDiagnostics;
public static final fun configure (Lcom/shopify/checkoutkit/ConfigurationUpdater;)V
public static final fun getConfiguration ()Lcom/shopify/checkoutkit/Configuration;
public static final fun invalidate ()V
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.shopify.checkoutkit

import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicBoolean

/** An SDK diagnostic that applications may observe for integration telemetry. */
public sealed interface CheckoutDiagnosticEvent {
/** An incoming checkout message was denied before protocol dispatch. */
public data class MessageRejected(
public val rejection: CheckoutMessageRejection,
) : CheckoutDiagnosticEvent
}

/**
* Details about an incoming checkout message denied by the ingress policy.
*
* The raw message body is intentionally omitted because rejected input is untrusted and may
* contain sensitive or arbitrarily large data.
*/
public data class CheckoutMessageRejection(
/** Origin the message was received from, for example `https://example.com`. */
public val origin: String,

/** Stable reason the message was denied. */
public val reason: Reason,
) {
public enum class Reason {
/** The message was sent from a child frame rather than the checkout's main frame. */
CHILD_FRAME,

/** The message origin used explicit port zero while origin validation was enabled. */
UNSUPPORTED_PORT,

/** The message origin did not match the effective allowlist. */
ORIGIN_NOT_ALLOWED,
}
}

/**
* SDK-wide diagnostic events emitted by Checkout Kit.
*
* Subscriptions are hot and do not replay earlier events. Subscribe before calling `preload` when
* preload diagnostics are required. Listener failures are isolated so diagnostics cannot interrupt
* checkout processing or prevent other listeners from receiving an event.
*/
public class CheckoutDiagnostics internal constructor() {
/** Receives diagnostic events on the Android main thread. */
public fun interface Listener {
public fun onDiagnosticEvent(event: CheckoutDiagnosticEvent)
}

/** A cancellable diagnostic subscription. */
public interface Subscription : AutoCloseable {
/** Stops this listener from receiving future events. */
public fun cancel()

/** Equivalent to [cancel], enabling Java try-with-resources usage. */
override fun close(): Unit = cancel()
}

private val listeners = CopyOnWriteArraySet<Listener>()

/**
* Subscribes [listener] to future diagnostics.
*
* The returned subscription retains the listener until it is cancelled.
*/
public fun subscribe(listener: Listener): Subscription {
listeners.add(listener)
return ListenerSubscription(listeners, listener)
}

internal fun emit(event: CheckoutDiagnosticEvent) {
log(event)

// Snapshot before dispatch so a listener added after emission cannot observe an earlier
// event, and listeners may cancel themselves without mutating the traversed collection.
val currentListeners = listeners.toList()
onMainThread {
currentListeners.forEach { listener ->
try {
listener.onDiagnosticEvent(event)
} catch (error: Exception) {
ShopifyCheckoutKit.log.e(DIAGNOSTICS_LOG_TAG, "Diagnostic listener threw", error)
}
}
}
}

private fun log(event: CheckoutDiagnosticEvent) {
when (event) {
is CheckoutDiagnosticEvent.MessageRejected -> {
val rejection = event.rejection
ShopifyCheckoutKit.log.d(
DIAGNOSTICS_LOG_TAG,
"Rejected checkout message from ${rejection.origin}: ${rejection.reason.logDescription}",
)
}
}
}

private class ListenerSubscription(
private val listeners: CopyOnWriteArraySet<Listener>,
private val listener: Listener,
) : Subscription {
private val cancelled = AtomicBoolean(false)

override fun cancel() {
if (cancelled.compareAndSet(false, true)) {
listeners.remove(listener)
}
}
}

private companion object {
private const val DIAGNOSTICS_LOG_TAG = "CheckoutDiagnostics"
}
}

private val CheckoutMessageRejection.Reason.logDescription: String
get() = when (this) {
CheckoutMessageRejection.Reason.CHILD_FRAME -> "message was sent from a child frame"
CheckoutMessageRejection.Reason.UNSUPPORTED_PORT -> "origin uses unsupported port 0"
CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED -> "origin is not in the allowlist"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.shopify.checkoutkit

import java.net.URI

/** Transport metadata captured before an incoming message enters protocol dispatch. */
internal data class IncomingCheckoutMessage(
val body: String,
val origin: String,
val isMainFrame: Boolean,
)

/**
* Applies the SDK's admission rules to incoming checkout messages.
*
* A message may be valid JSON and valid checkout protocol while still being rejected because its
* transport metadata is not admitted. Keeping this decision outside the protocol client ensures
* the client only receives messages that the native WebView boundary has already trusted.
*/
internal class CheckoutMessageIngressPolicy(
private val configuredOrigins: Set<String>,
private val checkoutOrigin: String?,
) {
internal sealed interface Decision {
data object Accepted : Decision
data class Rejected(val rejection: CheckoutMessageRejection) : Decision
}

internal fun evaluate(message: IncomingCheckoutMessage): Decision {
if (!message.isMainFrame) {
return rejected(message, CheckoutMessageRejection.Reason.CHILD_FRAME)
}

val patterns = OriginAllowlist.effectivePatterns(
checkoutOrigin = checkoutOrigin,
configured = configuredOrigins,
)

return when {
patterns == null -> Decision.Accepted
// AndroidX supplies an authenticated source origin, but explicit port zero is not a
// useful web origin. Reject it only when validation is enabled to preserve native's
// open default.
runCatching { URI(message.origin).port == 0 }.getOrDefault(false) ->
rejected(message, CheckoutMessageRejection.Reason.UNSUPPORTED_PORT)
!OriginAllowlist.isAllowed(message.origin, patterns) ->
rejected(message, CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED)
else -> Decision.Accepted
}
}

private fun rejected(
message: IncomingCheckoutMessage,
reason: CheckoutMessageRejection.Reason,
): Decision.Rejected = Decision.Rejected(
CheckoutMessageRejection(origin = message.origin, reason = reason),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ 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.
*/
@ConsistentCopyVisibility
public data class Configuration internal constructor(
Expand All @@ -25,20 +22,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
Loading
Loading