From 1dcc9bd5ab83931adf268c8ab03d00df139c56f9 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 14 Aug 2026 12:02:04 +0100 Subject: [PATCH] Centralize Swift checkout message ingress validation --- platforms/swift/README.md | 7 +- .../CheckoutMessageIngressPolicy.swift | 58 ++++++++++++ .../CheckoutMessageRejection.swift | 33 +++++++ .../ShopifyCheckoutKit/CheckoutWebView.swift | 81 ++++++++--------- .../CheckoutMessageIngressPolicyTests.swift | 90 +++++++++++++++++++ .../CheckoutWebViewTests.swift | 22 +++-- .../PreloadCacheTests.swift | 45 ++++++++++ 7 files changed, 281 insertions(+), 55 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageIngressPolicy.swift create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageRejection.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutMessageIngressPolicyTests.swift diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 081a6ab24..53e7a0d2c 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -289,9 +289,10 @@ 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 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. +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. The message +body is untrusted and is not logged. ### Current configuration diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageIngressPolicy.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageIngressPolicy.swift new file mode 100644 index 000000000..e223fc982 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageIngressPolicy.swift @@ -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 + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageRejection.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageRejection.swift new file mode 100644 index 000000000..144cec1c2 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutMessageRejection.swift @@ -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" + } + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index d537654f7..de597e96f 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -608,25 +608,48 @@ private final class ScriptMessageHandlerRegistration { } } +extension CheckoutMessageIngressPolicy { + /// Adapts WebKit's authenticated transport metadata into the policy's testable input model. + @MainActor + fileprivate func evaluate(_ message: WKScriptMessage, in webView: CheckoutWebView) -> Decision { + let resolveOrigin = webView.messageOrigin + let resolveRequestURL = webView.messageRequestURL + + return evaluate( + IncomingCheckoutMessage( + isMainFrame: webView.messageIsMainFrame(message), + resolveOrigin: { resolveOrigin(message) }, + resolveRequestURL: { resolveRequestURL(message) } + ) + ) + } +} + extension CheckoutWebView: WKScriptMessageHandler { func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? String else { return } - guard messageIsMainFrame(message) else { - // 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, reason: "origin uses unsupported port 0") - return - } + let ingressPolicy = CheckoutMessageIngressPolicy( + configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins, + checkoutURL: loadedCheckoutURL + ) - guard isMessageOriginAllowed(message) else { - rejectMessage(message, reason: "origin is not in the allowlist") + switch ingressPolicy.evaluate(message, in: self) { + case .accepted: + break + case let .rejected(rejection): + // Child frames are expected ambient traffic during payment flows. Keep them at debug + // while warning for origin-validation failures that integrators may need to diagnose. + switch rejection.reason { + case .childFrame: + OSLogger.shared.debug("Ignoring checkout message from a child frame.") + case .unsupportedPort, .originNotAllowed: + OSLogger.shared.warn( + "Rejected checkout message from \(rejection.origin): \(rejection.reason.logDescription)" + ) + } return } @@ -716,40 +739,6 @@ private struct TerminalErrorNotification: Decodable { let params: JSONRPCErrorParams } -extension CheckoutWebView { - /// 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 - /// 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 diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutMessageIngressPolicyTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutMessageIngressPolicyTests.swift new file mode 100644 index 000000000..81a492cde --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutMessageIngressPolicyTests.swift @@ -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 + } + ) + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index b56d44f98..22277d6df 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -1082,6 +1082,14 @@ class CheckoutWebViewTests: XCTestCase { return (logger, { OSLogger.shared = originalLogger }) } + /// Captures ambient child-frame drops, which remain debug-only to avoid noisy default logs. + private func captureDebugLogs() -> (logger: TestableOSLogger, restore: () -> Void) { + let originalLogger = OSLogger.shared + let logger = TestableOSLogger(prefix: "ShopifyCheckoutKit", logLevel: .debug) + OSLogger.shared = logger.logger + return (logger, { OSLogger.shared = originalLogger }) + } + private func stubMessageOrigin(_ origin: String) { let parsed = URL(string: origin)! view.messageOrigin = { _ in @@ -1141,6 +1149,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(combinedLogs.contains("https://evil.example.com")) XCTAssertTrue(combinedLogs.contains("origin is not in the allowlist")) XCTAssertFalse(combinedLogs.contains(Self.readyBody)) + XCTAssertNil(mockDelegate.errorReceived) } @MainActor @@ -1162,19 +1171,17 @@ class CheckoutWebViewTests: XCTestCase { let combinedLogs = logger.capturedMessages.map(\.message).joined(separator: "\n") XCTAssertTrue(combinedLogs.contains("https://trusted.example.com")) XCTAssertTrue(combinedLogs.contains("origin uses unsupported port 0")) + XCTAssertNil(mockDelegate.errorReceived) } @MainActor - func testOriginValidationIgnoresChildFrameMessages() { + func testOriginValidationRejectsChildFrameMessages() { defer { resetOriginValidationConfig() } view.client = nil stubMessageOrigin("https://checkout.example.com") view.messageIsMainFrame = { _ in false } - let originalLogger = OSLogger.shared - let logger = TestableOSLogger(prefix: "ShopifyCheckoutKit", logLevel: .debug) - OSLogger.shared = logger.logger - defer { OSLogger.shared = originalLogger } - + let (logger, restoreLogger) = captureDebugLogs() + defer { restoreLogger() } view.userContentController( WKUserContentController(), didReceive: MockScriptMessage(body: Self.readyBody) @@ -1182,7 +1189,10 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) let combinedLogs = logger.capturedMessages.map(\.message).joined(separator: "\n") + XCTAssertTrue(combinedLogs.contains("(Debug)")) XCTAssertTrue(combinedLogs.contains("Ignoring checkout message from a child frame.")) + XCTAssertFalse(combinedLogs.contains(Self.readyBody)) + XCTAssertNil(mockDelegate.errorReceived) } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index f14e9ea5a..473a64eff 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -24,6 +24,7 @@ class PreloadCacheTests: XCTestCase { override func tearDown() async throws { CheckoutWebView.invalidate() ShopifyCheckoutKit.configuration.preloading.enabled = true + ShopifyCheckoutKit.configuration.allowedMessageOrigins = [] try await super.tearDown() } @@ -104,6 +105,45 @@ class PreloadCacheTests: XCTestCase { XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) } + func test_MessageRejectionDoesNotFailBackgroundedPreload() { + let entry = storeCacheEntry() + entry.loadedCheckoutURL = url + entry.messageOrigin = { _ in + MessageOrigin(scheme: "https", host: "evil.example.com", port: nil) + } + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let delegate = MockCheckoutWebViewDelegate() + entry.viewDelegate = delegate + + entry.userContentController( + WKUserContentController(), + didReceive: MockScriptMessage(body: ecReadyBody()) + ) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + XCTAssertEqual(CheckoutWebView.preloadCache.state, .loading) + XCTAssertNil(delegate.errorReceived) + } + + func test_ChildFrameRejectionDoesNotFailBackgroundedPreload() { + let entry = storeCacheEntry() + entry.messageIsMainFrame = { _ in false } + entry.messageOrigin = { _ in + MessageOrigin(scheme: "https", host: "checkout.example.com", port: nil) + } + let delegate = MockCheckoutWebViewDelegate() + entry.viewDelegate = delegate + + entry.userContentController( + WKUserContentController(), + didReceive: MockScriptMessage(body: ecReadyBody()) + ) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + XCTAssertEqual(CheckoutWebView.preloadCache.state, .loading) + XCTAssertNil(delegate.errorReceived) + } + func test_TerminalErrorOnBackgroundedPreloadDoesNotDeliverLifecycleFailure() async { let entry = storeCacheEntry() let preload = CheckoutPreload(cache: CheckoutWebView.preloadCache) @@ -259,6 +299,7 @@ class PreloadCacheTests: XCTestCase { private func storeCacheEntry() -> CheckoutWebView { let entry = CheckoutWebView(entryPoint: nil) entry.messageIsMainFrame = { _ in true } + entry.messageRequestURL = { _ in nil } _ = CheckoutWebView.preloadCache.store(entry, for: PreloadKey(url: url, entryPoint: nil)) return entry } @@ -277,6 +318,10 @@ class PreloadCacheTests: XCTestCase { """ } + private func ecReadyBody() -> String { + #"{"jsonrpc":"2.0","method":"ec.ready","id":"r1","params":{"delegate":[]}}"# + } + private func preloadFailureExpectation(for preload: CheckoutPreload) -> XCTestExpectation { let failed = expectation(description: "preload transitions to protocol failure") preload.onStateChange = { state in