Skip to content
Closed
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
21 changes: 11 additions & 10 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ ShopifyCheckoutKit.configure {
| `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`.

Expand Down Expand Up @@ -290,21 +289,23 @@ 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`:
Messages dropped by the ingress policy are always logged at debug level. To
forward structured diagnostics to telemetry, retain an SDK-wide diagnostics
subscription before preloading or presenting checkout:

```swift
ShopifyCheckoutKit.configure {
$0.onMessageRejected = { rejection in
print("Dropped \(rejection.origin): \(rejection.message)")
}
let diagnosticsSubscription = ShopifyCheckoutKit.diagnostics.subscribe { event in
guard case let .messageRejected(rejection) = event else { return }
print("Dropped \(rejection.origin): \(rejection.reason)")
}

// Stop observing when the owning component is destroyed.
diagnosticsSubscription.cancel()
```

> [!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.
> Rejected messages are untrusted and never enter checkout protocol dispatch.
> The diagnostic intentionally omits the raw message body.

### Current configuration

Expand Down
130 changes: 130 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Foundation

/// An SDK diagnostic that applications may observe for integration telemetry.
///
/// Diagnostics are informational and never replace checkout lifecycle, preload,
/// or protocol events. Applications may safely ignore every diagnostic event.
public enum CheckoutDiagnosticEvent: Equatable, Sendable {
/// An incoming checkout message was denied before protocol dispatch.
case messageRejected(CheckoutMessageRejection)
}

/// 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 struct CheckoutMessageRejection: Equatable, Sendable {
/// The origin the message was received from, for example `https://example.com`.
public let origin: String

/// The stable reason the message was denied.
public let reason: Reason

package init(origin: String, reason: Reason) {
self.origin = origin
self.reason = reason
}

public enum Reason: Equatable, Sendable {
/// The message was sent from a child frame rather than the checkout's main frame.
case childFrame

/// The message origin used explicit port zero while origin validation was enabled.
case unsupportedPort

/// The message origin did not match the effective allowlist.
case originNotAllowed
}
}

/// SDK-wide diagnostic events emitted by Checkout Kit.
///
/// Subscriptions are hot and do not replay earlier events. Subscribe before
/// calling `preload(checkout:)` when preload diagnostics are required. Listeners
/// are called on the main actor so diagnostics follow the same observation model
/// as checkout preload state.
public final class CheckoutDiagnostics: Sendable {
/// A retained observation of SDK diagnostic events.
///
/// Keep the subscription for as long as diagnostics should be observed.
/// Observation stops when the subscription is cancelled or released.
@MainActor
public final class Subscription {
private var listener: (@MainActor (CheckoutDiagnosticEvent) -> Void)?
Comment on lines +49 to +53

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was tempted to implement an AsyncStream here instead but decided to align with the existing subscribe pattern for preload observability. Also, implementing async streams in Kotlin required the coroutines dependency.


fileprivate init(listener: @escaping @MainActor (CheckoutDiagnosticEvent) -> Void) {
self.listener = listener
}

/// Stops this listener from receiving future diagnostic events.
public func cancel() {
listener = nil
}

fileprivate func receive(_ event: CheckoutDiagnosticEvent) {
listener?(event)
}
}

@MainActor
private final class WeakSubscription {
weak var value: Subscription?

init(_ value: Subscription) {
self.value = value
}
}

@MainActor private var subscriptions = [UUID: WeakSubscription]()

package init() {}

/// Subscribes a listener to future diagnostic events.
///
/// Retain the returned subscription for as long as events should be observed.
@MainActor
public func subscribe(
_ listener: @escaping @MainActor (CheckoutDiagnosticEvent) -> Void
) -> Subscription {
subscriptions = subscriptions.filter { $0.value.value != nil }

let subscription = Subscription(listener: listener)
subscriptions[UUID()] = WeakSubscription(subscription)
return subscription
}

@MainActor
package func emit(_ event: CheckoutDiagnosticEvent) {
log(event)

// Retain a snapshot for this delivery so listeners may cancel themselves
// or release other subscriptions without mutating the traversed collection.
let currentSubscriptions = subscriptions.compactMap { $0.value.value }
subscriptions = subscriptions.filter { $0.value.value != nil }
for subscription in currentSubscriptions {
subscription.receive(event)
}
}

private func log(_ event: CheckoutDiagnosticEvent) {
switch event {
case let .messageRejected(rejection):
OSLogger.shared.debug(
"Rejected checkout message from \(rejection.origin): \(rejection.reason.logDescription)"
)
}
}
}

extension CheckoutMessageRejection.Reason {
fileprivate var logDescription: String {
switch self {
case .childFrame:
return "message was sent from a child frame"
case .unsupportedPort:
return "origin uses unsupported port 0"
case .originNotAllowed:
return "origin is not in the allowlist"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

/// Transport metadata available before an incoming message enters protocol dispatch.
///
/// WebKit owns the authoritative frame and origin metadata. Keeping that metadata
/// separate from the untrusted message body prevents protocol handlers from being
/// responsible for transport admission decisions. Origin details are resolved lazily
/// because open-by-default admission does not need to inspect them.
struct IncomingCheckoutMessage {
let body: String
let isMainFrame: Bool
let resolveOrigin: () -> MessageOrigin
let resolveRequestURL: () -> URL?
}

/// Applies the SDK's admission rules to incoming checkout messages.
///
/// A message may be valid JSON and valid checkout protocol while still being
/// rejected by this policy because its transport metadata is not admitted.
struct CheckoutMessageIngressPolicy {
enum Decision: Equatable {
case accepted
case rejected(CheckoutMessageRejection)
}

let configuredOrigins: [String]
let checkoutURL: URL?

func evaluate(_ message: IncomingCheckoutMessage) -> Decision {
guard message.isMainFrame else {
return .rejected(
CheckoutMessageRejection(origin: message.resolveOrigin().description, reason: .childFrame)
)
}

let patterns = MessageOriginValidator.effectiveAllowlist(
configuredOrigins: configuredOrigins,
checkoutURL: checkoutURL
)
guard let patterns else { return .accepted }

// WKSecurityOrigin reports both the default port and explicit port zero
// as zero. The frame request URL preserves the explicit spelling.
guard message.resolveRequestURL()?.port != 0 else {
return .rejected(
CheckoutMessageRejection(origin: message.resolveOrigin().description, reason: .unsupportedPort)
)
}

let origin = message.resolveOrigin()
guard MessageOriginValidator.isAllowed(origin: origin, patterns: patterns) else {
return .rejected(
CheckoutMessageRejection(origin: origin.description, reason: .originNotAllowed)
)
}

return .accepted
}
}
69 changes: 19 additions & 50 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ class CheckoutWebView: WKWebView {
private var bridgeRegistration: ScriptMessageHandlerRegistration?

var client: (any CheckoutCommunicationProtocol)?

/// Diagnostic destination for transport-level observations. Tests replace
/// this with an isolated instance so global subscribers cannot leak between cases.
var diagnostics: CheckoutDiagnostics = ShopifyCheckoutKit.diagnostics
var externalURLHandler: any ExternalURLHandling = UIApplicationExternalURLHandler()

/// Resolves whether a navigation targets the main frame. Overridable in tests.
Expand Down Expand Up @@ -614,18 +618,22 @@ extension CheckoutWebView: WKScriptMessageHandler {
return
}

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
}
let incomingMessage = IncomingCheckoutMessage(
body: body,
isMainFrame: messageIsMainFrame(message),
resolveOrigin: { self.messageOrigin(message) },
resolveRequestURL: { self.messageRequestURL(message) }
)
let ingressPolicy = CheckoutMessageIngressPolicy(
configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins,
checkoutURL: loadedCheckoutURL
)

guard isMessageOriginAllowed(message) else {
rejectMessage(message, body: body, reason: "origin is not in the allowlist")
switch ingressPolicy.evaluate(incomingMessage) {
case .accepted:
break
case let .rejected(rejection):
diagnostics.emit(.messageRejected(rejection))
return
}

Expand Down Expand Up @@ -715,45 +723,6 @@ 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 UIApplication {
var foregroundActiveWindow: UIWindow? {
let activeScenes = connectedScenes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,6 @@ public struct Configuration: Sendable {
/// 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
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import Foundation
import WebKit

/// Details about an incoming checkout message that was rejected during origin
/// validation. Surfaced through `Configuration.onMessageRejected`.
public struct MessageRejection: Sendable {
/// The origin the message was received from, e.g. `https://example.com`.
public let origin: String
/// The raw message body as received from the checkout surface.
public let message: String
/// Human-readable reason the message was rejected.
public let reason: String

public init(origin: String, message: String, reason: String) {
self.origin = origin
self.message = message
self.reason = reason
}
}

/// A normalized representation of a message origin (scheme + host + port).
struct MessageOrigin: Equatable {
let scheme: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import UIKit
/// The version of the `ShopifyCheckoutKit` library.
public let version = "4.0.0-alpha.4"

/// SDK-wide diagnostics emitted by Checkout Kit.
///
/// Diagnostics are observational and may be safely ignored. Subscribe before
/// preloading to observe events emitted by a background checkout WebView.
public let diagnostics = CheckoutDiagnostics()

private let lockedCheckoutKitConfiguration = LockedValue(Configuration())

/// The configuration options for the `ShopifyCheckoutKit` library.
Expand Down
Loading
Loading