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
19 changes: 3 additions & 16 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,9 @@ 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.
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 call `.onFail` or `checkoutDidFail(error:)` during presentation.

### Current configuration

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 isMainFrame: Bool
let resolveOrigin: () -> MessageOrigin
let resolveRequestURL: () -> URL?
}

/// 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.
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// Details about an incoming checkout message rejected by the transport admission policy.
struct CheckoutMessageRejection: Equatable {
/// Stable reason the message was rejected.
enum Reason: Equatable {
/// The message was sent from a child frame rather than the checkout document.
case childFrame

/// The message request URL used explicit port zero, which WebKit cannot represent safely.
case unsupportedPort

/// The message origin was not included in the effective allowlist.
case originNotAllowed
}

/// Origin the message was received from, for example `https://example.com`.
let origin: String

/// Stable reason the message was rejected.
let reason: Reason
}

extension CheckoutMessageRejection.Reason {
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"
}
}
}
68 changes: 18 additions & 50 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -614,18 +614,25 @@ 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(
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):
// Rejected messages are untrusted input, not checkout lifecycle failures. Warn and
// drop them without allowing unrelated page activity to terminate a healthy checkout.
OSLogger.shared.warn(
"Rejected checkout message from \(rejection.origin): \(rejection.reason.logDescription)"
)
return
}

Expand Down Expand Up @@ -715,45 +722,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
@@ -0,0 +1,90 @@
@testable import ShopifyCheckoutKit
import XCTest

final class CheckoutMessageIngressPolicyTests: XCTestCase {
private let checkoutURL = URL(string: "https://checkout.example.com/cart")!

func testOpenByDefaultAcceptsWithoutResolvingOriginMetadata() {
let policy = CheckoutMessageIngressPolicy(configuredOrigins: [], checkoutURL: checkoutURL)
var didResolveOrigin = false
var didResolveRequestURL = false

XCTAssertEqual(
policy.evaluate(
message(
origin: "https://untrusted.example.com",
didResolveOrigin: { didResolveOrigin = true },
didResolveRequestURL: { didResolveRequestURL = true }
)
),
.accepted
)
XCTAssertFalse(didResolveOrigin)
XCTAssertFalse(didResolveRequestURL)
}

func testChildFrameIsRejected() {
let policy = CheckoutMessageIngressPolicy(configuredOrigins: [], checkoutURL: checkoutURL)

XCTAssertEqual(
policy.evaluate(message(origin: "https://checkout.example.com", isMainFrame: false)),
.rejected(
CheckoutMessageRejection(origin: "https://checkout.example.com", reason: .childFrame)
)
)
}

func testExplicitPortZeroIsRejectedWhenValidationIsEnabled() throws {
let policy = CheckoutMessageIngressPolicy(
configuredOrigins: ["https://trusted.example.com"],
checkoutURL: checkoutURL
)

XCTAssertEqual(
try policy.evaluate(
message(
origin: "https://trusted.example.com",
requestURL: XCTUnwrap(URL(string: "https://trusted.example.com:0"))
)
),
.rejected(
CheckoutMessageRejection(origin: "https://trusted.example.com", reason: .unsupportedPort)
)
)
}

func testOriginOutsideAllowlistIsRejected() {
let policy = CheckoutMessageIngressPolicy(
configuredOrigins: ["https://trusted.example.com"],
checkoutURL: checkoutURL
)

XCTAssertEqual(
policy.evaluate(message(origin: "https://untrusted.example.com")),
.rejected(
CheckoutMessageRejection(origin: "https://untrusted.example.com", reason: .originNotAllowed)
)
)
}

private func message(
origin: String,
requestURL: URL? = nil,
isMainFrame: Bool = true,
didResolveOrigin: @escaping () -> Void = {},
didResolveRequestURL: @escaping () -> Void = {}
) -> IncomingCheckoutMessage {
let url = URL(string: origin)!
return IncomingCheckoutMessage(
isMainFrame: isMainFrame,
resolveOrigin: {
didResolveOrigin()
return MessageOrigin(scheme: url.scheme!, host: url.host!, port: url.port)
},
resolveRequestURL: {
didResolveRequestURL()
return requestURL ?? url
}
)
}
}
Loading
Loading