diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 9a12100b0..df173d7a3 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -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`. @@ -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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 5ff588774..d537654f7 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -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 } @@ -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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index 0c443a7b7..594b16796 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -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 { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift index ec65bbde3..abc95d22b 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -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 diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 52d71fb6d..b56d44f98 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -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) { @@ -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(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 @@ -1140,8 +1150,8 @@ 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(nil) - ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + let (logger, restoreLogger) = captureWarnLogs() + defer { restoreLogger() } view.userContentController( WKUserContentController(), @@ -1149,19 +1159,21 @@ class CheckoutWebViewTests: XCTestCase { ) 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(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(), @@ -1169,8 +1181,8 @@ class CheckoutWebViewTests: XCTestCase { ) 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 diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 214ed1a18..19debd3cc 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -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")) diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 3e64a6ff6..a3c1fd074 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -4626,160 +4626,6 @@ } ] }, - { - "kind": "Var", - "name": "onMessageRejected", - "printedName": "onMessageRejected", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", - "children": [ - { - "kind": "TypeNameAlias", - "name": "Void", - "printedName": "Swift.Void", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - } - ] - }, - { - "kind": "TypeNominal", - "name": "MessageRejection", - "printedName": "ShopifyCheckoutKit.MessageRejection", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" - } - ] - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvp", - "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvp", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasInitialValue", - "HasStorage" - ], - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", - "children": [ - { - "kind": "TypeNameAlias", - "name": "Void", - "printedName": "Swift.Void", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - } - ] - }, - { - "kind": "TypeNominal", - "name": "MessageRejection", - "printedName": "ShopifyCheckoutKit.MessageRejection", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" - } - ] - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvg", - "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvg", - "moduleName": "ShopifyCheckoutKit", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - }, - { - "kind": "Accessor", - "name": "Set", - "printedName": "Set()", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - }, - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", - "children": [ - { - "kind": "TypeNameAlias", - "name": "Void", - "printedName": "Swift.Void", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - } - ] - }, - { - "kind": "TypeNominal", - "name": "MessageRejection", - "printedName": "ShopifyCheckoutKit.MessageRejection", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" - } - ] - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvs", - "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvs", - "moduleName": "ShopifyCheckoutKit", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "set" - } - ] - }, { "kind": "TypeDecl", "name": "Appearance", @@ -6710,221 +6556,6 @@ } ] }, - { - "kind": "TypeDecl", - "name": "MessageRejection", - "printedName": "MessageRejection", - "children": [ - { - "kind": "Var", - "name": "origin", - "printedName": "origin", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6originSSvp", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6originSSvp", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6originSSvg", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6originSSvg", - "moduleName": "ShopifyCheckoutKit", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "message", - "printedName": "message", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV7messageSSvp", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV7messageSSvp", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV7messageSSvg", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV7messageSSvg", - "moduleName": "ShopifyCheckoutKit", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "reason", - "printedName": "reason", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6reasonSSvp", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6reasonSSvp", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6reasonSSvg", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6reasonSSvg", - "moduleName": "ShopifyCheckoutKit", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(origin:message:reason:)", - "children": [ - { - "kind": "TypeNominal", - "name": "MessageRejection", - "printedName": "ShopifyCheckoutKit.MessageRejection", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" - }, - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - }, - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - }, - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Constructor", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6origin7message6reasonACSS_S2Stcfc", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6origin7message6reasonACSS_S2Stcfc", - "moduleName": "ShopifyCheckoutKit", - "init_kind": "Designated" - } - ], - "declKind": "Struct", - "usr": "s:18ShopifyCheckoutKit16MessageRejectionV", - "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV", - "moduleName": "ShopifyCheckoutKit", - "conformances": [ - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - }, { "kind": "TypeDecl", "name": "MetaData",