From 88dae6cc432a8876484120ca5268b5286163b667 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 12:15:32 -0400 Subject: [PATCH 1/5] feat: incoming message origin validation for ios --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 35 ++++ .../ShopifyCheckoutKit/Configuration.swift | 19 ++ .../MessageOriginValidator.swift | 168 ++++++++++++++++++ .../CheckoutWebViewTests.swift | 132 ++++++++++++++ .../ConfigurationTests.swift | 18 ++ .../MessageOriginValidatorTests.swift | 158 ++++++++++++++++ 6 files changed, 530 insertions(+) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 15830e24f..c6f114721 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -272,6 +272,12 @@ class CheckoutWebView: WKWebView { var openExternalURL: (URL) -> Void = { UIApplication.shared.open($0) } + /// Resolves the origin an incoming message was sent from. Overridable in + /// tests since `WKSecurityOrigin` cannot be constructed directly. + var messageOrigin: (WKScriptMessage) -> MessageOrigin = { message in + MessageOrigin(securityOrigin: message.frameInfo.securityOrigin) + } + /// Kit-owned client that handles delegations and kit-mandated notifications. Currently: /// - `ec.ready` - kit-owned handshake. Supported delegations are announced up /// front via the `ec_delegate` URL query param; acceptance is implicit, so the @@ -373,6 +379,10 @@ class CheckoutWebView: WKWebView { /// Ensures one terminal failure is handled per checkout session, regardless /// of whether it originated from `ec.error` or WebKit process termination. private var hasHandledTerminalFailure = false + + /// The checkout URL passed to `load(checkout:)`. Used to derive the trusted + /// cart-url origin for incoming message validation. + var loadedCheckoutURL: URL? private var entryPoint: MetaData.EntryPoint? // MARK: Initializers @@ -451,6 +461,7 @@ class CheckoutWebView: WKWebView { func load(checkout url: URL, isPreload: Bool = false) { OSLogger.shared.info("Loading checkout URL: \(LogSafeURL.string(url)), isPreload: \(isPreload)") + loadedCheckoutURL = url var request = URLRequest(url: url) if isPreload, ShopifyCheckoutKit.configuration.preloading.enabled { @@ -528,6 +539,15 @@ extension CheckoutWebView: WKScriptMessageHandler { return } + guard isMessageOriginAllowed(message) else { + let rejection = MessageRejection(origin: messageOrigin(message).description, body: body) + let onRejected = ShopifyCheckoutKit.configuration.onMessageRejected ?? { rejection in + OSLogger.shared.debug("Rejected checkout message from untrusted origin \(rejection.origin)") + } + onRejected(rejection) + return + } + guard let method = CheckoutProtocol.supportedProtocolMethod(body) else { if isTerminalProtocolError(body) { handleTerminalProtocolError(body, malformedEnvelope: true) @@ -611,6 +631,21 @@ private struct TerminalErrorNotification: Decodable { let params: JSONRPCErrorParams } +extension CheckoutWebView { + /// 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) + } +} + extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { // Handle rare cases where the url is nil diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index f6483831f..edd116688 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -43,6 +43,25 @@ public struct Configuration: Sendable { /// Levels: debug, warn, error, none (ordered threshold, most to least verbose) /// Default: .warn - which emits warnings and errors public var logLevel: LogLevel = .warn + + /// Origins that are trusted to send incoming checkout messages, in addition + /// to the loaded checkout origin and shop.app. + /// + /// The native surface is open by default: when this is empty, messages from + /// any origin are accepted. Provide one or more origins to restrict which + /// origins are trusted; the loaded checkout origin and shop.app are always + /// appended. Use `"*"` to explicitly disable origin validation. + /// + /// Entries are origin patterns: + /// - `"https://example.com"` — an exact origin. + /// - `"https://*.example.com"` — any subdomain of `example.com`. + /// - `"*"` — allow all origins (escape hatch). + 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 new file mode 100644 index 000000000..867190edd --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -0,0 +1,168 @@ +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 body: String + + public init(origin: String, body: String) { + self.origin = origin + self.body = body + } +} + +/// A normalized representation of a message origin (scheme + host + port). +struct MessageOrigin: Equatable { + let scheme: String + let host: String + /// The explicit port, or `nil` when the scheme's default port is used. + let port: Int? + + init(scheme: String, host: String, port: Int?) { + self.scheme = scheme.lowercased() + self.host = host.lowercased() + self.port = port + } + + init?(url: URL) { + guard let scheme = url.scheme, let host = url.host else { return nil } + self.init(scheme: scheme, host: host, port: url.port) + } + + @MainActor + init(securityOrigin: WKSecurityOrigin) { + // WKSecurityOrigin reports 0 when the default port for the scheme is used. + let normalizedPort = securityOrigin.port == 0 ? nil : securityOrigin.port + self.init(scheme: securityOrigin.protocol, host: securityOrigin.host, port: normalizedPort) + } + + /// The port used for comparison, resolving default ports for known schemes. + var effectivePort: Int? { + if let port { return port } + switch scheme { + case "https": return 443 + case "http": return 80 + default: return nil + } + } + + var description: String { + if let port { + return "\(scheme)://\(host):\(port)" + } + return "\(scheme)://\(host)" + } +} + +/// Computes the effective allowlist for incoming checkout messages and matches +/// origins against origin patterns. +/// +/// Native surfaces are open by default: an empty configured allowlist accepts +/// messages from any origin. When a merchant provides an allowlist, only the +/// configured origins plus the loaded checkout origin and shop.app are trusted. +/// `"*"` disables validation entirely. +enum MessageOriginValidator { + /// The escape hatch pattern that disables validation. + static let allowAllPattern = "*" + + /// shop.app is trusted by default; both the apex and its subdomains are allowed + /// so regional/checkout subdomains work without extra configuration. + static let shopAppOriginPatterns = ["https://shop.app", "https://*.shop.app"] + + /// Returns the effective allowlist patterns, or `nil` when validation is + /// disabled (allow all). + /// + /// - Parameters: + /// - configuredOrigins: Merchant-supplied allowlist entries. + /// - checkoutURL: The loaded checkout URL, whose origin is always trusted. + static func effectiveAllowlist( + configuredOrigins: [String], + checkoutURL: URL? + ) -> [String]? { + if configuredOrigins.contains(allowAllPattern) { + return nil + } + // Native surface: no allowlist means allow all origins. + if configuredOrigins.isEmpty { + return nil + } + + var patterns = configuredOrigins + if let checkoutURL, let origin = MessageOrigin(url: checkoutURL) { + patterns.append(origin.description) + } + patterns.append(contentsOf: shopAppOriginPatterns) + return patterns + } + + /// Whether `origin` matches any pattern in `patterns`. A `nil` list means + /// validation is disabled and every origin is allowed. + static func isAllowed(origin: MessageOrigin, patterns: [String]?) -> Bool { + guard let patterns else { return true } + return patterns.contains { matches(pattern: $0, origin: origin) } + } + + /// Matches a single origin pattern against a target origin. + /// + /// - `"*"` matches any origin. + /// - `"https://*.example.com"` matches proper subdomains of `example.com` + /// (not the apex), with matching scheme and port. + /// - `"https://example.com"` matches the exact origin. + /// + /// Invalid patterns return `false` (skipped). + static func matches(pattern: String, origin: MessageOrigin) -> Bool { + if pattern == allowAllPattern { return true } + + guard let parsed = parse(pattern: pattern) else { return false } + guard parsed.scheme == origin.scheme else { return false } + + let patternPort = MessageOrigin(scheme: parsed.scheme, host: parsed.host, port: parsed.port).effectivePort + guard patternPort == origin.effectivePort else { return false } + + if parsed.isWildcard { + return origin.host.hasSuffix(".\(parsed.host)") && origin.host != parsed.host + } + return origin.host == parsed.host + } + + private struct ParsedPattern { + let scheme: String + let host: String + let port: Int? + let isWildcard: Bool + } + + private static func parse(pattern: String) -> ParsedPattern? { + guard let schemeSeparator = pattern.range(of: "://") else { return nil } + let scheme = String(pattern[..(nil) + ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + 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()?.body, Self.readyBody) + } + + @MainActor + func testOriginValidationAllowsConfiguredOrigin() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://trusted.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationAllowsCheckoutOriginWhenAllowlistSet() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + // url is http://shopify1.shopify.com/checkouts/cn/123 + stubMessageOrigin("http://shopify1.shopify.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationAllowsShopAppSubdomainWhenAllowlistSet() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://checkout.shop.app") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationStarEscapeHatchAllowsAllOrigins() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://evil.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["*"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationDefaultRejectionLogsWithoutCrashing() { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://evil.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + } } private actor RecordingBridgeClient: CheckoutCommunicationProtocol { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 9b9bd8ec5..85c3faf7b 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -46,6 +46,24 @@ class ConfigurationTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, .storefront) } + func testAllowedMessageOriginsDefaultsToEmpty() { + XCTAssertEqual(ShopifyCheckoutKit.configuration.allowedMessageOrigins, []) + } + + func testAllowedMessageOriginsCanBeSet() { + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://example.com", "*"] + 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: "http://shopify1.shopify.com/checkouts/cn/123")) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift new file mode 100644 index 000000000..e653e20cc --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift @@ -0,0 +1,158 @@ +@testable import ShopifyCheckoutKit +import WebKit +import XCTest + +final class MessageOriginValidatorTests: XCTestCase { + private let checkoutURL = URL(string: "https://checkout.example.com/checkouts/cn/123")! + + // MARK: - effectiveAllowlist + + func testEmptyAllowlistAllowsAllOnNativeSurface() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: [], + checkoutURL: checkoutURL + ) + XCTAssertNil(patterns) + } + + func testStarAllowlistAllowsAll() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["*"], + checkoutURL: checkoutURL + ) + XCTAssertNil(patterns) + } + + func testAllowlistAppendsCheckoutOriginAndShopApp() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["https://merchant.example.com"], + checkoutURL: checkoutURL + ) + XCTAssertEqual(patterns, [ + "https://merchant.example.com", + "https://checkout.example.com", + "https://shop.app", + "https://*.shop.app" + ]) + } + + func testAllowlistWithoutCheckoutURLStillIncludesShopApp() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["https://merchant.example.com"], + checkoutURL: nil + ) + XCTAssertEqual(patterns, [ + "https://merchant.example.com", + "https://shop.app", + "https://*.shop.app" + ]) + } + + // MARK: - matches (exact origin) + + func testExactOriginMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginRejectsDifferentHost() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginRejectsDifferentScheme() { + let origin = MessageOrigin(scheme: "http", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginRejectsSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "sub.example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + // MARK: - matches (default vs explicit port) + + func testDefaultPortMatchesOmittedPort() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 443) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testNonDefaultPortMismatchIsRejected() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 8443) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExplicitPortMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 8443) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com:8443", origin: origin)) + } + + // MARK: - matches (wildcard subdomain) + + func testWildcardMatchesProperSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "a.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardMatchesNestedSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "a.b.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardRejectsApex() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardRejectsUnrelatedSuffix() { + let origin = MessageOrigin(scheme: "https", host: "notexample.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + // MARK: - matches (escape hatch & invalid) + + func testStarPatternMatchesAnything() { + let origin = MessageOrigin(scheme: "http", host: "anything.test", port: 9999) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "*", origin: origin)) + } + + func testInvalidPatternIsSkipped() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "not-a-valid-origin", origin: origin)) + } + + // MARK: - isAllowed + + func testIsAllowedReturnsTrueWhenPatternsNil() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertTrue(MessageOriginValidator.isAllowed(origin: origin, patterns: nil)) + } + + func testIsAllowedMatchesAnyPattern() { + let origin = MessageOrigin(scheme: "https", host: "sub.shop.app", port: nil) + XCTAssertTrue(MessageOriginValidator.isAllowed( + origin: origin, + patterns: ["https://merchant.example.com", "https://shop.app", "https://*.shop.app"] + )) + } + + func testIsAllowedRejectsWhenNoPatternMatches() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertFalse(MessageOriginValidator.isAllowed( + origin: origin, + patterns: ["https://merchant.example.com", "https://shop.app", "https://*.shop.app"] + )) + } + + // MARK: - MessageOrigin + + func testMessageOriginFromURLDropsDefaultPort() throws { + let origin = try MessageOrigin(url: XCTUnwrap(URL(string: "https://example.com/path?x=1"))) + XCTAssertEqual(origin?.description, "https://example.com") + } + + func testMessageOriginFromURLKeepsExplicitPort() throws { + let origin = try MessageOrigin(url: XCTUnwrap(URL(string: "https://example.com:8443/path"))) + XCTAssertEqual(origin?.description, "https://example.com:8443") + } +} From 8d28621e9bc69876c81adbec42f17ffc6461f339 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 13:20:57 +0200 Subject: [PATCH 2/5] fix(swift): harden checkout origin validation --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 52 +- .../MessageOriginValidator.swift | 55 ++- .../CheckoutWebViewControllerTests.swift | 2 +- .../CheckoutWebViewTests.swift | 78 ++- .../ConfigurationTests.swift | 4 +- .../MessageOriginValidatorTests.swift | 20 + .../PreloadCacheTests.swift | 2 +- .../PreloadObservabilityTests.swift | 12 +- platforms/swift/api/ShopifyCheckoutKit.json | 466 ++++++++++++++++++ 9 files changed, 650 insertions(+), 41 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index c6f114721..6d05476d3 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -272,12 +272,18 @@ class CheckoutWebView: WKWebView { var openExternalURL: (URL) -> Void = { UIApplication.shared.open($0) } + /// Resolves whether a navigation targets the main frame. Overridable in tests. + var navigationIsMainFrame: (WKNavigationAction) -> Bool = { $0.targetFrame?.isMainFrame == true } + /// Resolves the origin an incoming message was sent from. Overridable in /// tests since `WKSecurityOrigin` cannot be constructed directly. var messageOrigin: (WKScriptMessage) -> MessageOrigin = { message in MessageOrigin(securityOrigin: message.frameInfo.securityOrigin) } + /// Resolves whether an incoming message came from the main frame. Overridable in tests. + var messageIsMainFrame: (WKScriptMessage) -> Bool = { $0.frameInfo.isMainFrame } + /// Kit-owned client that handles delegations and kit-mandated notifications. Currently: /// - `ec.ready` - kit-owned handshake. Supported delegations are announced up /// front via the `ec_delegate` URL query param; acceptance is implicit, so the @@ -460,6 +466,13 @@ class CheckoutWebView: WKWebView { // MARK: - func load(checkout url: URL, isPreload: Bool = false) { + guard url.scheme?.lowercased() == "https", url.host != nil else { + handleCachedViewFailure(.navigationFailed) + viewDelegate?.checkoutViewDidFailWithError( + error: .sdkError(underlying: InsecureCheckoutURLError(url: url)) + ) + return + } OSLogger.shared.info("Loading checkout URL: \(LogSafeURL.string(url)), isPreload: \(isPreload)") loadedCheckoutURL = url var request = URLRequest(url: url) @@ -539,12 +552,13 @@ extension CheckoutWebView: WKScriptMessageHandler { return } + guard messageIsMainFrame(message) else { + rejectMessage(message, body: body, reason: "message was sent from a child frame") + return + } + guard isMessageOriginAllowed(message) else { - let rejection = MessageRejection(origin: messageOrigin(message).description, body: body) - let onRejected = ShopifyCheckoutKit.configuration.onMessageRejected ?? { rejection in - OSLogger.shared.debug("Rejected checkout message from untrusted origin \(rejection.origin)") - } - onRejected(rejection) + rejectMessage(message, body: body, reason: "origin is not in the allowlist") return } @@ -632,6 +646,18 @@ 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) + } + /// 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. @@ -670,6 +696,14 @@ extension CheckoutWebView: WKNavigationDelegate { } } + if navigationIsMainFrame(action), url.scheme?.lowercased() != "https" { + handleCachedViewFailure(.navigationFailed) + viewDelegate?.checkoutViewDidFailWithError( + error: .sdkError(underlying: InsecureCheckoutURLError(url: url)) + ) + return decisionHandler(.cancel) + } + decisionHandler(.allow) } @@ -836,3 +870,11 @@ extension CheckoutWebView: WKNavigationDelegate { return self.url == url } } + +private struct InsecureCheckoutURLError: LocalizedError { + let url: URL + + var errorDescription: String? { + "Checkout requires an HTTPS URL: \(LogSafeURL.string(url))" + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift index 867190edd..9a42d6775 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -7,11 +7,14 @@ 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 body: String + public let message: String + /// Human-readable reason the message was rejected. + public let reason: String - public init(origin: String, body: String) { + public init(origin: String, message: String, reason: String) { self.origin = origin - self.body = body + self.message = message + self.reason = reason } } @@ -24,8 +27,8 @@ struct MessageOrigin: Equatable { init(scheme: String, host: String, port: Int?) { self.scheme = scheme.lowercased() - self.host = host.lowercased() - self.port = port + self.host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + self.port = Self.normalizedPort(scheme: scheme.lowercased(), port: port) } init?(url: URL) { @@ -51,10 +54,18 @@ struct MessageOrigin: Equatable { } var description: String { + let serializedHost = host.contains(":") ? "[\(host)]" : host if let port { - return "\(scheme)://\(host):\(port)" + return "\(scheme)://\(serializedHost):\(port)" + } + return "\(scheme)://\(serializedHost)" + } + + private static func normalizedPort(scheme: String, port: Int?) -> Int? { + switch (scheme, port) { + case ("https", 443), ("http", 80): nil + default: port } - return "\(scheme)://\(host)" } } @@ -147,15 +158,7 @@ enum MessageOriginValidator { } guard !authority.isEmpty else { return nil } - var host = authority - var port: Int? - if let colon = authority.lastIndex(of: ":") { - let portString = String(authority[authority.index(after: colon)...]) - if let parsedPort = Int(portString) { - port = parsedPort - host = String(authority[.. (host: String, port: Int?)? { + if authority.hasPrefix("[") { + guard let closingBracket = authority.firstIndex(of: "]") else { return nil } + let host = String(authority[authority.index(after: authority.startIndex) ..< closingBracket]) + let remainder = authority[authority.index(after: closingBracket)...] + guard !remainder.isEmpty else { return (host, nil) } + guard remainder.first == ":", let port = Int(remainder.dropFirst()) else { return nil } + return (host, port) + } + + if let colon = authority.lastIndex(of: ":") { + guard authority[..(nil) + ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + + 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") } @MainActor @@ -1071,8 +1121,8 @@ class CheckoutWebViewTests: XCTestCase { defer { resetOriginValidationConfig() } view.client = nil view.loadedCheckoutURL = url - // url is http://shopify1.shopify.com/checkouts/cn/123 - stubMessageOrigin("http://shopify1.shopify.com") + // url is https://shopify1.shopify.com/checkouts/cn/123 + stubMessageOrigin("https://shopify1.shopify.com") ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] let responseSent = expectation(description: "response sent") MockCheckoutBridge.sendResponseExpectation = responseSent diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 85c3faf7b..214ed1a18 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -65,7 +65,7 @@ class ConfigurationTests: XCTestCase { } func testPreloadingCanBeDisabled() async throws { - let checkoutURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) ShopifyCheckoutKit.preload(checkout: checkoutURL) ShopifyCheckoutKit.configuration.preloading.enabled = false @@ -79,7 +79,7 @@ class ConfigurationTests: XCTestCase { } func testChangingConfigurationWithoutChangingPreloadingDoesNotInvalidatePreload() async throws { - let checkoutURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) ShopifyCheckoutKit.preload(checkout: checkoutURL) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift index e653e20cc..eab0e868b 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift @@ -87,6 +87,26 @@ final class MessageOriginValidatorTests: XCTestCase { XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com:8443", origin: origin)) } + func testExplicitDefaultPortMatchesOmittedPort() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com:443", origin: origin)) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.org:443", origin: MessageOrigin( + scheme: "https", + host: "sub.example.org", + port: nil + ))) + } + + func testBracketedIPv6OriginsWithDefaultAndExplicitPorts() { + let defaultPortOrigin = MessageOrigin(scheme: "https", host: "2001:db8::1", port: nil) + let explicitPortOrigin = MessageOrigin(scheme: "https", host: "2001:db8::2", port: 8443) + + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://[2001:db8::1]:443", origin: defaultPortOrigin)) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://[2001:db8::2]:8443", origin: explicitPortOrigin)) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://[2001:db8::2]", origin: explicitPortOrigin)) + XCTAssertEqual(defaultPortOrigin.description, "https://[2001:db8::1]") + } + // MARK: - matches (wildcard subdomain) func testWildcardMatchesProperSubdomain() { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index 7c4b806c6..de82bc87e 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -13,7 +13,7 @@ import XCTest /// leave the slot untouched. @MainActor class PreloadCacheTests: XCTestCase { - private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! override func setUp() async throws { try await super.setUp() diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index b56e1e3e4..b1e0ddae4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -6,7 +6,7 @@ import XCTest @MainActor class PreloadObservabilityTests: XCTestCase { - private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! override func setUp() async throws { try await super.setUp() @@ -28,6 +28,14 @@ class PreloadObservabilityTests: XCTestCase { } } + func testHTTPPreloadTransitionsToNavigationFailure() throws { + let insecureURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + + let preload = ShopifyCheckoutKit.preload(checkout: insecureURL) + + XCTAssertEqual(preload?.state, .failed(reason: .navigationFailed)) + } + func testManualInvalidateTransitionsToIdle() { let preload = ShopifyCheckoutKit.preload(checkout: url) @@ -188,7 +196,7 @@ class PreloadObservabilityTests: XCTestCase { for: PreloadKey(url: url, entryPoint: nil) ) - let otherURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/other")) + let otherURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/other")) _ = CheckoutWebView.preloadCache.view(for: PreloadKey(url: otherURL, entryPoint: nil)) withExtendedLifetime(preload) { diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 81809842e..b2c0349e2 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -4406,6 +4406,257 @@ } ] }, + { + "kind": "Var", + "name": "allowedMessageOrigins", + "printedName": "allowedMessageOrigins", + "children": [ + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvp", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvg", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvs", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "set" + } + ] + }, + { + "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", @@ -6336,6 +6587,221 @@ } ] }, + { + "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", From 3323e56a1e7cf6689ea60ad10feecc50863e7adb Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 13:49:58 +0200 Subject: [PATCH 3/5] fix(swift): require configured origins --- .../ShopifyCheckoutKit/Configuration.swift | 3 +++ .../MessageOriginValidator.swift | 13 +++++----- .../MessageOriginValidatorTests.swift | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index edd116688..0c443a7b7 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -56,6 +56,9 @@ public struct Configuration: Sendable { /// - `"https://example.com"` — an exact origin. /// - `"https://*.example.com"` — any subdomain of `example.com`. /// - `"*"` — allow all origins (escape hatch). + /// + /// 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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift index 9a42d6775..ec65bbde3 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -150,16 +150,15 @@ enum MessageOriginValidator { private static func parse(pattern: String) -> ParsedPattern? { guard let schemeSeparator = pattern.range(of: "://") else { return nil } let scheme = String(pattern[.. Date: Wed, 5 Aug 2026 12:26:25 +0200 Subject: [PATCH 4/5] fix(swift): reject explicit port zero origins --- platforms/swift/README.md | 45 +++++++++++++++++++ .../ShopifyCheckoutKit/CheckoutWebView.swift | 21 +++++++++ .../CheckoutWebViewTests.swift | 45 +++++++++++++++++-- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 6aef9556d..c13e3ef21 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -22,6 +22,7 @@ - [SwiftUI](#swiftui) - [Preload checkout](#preload-checkout) - [Configure checkout](#configure-checkout) + - [Incoming message origin validation](#incoming-message-origin-validation) - [Current configuration](#current-configuration) - [Checkout lifecycle](#checkout-lifecycle) - [Error handling](#error-handling) @@ -221,9 +222,53 @@ ShopifyCheckoutKit.configure { | `closeButtonTintColor` | `nil` | Optional tint for the close button. | | `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`. +### Incoming message origin validation + +The native web view is a private, app-controlled runtime, so Checkout Kit is +**open by default**: with an empty `allowedMessageOrigins`, incoming +checkout-protocol messages from any origin are accepted. Provide one or more +origins to restrict which origins are trusted; the loaded checkout origin and +`shop.app` (including its subdomains) are always trusted as well. + +```swift +ShopifyCheckoutKit.configure { + $0.allowedMessageOrigins = [ + "https://checkout.example.com", + "https://*.example.com", + ] +} +``` + +Each entry may be an exact origin (`https://example.com`), a wildcard subdomain +(`https://*.example.com`, matching subdomains but not the apex), or `"*"` to +explicitly trust every origin. + +Exact and wildcard entries accept an optional trailing slash. Exact entries +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. + ### Current configuration ```swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 6d05476d3..fc96e61e9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -281,6 +281,10 @@ class CheckoutWebView: WKWebView { MessageOrigin(securityOrigin: message.frameInfo.securityOrigin) } + /// Resolves the request URL associated with an incoming message. Overridable + /// in tests since `WKFrameInfo` cannot be constructed directly. + var messageRequestURL: (WKScriptMessage) -> URL? = { $0.frameInfo.request.url } + /// Resolves whether an incoming message came from the main frame. Overridable in tests. var messageIsMainFrame: (WKScriptMessage) -> Bool = { $0.frameInfo.isMainFrame } @@ -557,6 +561,11 @@ extension CheckoutWebView: WKScriptMessageHandler { return } + guard !shouldRejectExplicitPortZero(message) else { + rejectMessage(message, body: body, reason: "origin uses unsupported port 0") + return + } + guard isMessageOriginAllowed(message) else { rejectMessage(message, body: body, reason: "origin is not in the allowlist") return @@ -670,6 +679,18 @@ extension CheckoutWebView { 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 CheckoutWebView: WKNavigationDelegate { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 6dfc61015..3c1da81ea 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -110,9 +110,9 @@ class CheckoutWebViewTests: XCTestCase { view.load(checkout: insecureURL) wait(for: [didFail], timeout: 2.0) - guard case let .sdkError(underlying) = mockDelegate.errorReceived else { - return XCTFail("Expected sdkError") - } + let error = try XCTUnwrap(mockDelegate.errorReceived) + XCTAssertEqual(error.code, .sdkError) + let underlying = try XCTUnwrap(error.underlyingError) XCTAssertTrue(underlying.localizedDescription.contains("requires an HTTPS URL")) } @@ -1061,6 +1061,24 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) } + @MainActor + func testOriginValidationAllowsExplicitPortZeroByDefault() async { + defer { resetOriginValidationConfig() } + view.client = nil + stubMessageOrigin("https://example.com") + view.messageRequestURL = { _ in URL(string: "https://example.com:0")! } + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + + view.userContentController( + WKUserContentController(), + didReceive: MockScriptMessage(body: Self.readyBody) + ) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + @MainActor func testOriginValidationRejectsUntrustedOriginWhenAllowlistSet() { defer { resetOriginValidationConfig() } @@ -1080,6 +1098,27 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertEqual(rejection.get()?.reason, "origin is not in the allowlist") } + @MainActor + func testOriginValidationRejectsExplicitPortZeroWhenAllowlistSet() { + defer { resetOriginValidationConfig() } + view.client = nil + 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) } + + 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") + } + @MainActor func testOriginValidationRejectsChildFrameMessages() { defer { resetOriginValidationConfig() } From c94b042bdb021a1809f7b62801f4f8c830f3af76 Mon Sep 17 00:00:00 2001 From: Tiago Santos <2342727+tiagocandido@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:41:31 +0200 Subject: [PATCH 5/5] test(swift): model synthetic messages explicitly --- .../Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift | 1 + .../swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 3c1da81ea..26294508d 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -18,6 +18,7 @@ class CheckoutWebViewTests: XCTestCase { view.viewDelegate = mockDelegate view.checkoutBridge = MockCheckoutBridge.self view.messageIsMainFrame = { _ in true } + view.messageRequestURL = { _ in nil } MockCheckoutBridge.reset() } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index de82bc87e..a644ed36a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -203,6 +203,7 @@ class PreloadCacheTests: XCTestCase { func test_TerminalErrorOnForeignViewPreservesSlot() async { let entry = storeCacheEntry() let foreign = CheckoutWebView(entryPoint: nil) + foreign.messageIsMainFrame = { _ in true } let delegate = MockCheckoutWebViewDelegate() let didFail = expectation(description: "foreign view delegate receives failure") delegate.didFailWithErrorExpectation = didFail @@ -239,6 +240,7 @@ class PreloadCacheTests: XCTestCase { private func storeCacheEntry() -> CheckoutWebView { let entry = CheckoutWebView(entryPoint: nil) + entry.messageIsMainFrame = { _ in true } _ = CheckoutWebView.preloadCache.store(entry, for: PreloadKey(url: url, entryPoint: nil)) return entry }