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
26 changes: 26 additions & 0 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down
21 changes: 21 additions & 0 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <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 @@ -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
Expand Down Expand Up @@ -61,6 +62,10 @@ internal class CheckoutWebView private constructor(
private var didRetryCheckoutRequest = false
private val touchHandler = CheckoutWebViewTouchHandler()

/** Origin of the loaded checkout URL, trusted as a safe default for incoming-message validation. */
internal var checkoutOrigin: String? = null
private set

init {
configureWebView(::listener)
webViewClient = CheckoutWebViewClient()
Expand Down Expand Up @@ -116,12 +121,16 @@ internal class CheckoutWebView private constructor(
}

fun loadCheckout(url: String, isPreload: Boolean = false) {
if (!OriginAllowlist.isHttpsUrl(url)) {
throw insecureCheckoutUrlException(url)
}
log.d(
LOG_TAG,
"Loading checkout with url ${url.redactedUrlForLogging()}. IsPreload: $isPreload."
)
loadComplete = false
isPreloadRequest = isPreload
checkoutOrigin = OriginAllowlist.originFromUrl(url)
Handler(Looper.getMainLooper()).post {
val request = CheckoutRequest(
url = CheckoutUrlDecorator.decorate(url),
Expand Down Expand Up @@ -265,20 +274,39 @@ internal class CheckoutWebView private constructor(
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val uri = request?.url
if (uri == null || (!uri.isContactLink() && !uri.isDeepLink())) return false

when (val result = ExternalUriLauncher.launch(context, uri)) {
is ExternalUriLauncher.Result.Launched ->
log.d(LOG_TAG, "Deep link intercepted: ${uri.redactedForLogging()} — allowed")
is ExternalUriLauncher.Result.Rejected ->
log.d(
LOG_TAG,
"Deep link intercepted: ${uri.redactedForLogging()} — rejected (${result.reason})"
): Boolean = handleNavigation(request?.url, request?.isForMainFrame == true)

@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
handleNavigation(url?.toUri(), isMainFrame = true)

private fun handleNavigation(uri: Uri?, isMainFrame: Boolean): Boolean {
return when {
uri == null -> false
uri.isContactLink() || uri.isDeepLink() -> {
when (val result = ExternalUriLauncher.launch(context, uri)) {
is ExternalUriLauncher.Result.Launched ->
log.d(LOG_TAG, "Deep link intercepted: ${uri.redactedForLogging()} — allowed")
is ExternalUriLauncher.Result.Rejected ->
log.d(
LOG_TAG,
"Deep link intercepted: ${uri.redactedForLogging()} — rejected (${result.reason})"
)
}
true
}
isMainFrame && uri.scheme != Scheme.HTTPS -> {
val error = insecureCheckoutUrlException(uri.toString())
preloadCache.evict(
this@CheckoutWebView,
PreloadState.Failed(PreloadState.FailureReason.NavigationFailed),
)
resetCheckoutRequestRetryState()
listener.onCheckoutViewFailedWithError(error)
true
}
else -> false
}
return true
}

private fun handleClientError(
Expand Down Expand Up @@ -352,25 +380,32 @@ internal class CheckoutWebView private constructor(
webMessageTransport: WebMessageTransport = WebMessageListenerTransport,
listener: PreloadStateListener? = null,
): CheckoutPreload? {
if (!ShopifyCheckoutKit.configuration.preloading.enabled) {
return null
}

return try {
runOnUiThreadBlocking(activity) {
val view = CheckoutWebView(activity, webMessageTransport)
val handle = CheckoutPreload(preloadCache)
view.apply {
loadCheckout(url, isPreload = true)
log.d(LOG_TAG, "Pausing preloaded WebView.")
onPause()
return when {
!ShopifyCheckoutKit.configuration.preloading.enabled -> null
!OriginAllowlist.isHttpsUrl(url) -> {
runOnUiThreadBlocking(activity) {
val handle = CheckoutPreload(preloadCache)
preloadCache.evict(PreloadState.Failed(PreloadState.FailureReason.NavigationFailed))
handle.listener = listener
handle
}
preloadCache.store(PreloadKey.forUrl(url), view, activity)
handle.listener = listener
handle
}
} catch (_: UnsupportedWebViewException) {
null
else -> try {
runOnUiThreadBlocking(activity) {
val view = CheckoutWebView(activity, webMessageTransport)
val handle = CheckoutPreload(preloadCache)
view.apply {
loadCheckout(url, isPreload = true)
log.d(LOG_TAG, "Pausing preloaded WebView.")
onPause()
}
preloadCache.store(PreloadKey.forUrl(url), view, activity)
handle.listener = listener
handle
}
} catch (_: UnsupportedWebViewException) {
null
}
}
}

Expand All @@ -383,6 +418,9 @@ internal class CheckoutWebView private constructor(
check(Looper.myLooper() == Looper.getMainLooper()) {
"Checkout views must be created on the main thread."
}
if (!OriginAllowlist.isHttpsUrl(url)) {
throw insecureCheckoutUrlException(url)
}
val cachedView = if (ShopifyCheckoutKit.configuration.preloading.enabled) {
preloadCache.take(PreloadKey.forUrl(url))
} else {
Expand Down Expand Up @@ -446,6 +484,11 @@ internal class CheckoutWebView private constructor(

private const val LOG_TAG = "CheckoutWebView"

private fun insecureCheckoutUrlException(url: String): CheckoutException = CheckoutException(
code = CheckoutErrorCode.SDK_ERROR,
message = "Checkout requires an HTTPS URL: ${url.redactedUrlForLogging()}",
)

internal class CheckoutWebViewTouchHandler {
private var lastTouchRawY = 0f
private var touchGestureOwnerResolved = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import android.content.Context
* Configuration for Shopify Checkout Kit.
*
* Allows specifying the colors, sheet presentation, and runtime behavior that should be used for checkout.
*
* @property allowedMessageOrigins Extra origins allowed to post incoming checkout-protocol messages.
* Native checkout is open by default: leaving this empty trusts every origin. Once populated, the
* effective allowlist is these origins plus the cart URL origin and `shop.app` (including its
* subdomains). Entries may be exact origins (`https://example.com`), scheme-qualified wildcard
* subdomains (`https://&#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 @@ -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<String> = emptySet(),
Comment thread
tiagocandido marked this conversation as resolved.
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 @@ -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,
Expand Down Expand Up @@ -73,8 +74,8 @@ internal class EmbeddedCheckoutProtocolBridge(
webView = view,
jsObjectName = INTERFACE_NAME,
allowedOriginRules = ALLOWED_MESSAGE_ORIGIN_RULES,
) { message, isMainFrame ->
receiveWebMessage(message, isMainFrame)
) { message, sourceOrigin, isMainFrame ->
receiveWebMessage(message, sourceOrigin, isMainFrame)
}
if (!attached) throw UnsupportedWebViewException()
isTransportAttached = true
Expand All @@ -91,15 +92,49 @@ internal class EmbeddedCheckoutProtocolBridge(
this.client = client
}

private fun receiveWebMessage(message: String, isMainFrame: Boolean) {
private fun receiveWebMessage(message: String, sourceOrigin: String, isMainFrame: Boolean) {
if (!isMainFrame) {
log.d(LOG_TAG, "Ignoring ECP WebMessage from a child frame.")
return
}

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

receiveMessage(message)
}

/**
* Origin validation runs here (not at the WebView layer) so [ALLOWED_MESSAGE_ORIGIN_RULES] can
* stay `"*"` and deliver every message with its verified origin. That lets the kit surface
* drops through [Configuration.onMessageRejected] instead of the WebView silently discarding
* them.
*/
private fun isOriginAllowed(sourceOrigin: String): Boolean {
val configuration = ShopifyCheckoutKit.configuration
val patterns = OriginAllowlist.effectivePatterns(
checkoutOrigin = view.checkoutOrigin,
configured = configuration.allowedMessageOrigins,
)
return OriginAllowlist.isAllowed(sourceOrigin, patterns)
}

private fun rejectMessage(sourceOrigin: String, message: String) {
val reason = "origin \"$sourceOrigin\" is not in the allowlist"
val callback = ShopifyCheckoutKit.configuration.onMessageRejected
if (callback != null) {
try {
callback(RejectedMessage(origin = sourceOrigin, message = message, reason = reason))
} catch (error: Exception) {
log.e(LOG_TAG, "onMessageRejected callback threw", error)
}
} else {
log.d(LOG_TAG, "Dropped ECP WebMessage: $reason")
}
}

internal fun receiveMessage(message: String) {
protocolMessageExecutor.execute {
processMessage(message)
Expand Down
Loading
Loading