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.
Messages dropped by origin validation are never silently discarded: each
rejection is logged as a warning with the message origin and the reason it was
dropped. The message body is untrusted and is not logged.

### Current configuration

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,17 +615,18 @@ extension CheckoutWebView: WKScriptMessageHandler {
}

guard messageIsMainFrame(message) else {
rejectMessage(message, body: body, reason: "message was sent from a child frame")
// Child-frame messages are ambient noise, not a validation failure.
OSLogger.shared.debug("Ignoring checkout message from a child frame.")
return
}

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

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

Expand Down Expand Up @@ -716,16 +717,11 @@ private struct TerminalErrorNotification: Decodable {
}

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)
/// Rejected messages are never silently dropped: each rejection is logged as
/// a warning with the trusted origin and reason. The message body is untrusted
/// and intentionally not logged.
private func rejectMessage(_ message: WKScriptMessage, reason: String) {
OSLogger.shared.warn("Rejected checkout message from \(messageOrigin(message).description): \(reason)")
}

/// Validates the origin of an incoming checkout message against the effective
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 @@ -1071,7 +1071,15 @@ class CheckoutWebViewTests: XCTestCase {

private func resetOriginValidationConfig() {
ShopifyCheckoutKit.configuration.allowedMessageOrigins = []
ShopifyCheckoutKit.configuration.onMessageRejected = nil
}

/// Captures rejection logs at the default `.warn` level, verifying that
/// dropped messages surface without opting into debug logging.
private func captureWarnLogs() -> (logger: TestableOSLogger, restore: () -> Void) {
let originalLogger = OSLogger.shared
let logger = TestableOSLogger(prefix: "ShopifyCheckoutKit", logLevel: .warn)
OSLogger.shared = logger.logger
return (logger, { OSLogger.shared = originalLogger })
}

private func stubMessageOrigin(_ origin: String) {
Expand Down Expand Up @@ -1121,16 +1129,18 @@ class CheckoutWebViewTests: XCTestCase {
view.loadedCheckoutURL = url
stubMessageOrigin("https://evil.example.com")
ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"]
let rejection = LockedValue<MessageRejection?>(nil)
ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) }
let (logger, restoreLogger) = captureWarnLogs()
defer { restoreLogger() }
let message = MockScriptMessage(body: Self.readyBody)

view.userContentController(WKUserContentController(), didReceive: message)

XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
XCTAssertEqual(rejection.get()?.origin, "https://evil.example.com")
XCTAssertEqual(rejection.get()?.message, Self.readyBody)
XCTAssertEqual(rejection.get()?.reason, "origin is not in the allowlist")
let combinedLogs = logger.capturedMessages.map(\.message).joined(separator: "\n")
XCTAssertTrue(combinedLogs.contains("(Warning)"))
XCTAssertTrue(combinedLogs.contains("https://evil.example.com"))
XCTAssertTrue(combinedLogs.contains("origin is not in the allowlist"))
XCTAssertFalse(combinedLogs.contains(Self.readyBody))
}

@MainActor
Expand All @@ -1140,37 +1150,39 @@ class CheckoutWebViewTests: XCTestCase {
stubMessageOrigin("https://trusted.example.com")
view.messageRequestURL = { _ in URL(string: "https://trusted.example.com:0")! }
ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"]
let rejection = LockedValue<MessageRejection?>(nil)
ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) }
let (logger, restoreLogger) = captureWarnLogs()
defer { restoreLogger() }

view.userContentController(
WKUserContentController(),
didReceive: MockScriptMessage(body: Self.readyBody)
)

XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
XCTAssertEqual(rejection.get()?.origin, "https://trusted.example.com")
XCTAssertEqual(rejection.get()?.message, Self.readyBody)
XCTAssertEqual(rejection.get()?.reason, "origin uses unsupported port 0")
let combinedLogs = logger.capturedMessages.map(\.message).joined(separator: "\n")
XCTAssertTrue(combinedLogs.contains("https://trusted.example.com"))
XCTAssertTrue(combinedLogs.contains("origin uses unsupported port 0"))
}

@MainActor
func testOriginValidationRejectsChildFrameMessages() {
func testOriginValidationIgnoresChildFrameMessages() {
defer { resetOriginValidationConfig() }
view.client = nil
stubMessageOrigin("https://checkout.example.com")
view.messageIsMainFrame = { _ in false }
let rejection = LockedValue<MessageRejection?>(nil)
ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) }
let originalLogger = OSLogger.shared
let logger = TestableOSLogger(prefix: "ShopifyCheckoutKit", logLevel: .debug)
OSLogger.shared = logger.logger
defer { OSLogger.shared = originalLogger }

view.userContentController(
WKUserContentController(),
didReceive: MockScriptMessage(body: Self.readyBody)
)

XCTAssertFalse(MockCheckoutBridge.sendResponseCalled)
XCTAssertEqual(rejection.get()?.message, Self.readyBody)
XCTAssertEqual(rejection.get()?.reason, "message was sent from a child frame")
let combinedLogs = logger.capturedMessages.map(\.message).joined(separator: "\n")
XCTAssertTrue(combinedLogs.contains("Ignoring checkout message from a child frame."))
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,6 @@ class ConfigurationTests: XCTestCase {
XCTAssertEqual(ShopifyCheckoutKit.configuration.allowedMessageOrigins, ["https://example.com", "*"])
}

func testOnMessageRejectedDefaultsToNil() {
XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected)
}

func testOnMessageRejectedCanBeSet() {
ShopifyCheckoutKit.configuration.onMessageRejected = { _ in }
XCTAssertNotNil(ShopifyCheckoutKit.configuration.onMessageRejected)
}

func testPreloadingCanBeDisabled() async throws {
let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123"))

Expand Down
Loading
Loading