From 88dae6cc432a8876484120ca5268b5286163b667 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 12:15:32 -0400 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 } From 32f5b48c3e94aaa3b2ced44699812a363865f0f5 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 13:19:00 -0400 Subject: [PATCH 06/11] feat: incoming message origin validation for react native --- .../checkoutkit/ShopifyCheckoutKitModule.java | 20 +++++++++++++++++++ .../ios/ShopifyCheckoutKit.swift | 4 ++++ .../checkout-kit-react-native/src/index.d.ts | 16 +++++++++++++++ .../src/specs/NativeShopifyCheckoutKit.ts | 1 + .../tests/index.test.ts | 12 +++++++++++ 5 files changed, 53 insertions(+) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 667b2a39e..ea34e7232 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -13,9 +13,11 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { @@ -131,6 +133,10 @@ public void setConfig(ReadableMap config) { configuration.setPreloading(new Preloading(config.getBoolean("preloading"))); } + if (config.hasKey("allowedMessageOrigins")) { + configuration.setAllowedMessageOrigins(toStringSet(config.getArray("allowedMessageOrigins"))); + } + if (config.hasKey("logLevel")) { LogLevel logLevel = getLogLevel(config.getString("logLevel")); configuration.setLogLevel(logLevel); @@ -216,6 +222,20 @@ private String colorSchemeToString(ColorScheme colorScheme) { return colorScheme.getId(); } + private Set toStringSet(ReadableArray array) { + Set values = new HashSet<>(); + if (array == null) { + return values; + } + for (int i = 0; i < array.size(); i++) { + String value = array.getString(i); + if (value != null) { + values.add(value); + } + } + return values; + } + private LogLevel getLogLevel(String logLevel) { if (logLevel == null) { return LogLevel.ERROR; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index a4b2767f7..5e0dbd4c7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -156,6 +156,10 @@ class RCTShopifyCheckoutKit: NSObject { ShopifyCheckoutKit.configuration.preloading.enabled = preloading } + if let allowedMessageOrigins = configuration["allowedMessageOrigins"] as? [String] { + ShopifyCheckoutKit.configuration.allowedMessageOrigins = allowedMessageOrigins + } + if let colorScheme = configuration["colorScheme"] as? String { ShopifyCheckoutKit.configuration.colorScheme = getColorScheme(colorScheme) } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 83f160bb1..64dfa3d93 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -109,6 +109,22 @@ interface CommonConfiguration { * @default true */ preloading?: boolean; + /** + * Origins trusted to send incoming checkout messages, in addition to the + * loaded checkout origin and `shop.app` (including its subdomains). + * + * The native surface is open by default: when this is empty (the default), + * messages from any origin are accepted. Provide one or more origins to + * restrict which origins are trusted. Entries may be exact origins + * (`https://example.com`), wildcard subdomains (`https://*.example.com`), or + * `'*'` to explicitly disable origin validation. + * + * Rejected messages are logged by the native SDK at debug level; they are + * never silently dropped. + * + * @default [] (all origins trusted) + */ + allowedMessageOrigins?: string[]; } export type Configuration = CommonConfiguration & { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index c4bcaee5e..8e8d6cf93 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -35,6 +35,7 @@ type ConfigurationSpec = { colorScheme?: string; logLevel?: string; preloading?: boolean; + allowedMessageOrigins?: string[]; colors?: ColorsSpec; }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index b8b9daf53..5bb727671 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -183,6 +183,18 @@ describe('ShopifyCheckoutKit', () => { instance.setConfig(configWithPreloading); expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithPreloading); }); + + it('calls `setConfig` with allowedMessageOrigins configuration', () => { + const instance = new ShopifyCheckout(); + const configWithAllowedOrigins: Configuration = { + colorScheme: ColorScheme.automatic, + allowedMessageOrigins: ['https://example.com', 'https://*.example.com'], + }; + instance.setConfig(configWithAllowedOrigins); + expect(NativeModule.setConfig).toHaveBeenCalledWith( + configWithAllowedOrigins, + ); + }); }); describe('preload', () => { From 29dc3e49f77574d95f3fc6c7b0a7bf17f21d858b Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 13:24:16 +0200 Subject: [PATCH 07/11] fix(react-native): bridge message rejection callbacks --- .../react-native/__mocks__/react-native.ts | 3 + .../checkoutkit/ShopifyCheckoutKitModule.java | 12 +++ .../api/checkout-kit-react-native.api.md | 21 ++++- .../ios/ShopifyCheckoutKit.mm | 13 ++++ .../ios/ShopifyCheckoutKit.swift | 16 ++++ .../checkout-kit-react-native/src/index.d.ts | 19 +++-- .../checkout-kit-react-native/src/index.ts | 37 ++++++++- .../src/specs/NativeShopifyCheckoutKit.ts | 13 +++- .../tests/index.test.ts | 76 +++++++++++++++++++ 9 files changed, 195 insertions(+), 15 deletions(-) diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index f640146f0..edb9536ae 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -82,6 +82,9 @@ const ShopifyCheckoutKit = { onDispatch: jest.fn((callback: (envelopeJson: string) => void) => shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback), ), + onMessageRejected: jest.fn(callback => + shopifyCheckoutKitEventEmitter.addListener('onMessageRejected', callback), + ), preload: jest.fn(), present: jest.fn(), dismiss: jest.fn(), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index ea34e7232..ab87d47c1 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -18,6 +18,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import kotlin.Unit; public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { @@ -122,6 +123,8 @@ public WritableMap getConfig() { resultConfig.putString("colorScheme", colorSchemeToString(checkoutConfig.getColorScheme())); resultConfig.putString("logLevel", logLevelToString(checkoutConfig.getLogLevel())); resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); + resultConfig.putArray("allowedMessageOrigins", + Arguments.fromList(new ArrayList<>(checkoutConfig.getAllowedMessageOrigins()))); return resultConfig; } @@ -137,6 +140,15 @@ public void setConfig(ReadableMap config) { configuration.setAllowedMessageOrigins(toStringSet(config.getArray("allowedMessageOrigins"))); } + configuration.setOnMessageRejected(rejection -> { + WritableMap detail = Arguments.createMap(); + detail.putString("origin", rejection.getOrigin()); + detail.putString("message", rejection.getMessage()); + detail.putString("reason", rejection.getReason()); + emitOnMessageRejected(detail); + return Unit.INSTANCE; + }); + if (config.hasKey("logLevel")) { LogLevel logLevel = getLogLevel(config.getString("logLevel")); configuration.setLogLevel(logLevel); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index 90350685e..b617b4daf 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -214,8 +214,15 @@ export enum ColorScheme { web = "web_default" } -// Warning: (ae-forgotten-export) The symbol "CommonConfiguration" needs to be exported by the entry point index.d.ts -// +// @public (undocumented) +export interface CommonConfiguration { + allowedMessageOrigins?: string[]; + logLevel?: LogLevel; + onMessageRejected?: (detail: RejectedMessage) => void; + preloading?: boolean; + title?: string; +} + // @public (undocumented) export type Configuration = CommonConfiguration & { acceleratedCheckouts?: AcceleratedCheckoutConfiguration; @@ -309,6 +316,16 @@ export interface PresentCallbacks { // @public (undocumented) export type ProtocolHandlers = ProtocolHandlers_2; +// @public (undocumented) +export interface RejectedMessage { + // (undocumented) + message: string; + // (undocumented) + origin: string; + // (undocumented) + reason: string; +} + // @public (undocumented) export enum RenderState { // (undocumented) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index c99cf281e..a17e9ce9e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -58,6 +58,19 @@ - (void)emitOnDispatchFromSwift:(NSString *)value eventEmitterCallbackWrapper->_eventEmitterCallback("onDispatch", value); } +- (void)emitOnMessageRejectedFromSwift:(NSDictionary *)value +{ + EventEmitterCallbackWrapper *eventEmitterCallbackWrapper = + (EventEmitterCallbackWrapper *)objc_getAssociatedObject( + self, RCTShopifyCheckoutKitEventEmitterCallbackKey); + + if (eventEmitterCallbackWrapper == nil) { + return; + } + + eventEmitterCallbackWrapper->_eventEmitterCallback("onMessageRejected", value); +} + @end // TurboModule registration. `RCTModuleProviders` (generated by codegen from diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 5e0dbd4c7..596d2a217 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -160,6 +160,10 @@ class RCTShopifyCheckoutKit: NSObject { ShopifyCheckoutKit.configuration.allowedMessageOrigins = allowedMessageOrigins } + ShopifyCheckoutKit.configuration.onMessageRejected = { [weak self] rejection in + self?.emitMessageRejected(rejection) + } + if let colorScheme = configuration["colorScheme"] as? String { ShopifyCheckoutKit.configuration.colorScheme = getColorScheme(colorScheme) } @@ -193,6 +197,7 @@ class RCTShopifyCheckoutKit: NSObject { "tintColor": ShopifyCheckoutKit.configuration.tintColor, "backgroundColor": ShopifyCheckoutKit.configuration.backgroundColor, "closeButtonColor": ShopifyCheckoutKit.configuration.closeButtonTintColor, + "allowedMessageOrigins": ShopifyCheckoutKit.configuration.allowedMessageOrigins, "logLevel": logLevelToString(ShopifyCheckoutKit.configuration.logLevel) ] } @@ -345,6 +350,17 @@ extension RCTShopifyCheckoutKit { perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) } + private func emitMessageRejected(_ rejection: MessageRejection) { + perform( + NSSelectorFromString("emitOnMessageRejectedFromSwift:"), + with: [ + "origin": rejection.origin, + "message": rejection.message, + "reason": rejection.reason + ] + ) + } + /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards /// it to the JS dispatch event stream. private func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 64dfa3d93..5b57c34f9 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -1,10 +1,6 @@ import type {CheckoutException} from './errors'; import type {ProtocolHandlers} from './protocol'; -import type { - ApplePayContactField, - ColorScheme, - LogLevel, -} from './enums'; +import type {ApplePayContactField, ColorScheme, LogLevel} from './enums'; export { AcceleratedCheckoutWallet, ApplePayContactField, @@ -81,7 +77,7 @@ export interface AndroidAutomaticColors { dark: AndroidColors; } -interface CommonConfiguration { +export interface CommonConfiguration { /** * Sets the title of the Checkout sheet. * @@ -125,6 +121,17 @@ interface CommonConfiguration { * @default [] (all origins trusted) */ allowedMessageOrigins?: string[]; + /** + * Invoked when an incoming checkout message is rejected by origin + * validation. Treat the payload as untrusted. + */ + onMessageRejected?: (detail: RejectedMessage) => void; +} + +export interface RejectedMessage { + origin: string; + message: string; + reason: string; } export type Configuration = CommonConfiguration & { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index d39e40873..43ae6d0da 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -18,10 +18,12 @@ import type { AndroidAutomaticColors, AndroidColors, Configuration, + CommonConfiguration, Features, GeolocationRequestEvent, IosColors, PresentCallbacks, + RejectedMessage, ShopifyCheckoutKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './enums'; @@ -62,6 +64,10 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private dispatchSubscription?: {remove: () => void}; + private messageRejectedSubscription?: {remove: () => void}; + + private onMessageRejected?: (detail: RejectedMessage) => void; + private _acceleratedCheckoutsReady = false; // TurboModule constants are immutable for the lifetime of the process — @@ -169,7 +175,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @returns The current Configuration */ public getConfig(): Configuration { - return coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()); + return { + ...coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()), + ...(this.onMessageRejected + ? {onMessageRejected: this.onMessageRejected} + : {}), + }; } /** @@ -182,7 +193,9 @@ class ShopifyCheckout implements ShopifyCheckoutKit { configuration.acceleratedCheckouts, ); } - RNShopifyCheckoutKit.setConfig(configuration); + this.configureMessageRejectionCallback(configuration.onMessageRejected); + const {onMessageRejected: _, ...nativeConfiguration} = configuration; + RNShopifyCheckoutKit.setConfig(nativeConfiguration); } /** @@ -192,6 +205,9 @@ class ShopifyCheckout implements ShopifyCheckoutKit { */ public teardown() { this.releaseDispatchSubscription(); + this.messageRejectedSubscription?.remove(); + this.messageRejectedSubscription = undefined; + this.onMessageRejected = undefined; } /** @@ -242,6 +258,20 @@ class ShopifyCheckout implements ShopifyCheckoutKit { // --- private + private configureMessageRejectionCallback( + callback: Configuration['onMessageRejected'], + ): void { + this.onMessageRejected = callback; + if (callback && !this.messageRejectedSubscription) { + this.messageRejectedSubscription = RNShopifyCheckoutKit.onMessageRejected( + detail => this.onMessageRejected?.(detail), + ); + } else if (!callback && this.messageRejectedSubscription) { + this.messageRejectedSubscription.remove(); + this.messageRejectedSubscription = undefined; + } + } + /** * Accelerated Checkouts is only supported from iOS 16.0 onwards */ @@ -383,7 +413,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private permissionGranted(status: PermissionStatus): boolean { return status === 'granted'; } - } // API @@ -426,12 +455,14 @@ export type { CheckoutProtocolMethod, CheckoutProtocolPayloads, Configuration, + CommonConfiguration, ErrorResponse, Features, GeolocationRequestEvent, IosColors, PresentCallbacks, ProtocolHandlers, + RejectedMessage, RenderStateChangeEvent, }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index 8e8d6cf93..b3e771a49 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -47,15 +47,20 @@ type ConfigurationResultSpec = { tintColor?: string; backgroundColor?: string; closeButtonColor?: string; + allowedMessageOrigins: string[]; +}; + +export type RejectedMessageSpec = { + origin: string; + message: string; + reason: string; }; export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; + readonly onMessageRejected: CodegenTypes.EventEmitter; - present( - checkoutUrl: string, - subscribedMethods: string[], - ): void; + present(checkoutUrl: string, subscribedMethods: string[]): void; preload(checkoutUrl: string): void; dismiss(): void; invalidateCache(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index 5bb727671..024e21754 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -125,6 +125,11 @@ describe('Exports', () => { }); type Dispatch = (envelopeJson: string) => void; +type MessageRejectedDispatch = (detail: { + origin: string; + message: string; + reason: string; +}) => void; function lastDispatch(): Dispatch { const dispatch = NativeModule.onDispatch.mock.calls[ @@ -138,6 +143,16 @@ function lastDispatch(): Dispatch { return dispatch; } +function lastMessageRejectedDispatch(): MessageRejectedDispatch { + const dispatch = NativeModule.onMessageRejected.mock.calls[ + NativeModule.onMessageRejected.mock.calls.length - 1 + ]?.[0] as MessageRejectedDispatch | undefined; + if (!dispatch) { + throw new Error('Expected a message rejection event subscription'); + } + return dispatch; +} + describe('ShopifyCheckoutKit', () => { afterEach(() => { NativeModule.setConfig.mockReset(); @@ -195,6 +210,48 @@ describe('ShopifyCheckoutKit', () => { configWithAllowedOrigins, ); }); + + it('keeps onMessageRejected in JS and forwards rejection details', () => { + const first = jest.fn(); + const second = jest.fn(); + const instance = new ShopifyCheckout({onMessageRejected: first}); + const dispatch = lastMessageRejectedDispatch(); + const detail = { + origin: 'https://untrusted.example', + message: '{"type":"test"}', + reason: 'Origin is not allowed', + }; + + expect(NativeModule.setConfig).toHaveBeenCalledWith({}); + dispatch(detail); + expect(first).toHaveBeenCalledWith(detail); + + instance.setConfig({onMessageRejected: second}); + expect(NativeModule.onMessageRejected).toHaveBeenCalledTimes(1); + dispatch(detail); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledWith(detail); + }); + + it('removes the message rejection subscription when the callback is cleared', () => { + const remove = jest.fn(); + NativeModule.onMessageRejected.mockReturnValueOnce({remove}); + const instance = new ShopifyCheckout({onMessageRejected: jest.fn()}); + + instance.setConfig({}); + + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('removes the message rejection subscription during teardown', () => { + const remove = jest.fn(); + NativeModule.onMessageRejected.mockReturnValueOnce({remove}); + const instance = new ShopifyCheckout({onMessageRejected: jest.fn()}); + + instance.teardown(); + + expect(remove).toHaveBeenCalledTimes(1); + }); }); describe('preload', () => { @@ -635,6 +692,25 @@ describe('ShopifyCheckoutKit', () => { }); expect(NativeModule.getConfig).toHaveBeenCalledTimes(1); }); + + it('returns allowed origins and the configured rejection callback', () => { + const onMessageRejected = jest.fn(); + NativeModule.getConfig.mockReturnValueOnce({ + colorScheme: 'automatic', + logLevel: 'error', + preloading: true, + allowedMessageOrigins: ['https://example.com'], + }); + const instance = new ShopifyCheckout({onMessageRejected}); + + expect(instance.getConfig()).toStrictEqual({ + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + preloading: true, + allowedMessageOrigins: ['https://example.com'], + onMessageRejected, + }); + }); }); describe('Geolocation', () => { From ab496ebb3d6dd9b4c6bccc4b67371cebfc83fcf4 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Fri, 31 Jul 2026 15:38:56 +0200 Subject: [PATCH 08/11] fix(react-native): align rejection callback lifecycle --- .../checkoutkit/ShopifyCheckoutKitModule.java | 21 +++++++++------ .../ios/ShopifyCheckoutKit.swift | 8 ++++-- .../checkout-kit-react-native/src/context.tsx | 5 ++++ .../checkout-kit-react-native/src/index.ts | 11 +++++--- .../src/specs/NativeShopifyCheckoutKit.ts | 1 + .../tests/context.test.tsx | 26 +++++++++++++++++-- .../tests/index.test.ts | 26 ++++++++++++++----- .../ShopifyCheckoutKitModuleTest.java | 17 ++++++++++++ .../ShopifyCheckoutKitTests.swift | 12 +++++++++ 9 files changed, 105 insertions(+), 22 deletions(-) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index ab87d47c1..f95619887 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -140,14 +140,19 @@ public void setConfig(ReadableMap config) { configuration.setAllowedMessageOrigins(toStringSet(config.getArray("allowedMessageOrigins"))); } - configuration.setOnMessageRejected(rejection -> { - WritableMap detail = Arguments.createMap(); - detail.putString("origin", rejection.getOrigin()); - detail.putString("message", rejection.getMessage()); - detail.putString("reason", rejection.getReason()); - emitOnMessageRejected(detail); - return Unit.INSTANCE; - }); + if (config.hasKey("hasMessageRejectedCallback") + && config.getBoolean("hasMessageRejectedCallback")) { + configuration.setOnMessageRejected(rejection -> { + WritableMap detail = Arguments.createMap(); + detail.putString("origin", rejection.getOrigin()); + detail.putString("message", rejection.getMessage()); + detail.putString("reason", rejection.getReason()); + emitOnMessageRejected(detail); + return Unit.INSTANCE; + }); + } else { + configuration.setOnMessageRejected(null); + } if (config.hasKey("logLevel")) { LogLevel logLevel = getLogLevel(config.getString("logLevel")); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 596d2a217..af54d1b41 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -160,8 +160,12 @@ class RCTShopifyCheckoutKit: NSObject { ShopifyCheckoutKit.configuration.allowedMessageOrigins = allowedMessageOrigins } - ShopifyCheckoutKit.configuration.onMessageRejected = { [weak self] rejection in - self?.emitMessageRejected(rejection) + if configuration["hasMessageRejectedCallback"] as? Bool == true { + ShopifyCheckoutKit.configuration.onMessageRejected = { [weak self] rejection in + self?.emitMessageRejected(rejection) + } + } else { + ShopifyCheckoutKit.configuration.onMessageRejected = nil } if let colorScheme = configuration["colorScheme"] as? String { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx index ba370ced5..f04b89b20 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx @@ -43,6 +43,11 @@ export function ShopifyCheckoutProvider({ instance.current = new ShopifyCheckout(configuration, features); } + useEffect(() => { + const checkout = instance.current; + return () => checkout?.teardown(); + }, []); + useEffect(() => { if (!instance.current || !configuration) { return; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 43ae6d0da..51f00e971 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -194,14 +194,17 @@ class ShopifyCheckout implements ShopifyCheckoutKit { ); } this.configureMessageRejectionCallback(configuration.onMessageRejected); - const {onMessageRejected: _, ...nativeConfiguration} = configuration; - RNShopifyCheckoutKit.setConfig(nativeConfiguration); + const nativeConfiguration = {...configuration}; + delete nativeConfiguration.onMessageRejected; + RNShopifyCheckoutKit.setConfig({ + ...nativeConfiguration, + hasMessageRejectedCallback: + typeof configuration.onMessageRejected === 'function', + }); } /** * Cleans up resources and event listeners used by the checkout sheet. - * Currently a no-op — retained as part of the public API for forward - * compatibility with future protocol-client subscriptions. */ public teardown() { this.releaseDispatchSubscription(); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index b3e771a49..745beb2e7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -36,6 +36,7 @@ type ConfigurationSpec = { logLevel?: string; preloading?: boolean; allowedMessageOrigins?: string[]; + hasMessageRejectedCallback: boolean; colors?: ColorsSpec; }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index bb8195e4b..c91faf94b 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -52,6 +52,25 @@ describe('ShopifyCheckoutProvider', () => { expect(component).toBeTruthy(); }); + it('removes the message rejection subscription on unmount', () => { + const remove = jest.fn(); + NativeModules.ShopifyCheckoutKit.onMessageRejected.mockReturnValueOnce({ + remove, + }); + const configuration: Configuration = { + onMessageRejected: jest.fn(), + }; + + const component = render( + + + , + ); + component.unmount(); + + expect(remove).toHaveBeenCalledTimes(1); + }); + it('creates ShopifyCheckout instance with configuration', () => { render( @@ -61,7 +80,7 @@ describe('ShopifyCheckoutProvider', () => { expect( NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(config); + ).toHaveBeenCalledWith({...config, hasMessageRejectedCallback: false}); }); it('skips configuration when no configuration is provided', () => { @@ -357,7 +376,10 @@ describe('useShopifyCheckout', () => { expect( NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(newConfig); + ).toHaveBeenCalledWith({ + ...newConfig, + hasMessageRejectedCallback: false, + }); }); it('provides getConfig function', async () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index 024e21754..ecaed8b7a 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -162,7 +162,10 @@ describe('ShopifyCheckoutKit', () => { describe('instantiation', () => { it('calls `setConfig` with the specified config on instantiation', () => { new ShopifyCheckout(config); - expect(NativeModule.setConfig).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...config, + hasMessageRejectedCallback: false, + }); }); it('does not call `setConfig` if no config was specified on instantiation', () => { @@ -176,7 +179,10 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); instance.setConfig(config); expect(NativeModule.setConfig).toHaveBeenCalledTimes(1); - expect(NativeModule.setConfig).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...config, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with logLevel configuration', () => { @@ -186,7 +192,10 @@ describe('ShopifyCheckoutKit', () => { logLevel: LogLevel.debug, }; instance.setConfig(configWithLogLevel); - expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithLogLevel); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...configWithLogLevel, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with preloading configuration', () => { @@ -196,7 +205,10 @@ describe('ShopifyCheckoutKit', () => { preloading: false, }; instance.setConfig(configWithPreloading); - expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithPreloading); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + ...configWithPreloading, + hasMessageRejectedCallback: false, + }); }); it('calls `setConfig` with allowedMessageOrigins configuration', () => { @@ -207,7 +219,7 @@ describe('ShopifyCheckoutKit', () => { }; instance.setConfig(configWithAllowedOrigins); expect(NativeModule.setConfig).toHaveBeenCalledWith( - configWithAllowedOrigins, + {...configWithAllowedOrigins, hasMessageRejectedCallback: false}, ); }); @@ -222,7 +234,9 @@ describe('ShopifyCheckoutKit', () => { reason: 'Origin is not allowed', }; - expect(NativeModule.setConfig).toHaveBeenCalledWith({}); + expect(NativeModule.setConfig).toHaveBeenCalledWith({ + hasMessageRejectedCallback: true, + }); dispatch(detail); expect(first).toHaveBeenCalledWith(detail); diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 742e8b21a..e416cadb7 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -293,6 +293,23 @@ public void testCanSetDarkColorScheme() { .isEqualTo("dark"); } + @Test + public void testOnlyInstallsMessageRejectedCallbackWhenRequested() { + JavaOnlyMap config = new JavaOnlyMap(); + config.putArray("allowedMessageOrigins", JavaOnlyArray.from(List.of("https://example.com"))); + + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNull(); + + config.putBoolean("hasMessageRejectedCallback", true); + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNotNull(); + + config.putBoolean("hasMessageRejectedCallback", false); + shopifyCheckoutKitModule.setConfig(config); + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNull(); + } + @Test public void testCanConfigureLightColorSchemeWithValidColors() { JavaOnlyMap androidColors = createValidLightColors(); diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index d73fc06c3..bde7c57a8 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -22,6 +22,7 @@ class ShopifyCheckoutKitTests: XCTestCase { ShopifyCheckoutKit.configuration.closeButtonTintColor = nil ShopifyCheckoutKit.configuration.logLevel = LogLevel.error ShopifyCheckoutKit.configuration.preloading.enabled = true + ShopifyCheckoutKit.configuration.onMessageRejected = nil } private func getShopifyCheckoutKit() -> RCTShopifyCheckoutKit { @@ -57,6 +58,17 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.backgroundColor, UIColor(hex: "#0000FF")) } + func testOnlyInstallsMessageRejectedCallbackWhenRequested() { + shopifyCheckoutKit.setConfig(["allowedMessageOrigins": ["https://example.com"]]) + XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected) + + shopifyCheckoutKit.setConfig(["hasMessageRejectedCallback": true]) + XCTAssertNotNil(ShopifyCheckoutKit.configuration.onMessageRejected) + + shopifyCheckoutKit.setConfig(["hasMessageRejectedCallback": false]) + XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected) + } + func testConfigureWithInvalidColors() { let configuration: [AnyHashable: Any] = [ "colors": [ From 5af0ce487600bb6fb6ace0409d1fe669f85f534e Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 13:53:31 +0200 Subject: [PATCH 09/11] test(react-native): build allowed origins explicitly --- .../reactnativedemo/ShopifyCheckoutKitModuleTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index e416cadb7..0ebc7a2b3 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -296,7 +296,9 @@ public void testCanSetDarkColorScheme() { @Test public void testOnlyInstallsMessageRejectedCallbackWhenRequested() { JavaOnlyMap config = new JavaOnlyMap(); - config.putArray("allowedMessageOrigins", JavaOnlyArray.from(List.of("https://example.com"))); + JavaOnlyArray allowedMessageOrigins = new JavaOnlyArray(); + allowedMessageOrigins.pushString("https://example.com"); + config.putArray("allowedMessageOrigins", allowedMessageOrigins); shopifyCheckoutKitModule.setConfig(config); assertThat(ShopifyCheckoutKitModule.checkoutConfig.getOnMessageRejected()).isNull(); From 1c3e58d7e85f030f5b602cf9eb78ac9b1172e1b3 Mon Sep 17 00:00:00 2001 From: Michael Xu Date: Wed, 15 Jul 2026 13:23:09 -0400 Subject: [PATCH 10/11] docs: incoming message origin validation across platforms --- platforms/android/README.md | 42 +++++++++++++++++++++++++-- platforms/react-native/README.md | 30 +++++++++++++++++++ platforms/web/README.md | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/platforms/android/README.md b/platforms/android/README.md index 30c4d85c5..7d4dadc93 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -25,6 +25,7 @@ - [Preload checkout](#preload-checkout) - [Configure checkout](#configure-checkout) - [Color schemes](#color-schemes) + - [Incoming message origin validation](#incoming-message-origin-validation) - [Title localization](#title-localization) - [Current configuration](#current-configuration) - [Checkout lifecycle](#checkout-lifecycle) @@ -258,8 +259,8 @@ ShopifyCheckoutKit.configure { | `sheet` | `CheckoutSheetOptions()` | Customize native sheet presentation such as snap points, dismissal behavior, corner radius, title alignment, toolbar elevation, close icon styling, and the optional drag handle. | | `logLevel` | `LogLevel.WARN` | SDK logging verbosity. Use `LogLevel.DEBUG` during integration. | | `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. | -| `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. | -| `onMessageRejected` | `null` | Observes messages rejected by origin validation. | +| `allowedMessageOrigins` | `emptySet()` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). | +| `onMessageRejected` | `null` | Callback invoked when a message is dropped by origin validation. Defaults to logging at debug level. | ### Color schemes @@ -348,6 +349,43 @@ Set `dragHandle.visible = true` to show a fixed, visual-only drag handle at the when `dismissal.dragToDismissEnabled = false` so disabled drag gestures are not presented as available. Configure `dragHandleColor` in `ColorScheme` to override the default header-font-derived handle color. +### Incoming message origin validation + +The native WebView 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. + +```kotlin +ShopifyCheckoutKit.configure { + it.allowedMessageOrigins = setOf( + "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. + +Messages dropped by origin validation are logged at debug level. To observe +them instead, set `onMessageRejected`: + +```kotlin +ShopifyCheckoutKit.configure { + it.onMessageRejected = { rejected -> + Log.w("Checkout", "Dropped ${rejected.origin}: ${rejected.reason}") + } +} +``` + +> [!WARNING] +> The `RejectedMessage` 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. + ### Title localization Override `checkout_web_view_title` in your app resources: diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 166e0a803..40fd00fd8 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -37,6 +37,7 @@ experiences. - [Usage with the Shopify Storefront API](#usage-with-the-shopify-storefront-api) - [Configuration](#configuration) - [Colors](#colors) + - [Incoming message origin validation](#incoming-message-origin-validation) - [Localization](#localization) - [Checkout Sheet title](#checkout-sheet-title) - [iOS - Localization](#ios---localization) @@ -345,6 +346,7 @@ instance of the `ShopifyCheckout` class. | `preloading` | | `true` | Enable/disable [preloading](#preloading). | | `colors` | | `{}` | An object with `ios` and `android` properties to override the colors for iOS and Android platforms individually. See [`colors`](#colors) for more information. | | `logLevel` | | `error` | Sets the log level for the native SDK. Use `LogLevel.debug` for verbose logging during development, or `LogLevel.error` for production. | +| `allowedMessageOrigins` | | `[]` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). | Here's an example of how a fully customized configuration object might look: @@ -387,6 +389,34 @@ function AppWithContext() { const shopifyCheckout = new ShopifyCheckout(config); ``` +### Incoming message origin validation + +Checkout Kit runs on the native iOS and Android web views, which are private, +app-controlled runtimes. It is therefore **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. + +```tsx +const config: Configuration = { + 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. Messages dropped by origin validation are logged +by the native SDK at debug level. + +> [!NOTE] +> Incoming messages are advisory (lifecycle/UI signals) and are never treated as +> an authoritative source of checkout state, so origin validation is defense in +> depth. + ### Colors The SDK defaults to the `automatic` color scheme option, will switches between diff --git a/platforms/web/README.md b/platforms/web/README.md index 849ec075e..74ed399f0 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -36,6 +36,8 @@ Check out our blog to - [`target`](#target) - [`appearance`](#appearance) - [`log-level`](#log-level) + - [`allowed-origins`](#allowed-origins) + - [`onMessageRejected`](#onmessagerejected) - [Popup dimensions](#popup-dimensions) - [Overlay scrim](#overlay-scrim) - [Checkout lifecycle](#checkout-lifecycle) @@ -397,6 +399,54 @@ Use `"debug"` while wiring up `src` and event handlers during integration, or checkout.logLevel = 'debug'; ``` +### `allowed-origins` + +Controls which origins are trusted to post incoming checkout-protocol messages +to the component. On web, checkout is **closed by default**: with no configured +origins, only the cart URL origin (from `src`) and `shop.app` (including its +subdomains) are trusted. Messages from any other origin are dropped. + +Provide extra origins as a space- or comma-separated list. Each entry may be: + +| Entry | Matches | +| ------------------------- | -------------------------------------------------------------- | +| `https://example.com` | That exact origin. | +| `https://*.example.com` | Any subdomain of `example.com` (not the apex `example.com`). | +| `*` | Every origin — disables origin validation entirely. | + +```html + +``` + +```ts +checkout.allowedOrigins = ['https://checkout.example.com', 'https://*.example.com']; +``` + +> [!NOTE] +> Incoming messages are advisory (lifecycle/UI signals) and are never treated +> as an authoritative source of checkout state, so origin validation is defense +> in depth. Use `*` only when you understand the trade-off — in the shared +> browser, other pages and extensions can post to the component. + +Invalid entries are ignored, and a warning is logged at `log-level="warn"` or +more verbose. + +### `onMessageRejected` + +A property-only callback invoked whenever an incoming message is dropped by +origin validation. The smart default logs a warning; assign a function to +observe rejected messages instead (for example, to report them). + +```ts +checkout.onMessageRejected = ({ origin, data, reason }) => { + console.warn(`Dropped message from ${origin}: ${reason}`, data); +}; +``` + +> [!WARNING] +> The payload is untrusted — it was dropped precisely because its origin was +> not in the allowlist. Do not derive checkout state from it. + ### Popup dimensions When `target="popup"`, the popup is centered over the host window. Defaults From 8baa3ba9a4285a1a2090b73006ecede046e48db2 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 3 Aug 2026 13:54:54 +0200 Subject: [PATCH 11/11] docs: clarify configured origin formats --- platforms/android/README.md | 29 +++++------------------------ platforms/react-native/README.md | 15 +++++++++++++-- platforms/web/README.md | 5 +++++ 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/platforms/android/README.md b/platforms/android/README.md index 7d4dadc93..1f9c1f46c 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -370,6 +370,11 @@ 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`: @@ -402,30 +407,6 @@ Override `checkout_web_view_title` in your app resources: val configuration = ShopifyCheckoutKit.getConfiguration() ``` -### Incoming message origin validation - -Native checkout accepts messages from every origin by default. To restrict messages, configure one -or more exact origins or wildcard subdomains. The checkout URL's origin and `shop.app` remain -trusted automatically. - -```kotlin -ShopifyCheckoutKit.configure { - it.allowedMessageOrigins = setOf( - "https://checkout.example.com", - "https://*.example.org", - ) - it.onMessageRejected = { rejection -> - reportRejectedOrigin(rejection.origin, rejection.reason) - } -} -``` - -Exact entries accept an optional trailing slash, but not credentials, paths, queries, or fragments. -For example, `https://checkout.example.com/` is accepted, while -`https://user@checkout.example.com` and `https://checkout.example.com/path` are ignored. Wildcard -entries require the scheme and match subdomains only; `https://*.example.org` does not match -`https://example.org`. Use `"*"` to explicitly disable origin validation. - ## Checkout lifecycle Use `onFail` and `onDismiss` for checkout outcomes handled by your app. Use `CheckoutProtocol.Client` for typed checkout state, including completion. These descriptors wrap checkout protocol messages defined in the [protocol schema](../../protocol/services/shopping/embedded.openrpc.json). diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 40fd00fd8..97e9ad5ba 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -409,8 +409,19 @@ const config: Configuration = { 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. Messages dropped by origin validation are logged -by the native SDK at debug level. +explicitly trust every origin. Exact and wildcard entries accept an optional +trailing slash. Exact entries must not include credentials, paths, queries, or +fragments. Messages dropped by origin validation are logged by the native SDK +at debug level. To observe them instead, configure `onMessageRejected`: + +```tsx +const config: Configuration = { + allowedMessageOrigins: ['https://checkout.example.com'], + onMessageRejected: ({origin, message, reason}) => { + console.warn(`Dropped message from ${origin}: ${reason}`, message); + }, +}; +``` > [!NOTE] > Incoming messages are advisory (lifecycle/UI signals) and are never treated as diff --git a/platforms/web/README.md b/platforms/web/README.md index 74ed399f0..66b2ba009 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -414,6 +414,11 @@ Provide extra origins as a space- or comma-separated list. Each entry may be: | `https://*.example.com` | Any subdomain of `example.com` (not the apex `example.com`). | | `*` | Every origin — disables origin validation entirely. | +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. + ```html ```