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
15 changes: 12 additions & 3 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,10 @@ 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.
The native WebView is a private, app-controlled runtime, so Checkout Kit is **open by default**:
with an empty `allowedMessageOrigins`, incoming checkout-protocol messages from any origin are
accepted. Provide one or more origins to restrict which origins are trusted; the loaded checkout
origin and `shop.app` (including its subdomains) are always trusted as well.

```kotlin
ShopifyCheckoutKit.configure {
Expand All @@ -456,6 +457,14 @@ For example, `https://checkout.example.com/` is accepted, while
entries require the scheme and match subdomains only; `https://*.example.org` does not match
`https://example.org`. Use `"*"` to explicitly disable origin validation.

`CheckoutMessageIngressPolicy` evaluates the WebView's authenticated source origin and frame
metadata before a message reaches the protocol client. This keeps transport trust decisions at the
native WebView boundary while ensuring the protocol client only handles admitted checkout messages.

Rejected messages are dropped and logged at warning level. A rejected message is untrusted input,
not evidence that checkout failed, so it does not fail a preload or invoke `onFail` or
`onCheckoutFailed` during presentation.

## 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.shopify.checkoutkit

import java.net.URI

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

/**
* Applies the SDK's admission rules to incoming checkout messages.
*
* A message may be 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 the 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
@@ -0,0 +1,27 @@
package com.shopify.checkoutkit

/** Details about an incoming checkout message rejected by the transport admission policy. */
internal data class CheckoutMessageRejection(
/** Origin the message was received from, for example `https://example.com`. */
val origin: String,
/** Stable reason the message was rejected. */
val reason: Reason,
) {
enum class Reason {
/** The message was sent from a child frame rather than the checkout document. */
CHILD_FRAME,

/** The message origin used explicit port zero. */
UNSUPPORTED_PORT,

/** The message origin was not included in the effective allowlist. */
ORIGIN_NOT_ALLOWED,
}
}

internal 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
Expand Up @@ -92,39 +92,24 @@ internal class EmbeddedCheckoutProtocolBridge(
}

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)
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 log drops
* with the verified origin instead of the WebView silently discarding them.
*/
private fun isOriginAllowed(sourceOrigin: String): Boolean {
val configuration = ShopifyCheckoutKit.configuration
val patterns = OriginAllowlist.effectivePatterns(
val incomingMessage = IncomingCheckoutMessage(
origin = sourceOrigin,
isMainFrame = isMainFrame,
)
val ingressPolicy = CheckoutMessageIngressPolicy(
configuredOrigins = ShopifyCheckoutKit.configuration.allowedMessageOrigins,
checkoutOrigin = view.checkoutOrigin,
configured = configuration.allowedMessageOrigins,
)
return OriginAllowlist.isAllowed(sourceOrigin, patterns)

when (val decision = ingressPolicy.evaluate(incomingMessage)) {
CheckoutMessageIngressPolicy.Decision.Accepted -> receiveMessage(message)
is CheckoutMessageIngressPolicy.Decision.Rejected -> handleMessageRejection(decision.rejection)
}
}

/**
* 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")
/** Rejected messages are untrusted input, not checkout lifecycle failures. */
private fun handleMessageRejection(rejection: CheckoutMessageRejection) {
log.w(LOG_TAG, "Rejected ECP WebMessage from ${rejection.origin}: ${rejection.reason.logDescription}")
}

internal fun receiveMessage(message: String) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.shopify.checkoutkit

import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class CheckoutMessageIngressPolicyTest {
@Test
fun `open default accepts any main frame origin`() {
val policy = CheckoutMessageIngressPolicy(emptySet(), "https://checkout.example.com")

assertThat(policy.evaluate(message("https://untrusted.example.com")))
.isEqualTo(CheckoutMessageIngressPolicy.Decision.Accepted)
}

@Test
fun `child frame is rejected`() {
val policy = CheckoutMessageIngressPolicy(emptySet(), "https://checkout.example.com")

assertThat(policy.evaluate(message("https://checkout.example.com", isMainFrame = false)))
.isEqualTo(
CheckoutMessageIngressPolicy.Decision.Rejected(
CheckoutMessageRejection(
"https://checkout.example.com",
CheckoutMessageRejection.Reason.CHILD_FRAME,
),
),
)
}

@Test
fun `explicit port zero is rejected when validation is enabled`() {
val policy = CheckoutMessageIngressPolicy(
setOf("https://trusted.example.com"),
"https://checkout.example.com",
)

assertThat(policy.evaluate(message("https://trusted.example.com:0")))
.isEqualTo(
CheckoutMessageIngressPolicy.Decision.Rejected(
CheckoutMessageRejection(
"https://trusted.example.com:0",
CheckoutMessageRejection.Reason.UNSUPPORTED_PORT,
),
),
)
}

@Test
fun `origin outside allowlist is rejected`() {
val policy = CheckoutMessageIngressPolicy(
setOf("https://trusted.example.com"),
"https://checkout.example.com",
)

assertThat(policy.evaluate(message("https://untrusted.example.com")))
.isEqualTo(
CheckoutMessageIngressPolicy.Decision.Rejected(
CheckoutMessageRejection(
"https://untrusted.example.com",
CheckoutMessageRejection.Reason.ORIGIN_NOT_ALLOWED,
),
),
)
}

private fun message(origin: String, isMainFrame: Boolean = true): IncomingCheckoutMessage =
IncomingCheckoutMessage(origin = origin, isMainFrame = isMainFrame)
}
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,18 @@ class CheckoutWebViewTest {
}

@Test
fun `web message from an untrusted origin is dropped and logged when an allowlist is configured`() {
fun `web message from an untrusted origin is logged and dropped without failing checkout`() {
ShopifyCheckoutKit.configure {
it.allowedMessageOrigins = setOf("https://allowed.example.com")
}
val view = checkoutWebView(activity)
var failure: CheckoutException? = null
view.setListener(
CheckoutWebViewListener(
listener = NoopCheckoutListener(),
closeCheckoutWithError = { failure = it },
),
)
view.loadCheckout("https://checkout.shopify.com/cart/123")
ShadowLooper.shadowMainLooper().runToEndOfTasks()
var received = false
Expand All @@ -338,6 +345,7 @@ class CheckoutWebViewTest {
ShadowLooper.shadowMainLooper().runToEndOfTasks()
assertThat(sentinelReceived).isTrue()
}

assertThat(received).isFalse()
// Drops are logged as warnings at the default log level, with the verified
// origin and reason but never the untrusted message body.
Expand All @@ -349,6 +357,24 @@ class CheckoutWebViewTest {
}
).isTrue()
assertThat(ShadowLog.getLogs().none { it.msg.contains("ec.messages.change") }).isTrue()
assertThat(failure).isNull()
}

@Test
fun `web message from untrusted origin does not fail backgrounded preload`() {
ShopifyCheckoutKit.configure { it.allowedMessageOrigins = setOf("https://allowed.example.com") }
val preload = CheckoutWebView.preload(
"https://checkout.shopify.com/cart/123",
activity,
webMessageTransport,
)!!
ShadowLooper.shadowMainLooper().idle()

webMessageTransport.dispatchMessage(ecMessagesChangeMessage(), sourceOrigin = "https://evil.example.com")
ShadowLooper.shadowMainLooper().idle()

assertThat(CheckoutWebView.cachedPreloadViewForTesting()).isNotNull()
assertThat(preload.state).isEqualTo(PreloadState.Loading)
}

// endregion
Expand Down
Loading