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
45 changes: 45 additions & 0 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [SwiftUI](#swiftui)
- [Preload checkout](#preload-checkout)
- [Configure checkout](#configure-checkout)
- [Incoming message origin validation](#incoming-message-origin-validation)
- [Current configuration](#current-configuration)
- [Checkout lifecycle](#checkout-lifecycle)
- [Error handling](#error-handling)
Expand Down Expand Up @@ -221,9 +222,53 @@ ShopifyCheckoutKit.configure {
| `closeButtonTintColor` | `nil` | Optional tint for the close button. |
| `logLevel` | `.warn` | SDK logging verbosity. Threshold-ordered `.debug` → `.warn` → `.error` → `.none`; use `.debug` during integration. |
| `preloading.enabled` | `true` | Enables best-effort checkout preloading before presentation. |
| `allowedMessageOrigins` | `[]` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). |
| `onMessageRejected` | `nil` | Closure invoked when a message is dropped by origin validation. Defaults to logging at debug level. |

To localize the title, add `shopify_checkout_kit_title` to your app's `Localizable.xcstrings`.

### Incoming message origin validation

The native web view 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.

```swift
ShopifyCheckoutKit.configure {
$0.allowedMessageOrigins = [
"https://checkout.example.com",
"https://*.example.com",
]
}
```

Each entry may be an exact origin (`https://example.com`), a wildcard subdomain
(`https://*.example.com`, matching subdomains but not the apex), or `"*"` to
explicitly trust every origin.

Exact and wildcard entries accept an optional trailing slash. Exact entries
must not include credentials, paths, queries, or fragments. For example,
`https://example.com/` is accepted, while `https://user@example.com` and
`https://example.com/path` are ignored.

Messages dropped by origin validation are logged at debug level. To observe
them instead, set `onMessageRejected`:

```swift
ShopifyCheckoutKit.configure {
$0.onMessageRejected = { rejection in
print("Dropped \(rejection.origin): \(rejection.message)")
}
}
```

> [!WARNING]
> The `MessageRejection` payload is untrusted — it was dropped precisely because
> its origin was not in the allowlist. Incoming messages are advisory and are
> never treated as an authoritative source of checkout state.

### Current configuration

```swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,22 @@ class CheckoutWebView: WKWebView {

var openExternalURL: (URL) -> Void = { UIApplication.shared.open($0) }

/// Resolves whether a navigation targets the main frame. Overridable in tests.
var navigationIsMainFrame: (WKNavigationAction) -> Bool = { $0.targetFrame?.isMainFrame == true }

/// Resolves the origin an incoming message was sent from. Overridable in
/// tests since `WKSecurityOrigin` cannot be constructed directly.
var messageOrigin: (WKScriptMessage) -> MessageOrigin = { message in
MessageOrigin(securityOrigin: message.frameInfo.securityOrigin)
}

/// Resolves the request URL associated with an incoming message. Overridable
/// in tests since `WKFrameInfo` cannot be constructed directly.
var messageRequestURL: (WKScriptMessage) -> URL? = { $0.frameInfo.request.url }

/// Resolves whether an incoming message came from the main frame. Overridable in tests.
var messageIsMainFrame: (WKScriptMessage) -> Bool = { $0.frameInfo.isMainFrame }

/// Kit-owned client that handles delegations and kit-mandated notifications. Currently:
/// - `ec.ready` - kit-owned handshake. Supported delegations are announced up
/// front via the `ec_delegate` URL query param; acceptance is implicit, so the
Expand Down Expand Up @@ -373,6 +389,10 @@ class CheckoutWebView: WKWebView {
/// Ensures one terminal failure is handled per checkout session, regardless
/// of whether it originated from `ec.error` or WebKit process termination.
private var hasHandledTerminalFailure = false

/// The checkout URL passed to `load(checkout:)`. Used to derive the trusted
/// cart-url origin for incoming message validation.
var loadedCheckoutURL: URL?
private var entryPoint: MetaData.EntryPoint?

// MARK: Initializers
Expand Down Expand Up @@ -450,7 +470,15 @@ class CheckoutWebView: WKWebView {
// MARK: -

func load(checkout url: URL, isPreload: Bool = false) {
guard url.scheme?.lowercased() == "https", url.host != nil else {
handleCachedViewFailure(.navigationFailed)
viewDelegate?.checkoutViewDidFailWithError(
error: .sdkError(underlying: InsecureCheckoutURLError(url: url))
)
return
}
OSLogger.shared.info("Loading checkout URL: \(LogSafeURL.string(url)), isPreload: \(isPreload)")
loadedCheckoutURL = url
var request = URLRequest(url: url)

if isPreload, ShopifyCheckoutKit.configuration.preloading.enabled {
Expand Down Expand Up @@ -528,6 +556,21 @@ extension CheckoutWebView: WKScriptMessageHandler {
return
Comment thread
tiagocandido marked this conversation as resolved.
}

guard messageIsMainFrame(message) else {
rejectMessage(message, body: body, reason: "message was sent from a child frame")
return
}

guard !shouldRejectExplicitPortZero(message) else {
rejectMessage(message, body: body, reason: "origin uses unsupported port 0")
return
}

guard isMessageOriginAllowed(message) else {
rejectMessage(message, body: body, reason: "origin is not in the allowlist")
return
}

guard let method = CheckoutProtocol.supportedProtocolMethod(body) else {
if isTerminalProtocolError(body) {
handleTerminalProtocolError(body, malformedEnvelope: true)
Expand Down Expand Up @@ -611,6 +654,45 @@ private struct TerminalErrorNotification: Decodable {
let params: JSONRPCErrorParams
}

extension CheckoutWebView {
private func rejectMessage(_ message: WKScriptMessage, body: String, reason: String) {
let rejection = MessageRejection(
origin: messageOrigin(message).description,
message: body,
reason: reason
)
let onRejected = ShopifyCheckoutKit.configuration.onMessageRejected ?? { rejection in
OSLogger.shared.debug("Rejected checkout message from \(rejection.origin): \(rejection.reason)")
}
onRejected(rejection)
}

/// Validates the origin of an incoming checkout message against the effective
/// allowlist. When validation is disabled (native default with no configured
/// allowlist, or the `"*"` escape hatch) the message origin is not inspected.
func isMessageOriginAllowed(_ message: WKScriptMessage) -> Bool {
let patterns = MessageOriginValidator.effectiveAllowlist(
configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins,
checkoutURL: loadedCheckoutURL
)
guard let patterns else { return true }

return MessageOriginValidator.isAllowed(origin: messageOrigin(message), patterns: patterns)
}

/// `WKSecurityOrigin` reports both an omitted port and an explicit port 0 as
/// zero. Use the frame request URL to reject the explicit form when origin
/// validation is enabled, while preserving native's open-by-default behavior.
private func shouldRejectExplicitPortZero(_ message: WKScriptMessage) -> Bool {
let patterns = MessageOriginValidator.effectiveAllowlist(
configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins,
checkoutURL: loadedCheckoutURL
)
guard patterns != nil else { return false }
return messageRequestURL(message)?.port == 0
}
}

extension CheckoutWebView: WKNavigationDelegate {
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
// Handle rare cases where the url is nil
Expand All @@ -635,6 +717,14 @@ extension CheckoutWebView: WKNavigationDelegate {
}
}

if navigationIsMainFrame(action), url.scheme?.lowercased() != "https" {
handleCachedViewFailure(.navigationFailed)
viewDelegate?.checkoutViewDidFailWithError(
error: .sdkError(underlying: InsecureCheckoutURLError(url: url))
)
return decisionHandler(.cancel)
}

decisionHandler(.allow)
}

Expand Down Expand Up @@ -801,3 +891,11 @@ extension CheckoutWebView: WKNavigationDelegate {
return self.url == url
}
}

private struct InsecureCheckoutURLError: LocalizedError {
let url: URL

var errorDescription: String? {
"Checkout requires an HTTPS URL: \(LogSafeURL.string(url))"
}
}
22 changes: 22 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@ public struct Configuration: Sendable {
/// Levels: debug, warn, error, none (ordered threshold, most to least verbose)
/// Default: .warn - which emits warnings and errors
public var logLevel: LogLevel = .warn

/// Origins that are trusted to send incoming checkout messages, in addition
/// to the loaded checkout origin and shop.app.
///
/// The native surface is open by default: when this is empty, messages from
/// any origin are accepted. Provide one or more origins to restrict which
/// origins are trusted; the loaded checkout origin and shop.app are always
/// appended. Use `"*"` to explicitly disable origin validation.
///
/// Entries are origin patterns:
/// - `"https://example.com"` — an exact origin.
/// - `"https://*.example.com"` — any subdomain of `example.com`.
Comment thread
tiagocandido marked this conversation as resolved.
/// - `"*"` — allow all origins (escape hatch).
///
/// An optional trailing slash is accepted. Credentials, paths, queries,
/// and fragments are not valid in configured origin patterns.
public var allowedMessageOrigins: [String] = []

/// Invoked when an incoming checkout message is rejected during origin
/// validation. Defaults to logging a debug message; rejected messages are
/// never silently dropped.
public var onMessageRejected: (@Sendable (MessageRejection) -> Void)?
}

extension Configuration {
Expand Down
Loading
Loading