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 15830e24f..fc96e61e9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -272,6 +272,22 @@ 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 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 } + /// 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 +389,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 @@ -450,7 +470,15 @@ 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) if isPreload, ShopifyCheckoutKit.configuration.preloading.enabled { @@ -528,6 +556,21 @@ extension CheckoutWebView: WKScriptMessageHandler { return } + guard messageIsMainFrame(message) else { + rejectMessage(message, body: body, reason: "message was sent from a child frame") + return + } + + guard !shouldRejectExplicitPortZero(message) else { + rejectMessage(message, body: body, reason: "origin uses unsupported port 0") + return + } + + guard isMessageOriginAllowed(message) else { + rejectMessage(message, body: body, reason: "origin is not in the allowlist") + return + } + guard let method = CheckoutProtocol.supportedProtocolMethod(body) else { if isTerminalProtocolError(body) { handleTerminalProtocolError(body, malformedEnvelope: true) @@ -611,6 +654,45 @@ private struct TerminalErrorNotification: Decodable { let params: JSONRPCErrorParams } +extension CheckoutWebView { + private func rejectMessage(_ message: WKScriptMessage, body: String, reason: String) { + let rejection = MessageRejection( + origin: messageOrigin(message).description, + message: body, + reason: reason + ) + let onRejected = ShopifyCheckoutKit.configuration.onMessageRejected ?? { rejection in + OSLogger.shared.debug("Rejected checkout message from \(rejection.origin): \(rejection.reason)") + } + onRejected(rejection) + } + + /// Validates the origin of an incoming checkout message against the effective + /// allowlist. When validation is disabled (native default with no configured + /// allowlist, or the `"*"` escape hatch) the message origin is not inspected. + func isMessageOriginAllowed(_ message: WKScriptMessage) -> Bool { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins, + checkoutURL: loadedCheckoutURL + ) + guard let patterns else { return true } + + return MessageOriginValidator.isAllowed(origin: messageOrigin(message), patterns: patterns) + } + + /// `WKSecurityOrigin` reports both an omitted port and an explicit port 0 as + /// zero. Use the frame request URL to reject the explicit form when origin + /// validation is enabled, while preserving native's open-by-default behavior. + private func shouldRejectExplicitPortZero(_ message: WKScriptMessage) -> Bool { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins, + checkoutURL: loadedCheckoutURL + ) + guard patterns != nil else { return false } + return messageRequestURL(message)?.port == 0 + } +} + extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { // Handle rare cases where the url is nil @@ -635,6 +717,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) } @@ -801,3 +891,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/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index f6483831f..0c443a7b7 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -43,6 +43,28 @@ 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). + /// + /// 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 new file mode 100644 index 000000000..ec65bbde3 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -0,0 +1,190 @@ +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 + 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.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + self.port = Self.normalizedPort(scheme: scheme.lowercased(), 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 { + let serializedHost = host.contains(":") ? "[\(host)]" : host + if let 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 + } + } +} + +/// 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[.. (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) } + 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") + } + + @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() } + view.client = nil + stubMessageOrigin("https://checkout.example.com") + view.messageIsMainFrame = { _ in false } + let rejection = LockedValue(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 + 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 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 + 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..214ed1a18 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -46,8 +46,26 @@ 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")) + let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) ShopifyCheckoutKit.preload(checkout: checkoutURL) ShopifyCheckoutKit.configuration.preloading.enabled = false @@ -61,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 new file mode 100644 index 000000000..121698228 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift @@ -0,0 +1,202 @@ +@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 testExactOriginWithTrailingSlashMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com/", origin: origin)) + } + + func testExactOriginRejectsURLComponentsBeyondOrigin() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + let invalidPatterns = [ + "https://user@example.com", + "https://example.com/path", + "https://example.com?query=value", + "https://example.com#fragment" + ] + + for pattern in invalidPatterns { + XCTAssertFalse(MessageOriginValidator.matches(pattern: pattern, origin: origin), pattern) + } + } + + 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)) + } + + 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() { + let origin = MessageOrigin(scheme: "https", host: "a.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardWithTrailingSlashMatchesProperSubdomain() { + 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") + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index 7c4b806c6..a644ed36a 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() @@ -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 } 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",