diff --git a/platforms/android/README.md b/platforms/android/README.md index 30c4d85c5..1f9c1f46c 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,48 @@ 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. + +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`: + +```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: @@ -364,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 166e0a803..97e9ad5ba 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,45 @@ 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. 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 +> 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/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 667b2a39e..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 @@ -13,9 +13,12 @@ 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; +import kotlin.Unit; public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { @@ -120,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; } @@ -131,6 +136,24 @@ 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("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")); configuration.setLogLevel(logLevel); @@ -216,6 +239,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/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 a4b2767f7..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 @@ -156,6 +156,18 @@ class RCTShopifyCheckoutKit: NSObject { ShopifyCheckoutKit.configuration.preloading.enabled = preloading } + if let allowedMessageOrigins = configuration["allowedMessageOrigins"] as? [String] { + ShopifyCheckoutKit.configuration.allowedMessageOrigins = allowedMessageOrigins + } + + 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 { ShopifyCheckoutKit.configuration.colorScheme = getColorScheme(colorScheme) } @@ -189,6 +201,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) ] } @@ -341,6 +354,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/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.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 83f160bb1..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. * @@ -109,6 +105,33 @@ 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[]; + /** + * 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..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 @@ -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,16 +193,24 @@ class ShopifyCheckout implements ShopifyCheckoutKit { configuration.acceleratedCheckouts, ); } - RNShopifyCheckoutKit.setConfig(configuration); + this.configureMessageRejectionCallback(configuration.onMessageRejected); + 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(); + this.messageRejectedSubscription?.remove(); + this.messageRejectedSubscription = undefined; + this.onMessageRejected = undefined; } /** @@ -242,6 +261,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 +416,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private permissionGranted(status: PermissionStatus): boolean { return status === 'granted'; } - } // API @@ -426,12 +458,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 c4bcaee5e..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 @@ -35,6 +35,8 @@ type ConfigurationSpec = { colorScheme?: string; logLevel?: string; preloading?: boolean; + allowedMessageOrigins?: string[]; + hasMessageRejectedCallback: boolean; colors?: ColorsSpec; }; @@ -46,15 +48,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/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 b8b9daf53..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 @@ -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(); @@ -147,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', () => { @@ -161,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', () => { @@ -171,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', () => { @@ -181,7 +205,66 @@ 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', () => { + 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, hasMessageRejectedCallback: false}, + ); + }); + + 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({ + hasMessageRejectedCallback: true, + }); + 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); }); }); @@ -623,6 +706,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', () => { 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..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 @@ -293,6 +293,25 @@ public void testCanSetDarkColorScheme() { .isEqualTo("dark"); } + @Test + public void testOnlyInstallsMessageRejectedCallbackWhenRequested() { + JavaOnlyMap config = new JavaOnlyMap(); + JavaOnlyArray allowedMessageOrigins = new JavaOnlyArray(); + allowedMessageOrigins.pushString("https://example.com"); + config.putArray("allowedMessageOrigins", allowedMessageOrigins); + + 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": [ diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 6aef9556d..c13e3ef21 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -22,6 +22,7 @@ - [SwiftUI](#swiftui) - [Preload checkout](#preload-checkout) - [Configure checkout](#configure-checkout) + - [Incoming message origin validation](#incoming-message-origin-validation) - [Current configuration](#current-configuration) - [Checkout lifecycle](#checkout-lifecycle) - [Error handling](#error-handling) @@ -221,9 +222,53 @@ ShopifyCheckoutKit.configure { | `closeButtonTintColor` | `nil` | Optional tint for the close button. | | `logLevel` | `.warn` | SDK logging verbosity. Threshold-ordered `.debug` → `.warn` → `.error` → `.none`; use `.debug` during integration. | | `preloading.enabled` | `true` | Enables best-effort checkout preloading before presentation. | +| `allowedMessageOrigins` | `[]` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). | +| `onMessageRejected` | `nil` | Closure invoked when a message is dropped by origin validation. Defaults to logging at debug level. | To localize the title, add `shopify_checkout_kit_title` to your app's `Localizable.xcstrings`. +### Incoming message origin validation + +The native web view is a private, app-controlled runtime, so Checkout Kit is +**open by default**: with an empty `allowedMessageOrigins`, incoming +checkout-protocol messages from any origin are accepted. Provide one or more +origins to restrict which origins are trusted; the loaded checkout origin and +`shop.app` (including its subdomains) are always trusted as well. + +```swift +ShopifyCheckoutKit.configure { + $0.allowedMessageOrigins = [ + "https://checkout.example.com", + "https://*.example.com", + ] +} +``` + +Each entry may be an exact origin (`https://example.com`), a wildcard subdomain +(`https://*.example.com`, matching subdomains but not the apex), or `"*"` to +explicitly trust every origin. + +Exact and wildcard entries accept an optional trailing slash. Exact entries +must not include credentials, paths, queries, or fragments. For example, +`https://example.com/` is accepted, while `https://user@example.com` and +`https://example.com/path` are ignored. + +Messages dropped by origin validation are logged at debug level. To observe +them instead, set `onMessageRejected`: + +```swift +ShopifyCheckoutKit.configure { + $0.onMessageRejected = { rejection in + print("Dropped \(rejection.origin): \(rejection.message)") + } +} +``` + +> [!WARNING] +> The `MessageRejection` payload is untrusted — it was dropped precisely because +> its origin was not in the allowlist. Incoming messages are advisory and are +> never treated as an authoritative source of checkout state. + ### Current configuration ```swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 15830e24f..fc96e61e9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -272,6 +272,22 @@ class CheckoutWebView: WKWebView { var openExternalURL: (URL) -> Void = { UIApplication.shared.open($0) } + /// Resolves whether a navigation targets the main frame. Overridable in tests. + var navigationIsMainFrame: (WKNavigationAction) -> Bool = { $0.targetFrame?.isMainFrame == true } + + /// Resolves the origin an incoming message was sent from. Overridable in + /// tests since `WKSecurityOrigin` cannot be constructed directly. + var messageOrigin: (WKScriptMessage) -> MessageOrigin = { message in + MessageOrigin(securityOrigin: message.frameInfo.securityOrigin) + } + + /// Resolves the request URL associated with an incoming message. Overridable + /// in tests since `WKFrameInfo` cannot be constructed directly. + var messageRequestURL: (WKScriptMessage) -> URL? = { $0.frameInfo.request.url } + + /// Resolves whether an incoming message came from the main frame. Overridable in tests. + var messageIsMainFrame: (WKScriptMessage) -> Bool = { $0.frameInfo.isMainFrame } + /// Kit-owned client that handles delegations and kit-mandated notifications. Currently: /// - `ec.ready` - kit-owned handshake. Supported delegations are announced up /// front via the `ec_delegate` URL query param; acceptance is implicit, so the @@ -373,6 +389,10 @@ class CheckoutWebView: WKWebView { /// Ensures one terminal failure is handled per checkout session, regardless /// of whether it originated from `ec.error` or WebKit process termination. private var hasHandledTerminalFailure = false + + /// The checkout URL passed to `load(checkout:)`. Used to derive the trusted + /// cart-url origin for incoming message validation. + var loadedCheckoutURL: URL? private var entryPoint: MetaData.EntryPoint? // MARK: Initializers @@ -450,7 +470,15 @@ class CheckoutWebView: WKWebView { // MARK: - func load(checkout url: URL, isPreload: Bool = false) { + guard url.scheme?.lowercased() == "https", url.host != nil else { + handleCachedViewFailure(.navigationFailed) + viewDelegate?.checkoutViewDidFailWithError( + error: .sdkError(underlying: InsecureCheckoutURLError(url: url)) + ) + return + } OSLogger.shared.info("Loading checkout URL: \(LogSafeURL.string(url)), isPreload: \(isPreload)") + loadedCheckoutURL = url var request = URLRequest(url: url) if isPreload, ShopifyCheckoutKit.configuration.preloading.enabled { @@ -528,6 +556,21 @@ extension CheckoutWebView: WKScriptMessageHandler { return } + guard messageIsMainFrame(message) else { + rejectMessage(message, body: body, reason: "message was sent from a child frame") + return + } + + guard !shouldRejectExplicitPortZero(message) else { + rejectMessage(message, body: body, reason: "origin uses unsupported port 0") + return + } + + guard isMessageOriginAllowed(message) else { + rejectMessage(message, body: body, reason: "origin is not in the allowlist") + return + } + guard let method = CheckoutProtocol.supportedProtocolMethod(body) else { if isTerminalProtocolError(body) { handleTerminalProtocolError(body, malformedEnvelope: true) @@ -611,6 +654,45 @@ private struct TerminalErrorNotification: Decodable { let params: JSONRPCErrorParams } +extension CheckoutWebView { + private func rejectMessage(_ message: WKScriptMessage, body: String, reason: String) { + let rejection = MessageRejection( + origin: messageOrigin(message).description, + message: body, + reason: reason + ) + let onRejected = ShopifyCheckoutKit.configuration.onMessageRejected ?? { rejection in + OSLogger.shared.debug("Rejected checkout message from \(rejection.origin): \(rejection.reason)") + } + onRejected(rejection) + } + + /// Validates the origin of an incoming checkout message against the effective + /// allowlist. When validation is disabled (native default with no configured + /// allowlist, or the `"*"` escape hatch) the message origin is not inspected. + func isMessageOriginAllowed(_ message: WKScriptMessage) -> Bool { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins, + checkoutURL: loadedCheckoutURL + ) + guard let patterns else { return true } + + return MessageOriginValidator.isAllowed(origin: messageOrigin(message), patterns: patterns) + } + + /// `WKSecurityOrigin` reports both an omitted port and an explicit port 0 as + /// zero. Use the frame request URL to reject the explicit form when origin + /// validation is enabled, while preserving native's open-by-default behavior. + private func shouldRejectExplicitPortZero(_ message: WKScriptMessage) -> Bool { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ShopifyCheckoutKit.configuration.allowedMessageOrigins, + checkoutURL: loadedCheckoutURL + ) + guard patterns != nil else { return false } + return messageRequestURL(message)?.port == 0 + } +} + extension CheckoutWebView: WKNavigationDelegate { func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { // Handle rare cases where the url is nil @@ -635,6 +717,14 @@ extension CheckoutWebView: WKNavigationDelegate { } } + if navigationIsMainFrame(action), url.scheme?.lowercased() != "https" { + handleCachedViewFailure(.navigationFailed) + viewDelegate?.checkoutViewDidFailWithError( + error: .sdkError(underlying: InsecureCheckoutURLError(url: url)) + ) + return decisionHandler(.cancel) + } + decisionHandler(.allow) } @@ -801,3 +891,11 @@ extension CheckoutWebView: WKNavigationDelegate { return self.url == url } } + +private struct InsecureCheckoutURLError: LocalizedError { + let url: URL + + var errorDescription: String? { + "Checkout requires an HTTPS URL: \(LogSafeURL.string(url))" + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift index f6483831f..0c443a7b7 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift @@ -43,6 +43,28 @@ public struct Configuration: Sendable { /// Levels: debug, warn, error, none (ordered threshold, most to least verbose) /// Default: .warn - which emits warnings and errors public var logLevel: LogLevel = .warn + + /// Origins that are trusted to send incoming checkout messages, in addition + /// to the loaded checkout origin and shop.app. + /// + /// The native surface is open by default: when this is empty, messages from + /// any origin are accepted. Provide one or more origins to restrict which + /// origins are trusted; the loaded checkout origin and shop.app are always + /// appended. Use `"*"` to explicitly disable origin validation. + /// + /// Entries are origin patterns: + /// - `"https://example.com"` — an exact origin. + /// - `"https://*.example.com"` — any subdomain of `example.com`. + /// - `"*"` — allow all origins (escape hatch). + /// + /// An optional trailing slash is accepted. Credentials, paths, queries, + /// and fragments are not valid in configured origin patterns. + public var allowedMessageOrigins: [String] = [] + + /// Invoked when an incoming checkout message is rejected during origin + /// validation. Defaults to logging a debug message; rejected messages are + /// never silently dropped. + public var onMessageRejected: (@Sendable (MessageRejection) -> Void)? } extension Configuration { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift new file mode 100644 index 000000000..ec65bbde3 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/MessageOriginValidator.swift @@ -0,0 +1,190 @@ +import Foundation +import WebKit + +/// Details about an incoming checkout message that was rejected during origin +/// validation. Surfaced through `Configuration.onMessageRejected`. +public struct MessageRejection: Sendable { + /// The origin the message was received from, e.g. `https://example.com`. + public let origin: String + /// The raw message body as received from the checkout surface. + public let message: String + /// Human-readable reason the message was rejected. + public let reason: String + + public init(origin: String, message: String, reason: String) { + self.origin = origin + self.message = message + self.reason = reason + } +} + +/// A normalized representation of a message origin (scheme + host + port). +struct MessageOrigin: Equatable { + let scheme: String + let host: String + /// The explicit port, or `nil` when the scheme's default port is used. + let port: Int? + + init(scheme: String, host: String, port: Int?) { + self.scheme = scheme.lowercased() + self.host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + self.port = Self.normalizedPort(scheme: scheme.lowercased(), port: port) + } + + init?(url: URL) { + guard let scheme = url.scheme, let host = url.host else { return nil } + self.init(scheme: scheme, host: host, port: url.port) + } + + @MainActor + init(securityOrigin: WKSecurityOrigin) { + // WKSecurityOrigin reports 0 when the default port for the scheme is used. + let normalizedPort = securityOrigin.port == 0 ? nil : securityOrigin.port + self.init(scheme: securityOrigin.protocol, host: securityOrigin.host, port: normalizedPort) + } + + /// The port used for comparison, resolving default ports for known schemes. + var effectivePort: Int? { + if let port { return port } + switch scheme { + case "https": return 443 + case "http": return 80 + default: return nil + } + } + + var description: String { + let serializedHost = host.contains(":") ? "[\(host)]" : host + if let port { + return "\(scheme)://\(serializedHost):\(port)" + } + return "\(scheme)://\(serializedHost)" + } + + private static func normalizedPort(scheme: String, port: Int?) -> Int? { + switch (scheme, port) { + case ("https", 443), ("http", 80): nil + default: port + } + } +} + +/// Computes the effective allowlist for incoming checkout messages and matches +/// origins against origin patterns. +/// +/// Native surfaces are open by default: an empty configured allowlist accepts +/// messages from any origin. When a merchant provides an allowlist, only the +/// configured origins plus the loaded checkout origin and shop.app are trusted. +/// `"*"` disables validation entirely. +enum MessageOriginValidator { + /// The escape hatch pattern that disables validation. + static let allowAllPattern = "*" + + /// shop.app is trusted by default; both the apex and its subdomains are allowed + /// so regional/checkout subdomains work without extra configuration. + static let shopAppOriginPatterns = ["https://shop.app", "https://*.shop.app"] + + /// Returns the effective allowlist patterns, or `nil` when validation is + /// disabled (allow all). + /// + /// - Parameters: + /// - configuredOrigins: Merchant-supplied allowlist entries. + /// - checkoutURL: The loaded checkout URL, whose origin is always trusted. + static func effectiveAllowlist( + configuredOrigins: [String], + checkoutURL: URL? + ) -> [String]? { + if configuredOrigins.contains(allowAllPattern) { + return nil + } + // Native surface: no allowlist means allow all origins. + if configuredOrigins.isEmpty { + return nil + } + + var patterns = configuredOrigins + if let checkoutURL, let origin = MessageOrigin(url: checkoutURL) { + patterns.append(origin.description) + } + patterns.append(contentsOf: shopAppOriginPatterns) + return patterns + } + + /// Whether `origin` matches any pattern in `patterns`. A `nil` list means + /// validation is disabled and every origin is allowed. + static func isAllowed(origin: MessageOrigin, patterns: [String]?) -> Bool { + guard let patterns else { return true } + return patterns.contains { matches(pattern: $0, origin: origin) } + } + + /// Matches a single origin pattern against a target origin. + /// + /// - `"*"` matches any origin. + /// - `"https://*.example.com"` matches proper subdomains of `example.com` + /// (not the apex), with matching scheme and port. + /// - `"https://example.com"` matches the exact origin. + /// + /// Invalid patterns return `false` (skipped). + static func matches(pattern: String, origin: MessageOrigin) -> Bool { + if pattern == allowAllPattern { return true } + + guard let parsed = parse(pattern: pattern) else { return false } + guard parsed.scheme == origin.scheme else { return false } + + let patternPort = MessageOrigin(scheme: parsed.scheme, host: parsed.host, port: parsed.port).effectivePort + guard patternPort == origin.effectivePort else { return false } + + if parsed.isWildcard { + return origin.host.hasSuffix(".\(parsed.host)") && origin.host != parsed.host + } + return origin.host == parsed.host + } + + private struct ParsedPattern { + let scheme: String + let host: String + let port: Int? + let isWildcard: Bool + } + + private static func parse(pattern: String) -> ParsedPattern? { + guard let schemeSeparator = pattern.range(of: "://") else { return nil } + let scheme = String(pattern[.. (host: String, port: Int?)? { + if authority.hasPrefix("[") { + guard let closingBracket = authority.firstIndex(of: "]") else { return nil } + let host = String(authority[authority.index(after: authority.startIndex) ..< closingBracket]) + let remainder = authority[authority.index(after: closingBracket)...] + guard !remainder.isEmpty else { return (host, nil) } + guard remainder.first == ":", let port = Int(remainder.dropFirst()) else { return nil } + return (host, port) + } + + if let colon = authority.lastIndex(of: ":") { + guard authority[..(nil) + ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + XCTAssertEqual(rejection.get()?.origin, "https://evil.example.com") + XCTAssertEqual(rejection.get()?.message, Self.readyBody) + XCTAssertEqual(rejection.get()?.reason, "origin is not in the allowlist") + } + + @MainActor + func testOriginValidationRejectsExplicitPortZeroWhenAllowlistSet() { + defer { resetOriginValidationConfig() } + view.client = nil + stubMessageOrigin("https://trusted.example.com") + view.messageRequestURL = { _ in URL(string: "https://trusted.example.com:0")! } + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let rejection = LockedValue(nil) + ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + + view.userContentController( + WKUserContentController(), + didReceive: MockScriptMessage(body: Self.readyBody) + ) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + XCTAssertEqual(rejection.get()?.origin, "https://trusted.example.com") + XCTAssertEqual(rejection.get()?.message, Self.readyBody) + XCTAssertEqual(rejection.get()?.reason, "origin uses unsupported port 0") + } + + @MainActor + func testOriginValidationRejectsChildFrameMessages() { + defer { resetOriginValidationConfig() } + view.client = nil + stubMessageOrigin("https://checkout.example.com") + view.messageIsMainFrame = { _ in false } + let rejection = LockedValue(nil) + ShopifyCheckoutKit.configuration.onMessageRejected = { rejection.set($0) } + + view.userContentController( + WKUserContentController(), + didReceive: MockScriptMessage(body: Self.readyBody) + ) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + XCTAssertEqual(rejection.get()?.message, Self.readyBody) + XCTAssertEqual(rejection.get()?.reason, "message was sent from a child frame") + } + + @MainActor + func testOriginValidationAllowsConfiguredOrigin() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://trusted.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationAllowsCheckoutOriginWhenAllowlistSet() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + // url is https://shopify1.shopify.com/checkouts/cn/123 + stubMessageOrigin("https://shopify1.shopify.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationAllowsShopAppSubdomainWhenAllowlistSet() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://checkout.shop.app") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationStarEscapeHatchAllowsAllOrigins() async { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://evil.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["*"] + let responseSent = expectation(description: "response sent") + MockCheckoutBridge.sendResponseExpectation = responseSent + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + await fulfillment(of: [responseSent], timeout: 5.0) + XCTAssertTrue(MockCheckoutBridge.sendResponseCalled) + } + + @MainActor + func testOriginValidationDefaultRejectionLogsWithoutCrashing() { + defer { resetOriginValidationConfig() } + view.client = nil + view.loadedCheckoutURL = url + stubMessageOrigin("https://evil.example.com") + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://trusted.example.com"] + let message = MockScriptMessage(body: Self.readyBody) + + view.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertFalse(MockCheckoutBridge.sendResponseCalled) + } } private actor RecordingBridgeClient: CheckoutCommunicationProtocol { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 9b9bd8ec5..214ed1a18 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -46,8 +46,26 @@ class ConfigurationTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, .storefront) } + func testAllowedMessageOriginsDefaultsToEmpty() { + XCTAssertEqual(ShopifyCheckoutKit.configuration.allowedMessageOrigins, []) + } + + func testAllowedMessageOriginsCanBeSet() { + ShopifyCheckoutKit.configuration.allowedMessageOrigins = ["https://example.com", "*"] + XCTAssertEqual(ShopifyCheckoutKit.configuration.allowedMessageOrigins, ["https://example.com", "*"]) + } + + func testOnMessageRejectedDefaultsToNil() { + XCTAssertNil(ShopifyCheckoutKit.configuration.onMessageRejected) + } + + func testOnMessageRejectedCanBeSet() { + ShopifyCheckoutKit.configuration.onMessageRejected = { _ in } + XCTAssertNotNil(ShopifyCheckoutKit.configuration.onMessageRejected) + } + func testPreloadingCanBeDisabled() async throws { - let checkoutURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) ShopifyCheckoutKit.preload(checkout: checkoutURL) ShopifyCheckoutKit.configuration.preloading.enabled = false @@ -61,7 +79,7 @@ class ConfigurationTests: XCTestCase { } func testChangingConfigurationWithoutChangingPreloadingDoesNotInvalidatePreload() async throws { - let checkoutURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) ShopifyCheckoutKit.preload(checkout: checkoutURL) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift new file mode 100644 index 000000000..121698228 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/MessageOriginValidatorTests.swift @@ -0,0 +1,202 @@ +@testable import ShopifyCheckoutKit +import WebKit +import XCTest + +final class MessageOriginValidatorTests: XCTestCase { + private let checkoutURL = URL(string: "https://checkout.example.com/checkouts/cn/123")! + + // MARK: - effectiveAllowlist + + func testEmptyAllowlistAllowsAllOnNativeSurface() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: [], + checkoutURL: checkoutURL + ) + XCTAssertNil(patterns) + } + + func testStarAllowlistAllowsAll() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["*"], + checkoutURL: checkoutURL + ) + XCTAssertNil(patterns) + } + + func testAllowlistAppendsCheckoutOriginAndShopApp() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["https://merchant.example.com"], + checkoutURL: checkoutURL + ) + XCTAssertEqual(patterns, [ + "https://merchant.example.com", + "https://checkout.example.com", + "https://shop.app", + "https://*.shop.app" + ]) + } + + func testAllowlistWithoutCheckoutURLStillIncludesShopApp() { + let patterns = MessageOriginValidator.effectiveAllowlist( + configuredOrigins: ["https://merchant.example.com"], + checkoutURL: nil + ) + XCTAssertEqual(patterns, [ + "https://merchant.example.com", + "https://shop.app", + "https://*.shop.app" + ]) + } + + // MARK: - matches (exact origin) + + func testExactOriginMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginWithTrailingSlashMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com/", origin: origin)) + } + + func testExactOriginRejectsURLComponentsBeyondOrigin() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + let invalidPatterns = [ + "https://user@example.com", + "https://example.com/path", + "https://example.com?query=value", + "https://example.com#fragment" + ] + + for pattern in invalidPatterns { + XCTAssertFalse(MessageOriginValidator.matches(pattern: pattern, origin: origin), pattern) + } + } + + func testExactOriginRejectsDifferentHost() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginRejectsDifferentScheme() { + let origin = MessageOrigin(scheme: "http", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExactOriginRejectsSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "sub.example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + // MARK: - matches (default vs explicit port) + + func testDefaultPortMatchesOmittedPort() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 443) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testNonDefaultPortMismatchIsRejected() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 8443) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://example.com", origin: origin)) + } + + func testExplicitPortMatches() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: 8443) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com:8443", origin: origin)) + } + + func testExplicitDefaultPortMatchesOmittedPort() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://example.com:443", origin: origin)) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.org:443", origin: MessageOrigin( + scheme: "https", + host: "sub.example.org", + port: nil + ))) + } + + func testBracketedIPv6OriginsWithDefaultAndExplicitPorts() { + let defaultPortOrigin = MessageOrigin(scheme: "https", host: "2001:db8::1", port: nil) + let explicitPortOrigin = MessageOrigin(scheme: "https", host: "2001:db8::2", port: 8443) + + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://[2001:db8::1]:443", origin: defaultPortOrigin)) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://[2001:db8::2]:8443", origin: explicitPortOrigin)) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://[2001:db8::2]", origin: explicitPortOrigin)) + XCTAssertEqual(defaultPortOrigin.description, "https://[2001:db8::1]") + } + + // MARK: - matches (wildcard subdomain) + + func testWildcardMatchesProperSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "a.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardWithTrailingSlashMatchesProperSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "a.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com/", origin: origin)) + } + + func testWildcardMatchesNestedSubdomain() { + let origin = MessageOrigin(scheme: "https", host: "a.b.example.com", port: nil) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardRejectsApex() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + func testWildcardRejectsUnrelatedSuffix() { + let origin = MessageOrigin(scheme: "https", host: "notexample.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "https://*.example.com", origin: origin)) + } + + // MARK: - matches (escape hatch & invalid) + + func testStarPatternMatchesAnything() { + let origin = MessageOrigin(scheme: "http", host: "anything.test", port: 9999) + XCTAssertTrue(MessageOriginValidator.matches(pattern: "*", origin: origin)) + } + + func testInvalidPatternIsSkipped() { + let origin = MessageOrigin(scheme: "https", host: "example.com", port: nil) + XCTAssertFalse(MessageOriginValidator.matches(pattern: "not-a-valid-origin", origin: origin)) + } + + // MARK: - isAllowed + + func testIsAllowedReturnsTrueWhenPatternsNil() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertTrue(MessageOriginValidator.isAllowed(origin: origin, patterns: nil)) + } + + func testIsAllowedMatchesAnyPattern() { + let origin = MessageOrigin(scheme: "https", host: "sub.shop.app", port: nil) + XCTAssertTrue(MessageOriginValidator.isAllowed( + origin: origin, + patterns: ["https://merchant.example.com", "https://shop.app", "https://*.shop.app"] + )) + } + + func testIsAllowedRejectsWhenNoPatternMatches() { + let origin = MessageOrigin(scheme: "https", host: "evil.com", port: nil) + XCTAssertFalse(MessageOriginValidator.isAllowed( + origin: origin, + patterns: ["https://merchant.example.com", "https://shop.app", "https://*.shop.app"] + )) + } + + // MARK: - MessageOrigin + + func testMessageOriginFromURLDropsDefaultPort() throws { + let origin = try MessageOrigin(url: XCTUnwrap(URL(string: "https://example.com/path?x=1"))) + XCTAssertEqual(origin?.description, "https://example.com") + } + + func testMessageOriginFromURLKeepsExplicitPort() throws { + let origin = try MessageOrigin(url: XCTUnwrap(URL(string: "https://example.com:8443/path"))) + XCTAssertEqual(origin?.description, "https://example.com:8443") + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index 7c4b806c6..a644ed36a 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -13,7 +13,7 @@ import XCTest /// leave the slot untouched. @MainActor class PreloadCacheTests: XCTestCase { - private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! override func setUp() async throws { try await super.setUp() @@ -203,6 +203,7 @@ class PreloadCacheTests: XCTestCase { func test_TerminalErrorOnForeignViewPreservesSlot() async { let entry = storeCacheEntry() let foreign = CheckoutWebView(entryPoint: nil) + foreign.messageIsMainFrame = { _ in true } let delegate = MockCheckoutWebViewDelegate() let didFail = expectation(description: "foreign view delegate receives failure") delegate.didFailWithErrorExpectation = didFail @@ -239,6 +240,7 @@ class PreloadCacheTests: XCTestCase { private func storeCacheEntry() -> CheckoutWebView { let entry = CheckoutWebView(entryPoint: nil) + entry.messageIsMainFrame = { _ in true } _ = CheckoutWebView.preloadCache.store(entry, for: PreloadKey(url: url, entryPoint: nil)) return entry } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index b56e1e3e4..b1e0ddae4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -6,7 +6,7 @@ import XCTest @MainActor class PreloadObservabilityTests: XCTestCase { - private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + private var url = URL(string: "https://shopify1.shopify.com/checkouts/cn/123")! override func setUp() async throws { try await super.setUp() @@ -28,6 +28,14 @@ class PreloadObservabilityTests: XCTestCase { } } + func testHTTPPreloadTransitionsToNavigationFailure() throws { + let insecureURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/123")) + + let preload = ShopifyCheckoutKit.preload(checkout: insecureURL) + + XCTAssertEqual(preload?.state, .failed(reason: .navigationFailed)) + } + func testManualInvalidateTransitionsToIdle() { let preload = ShopifyCheckoutKit.preload(checkout: url) @@ -188,7 +196,7 @@ class PreloadObservabilityTests: XCTestCase { for: PreloadKey(url: url, entryPoint: nil) ) - let otherURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/other")) + let otherURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/other")) _ = CheckoutWebView.preloadCache.view(for: PreloadKey(url: otherURL, entryPoint: nil)) withExtendedLifetime(preload) { diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 81809842e..b2c0349e2 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -4406,6 +4406,257 @@ } ] }, + { + "kind": "Var", + "name": "allowedMessageOrigins", + "printedName": "allowedMessageOrigins", + "children": [ + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvp", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvg", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Array", + "printedName": "[Swift.String]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sa" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvs", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV21allowedMessageOriginsSaySSGvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "onMessageRejected", + "printedName": "onMessageRejected", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "MessageRejection", + "printedName": "ShopifyCheckoutKit.MessageRejection", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvp", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "MessageRejection", + "printedName": "ShopifyCheckoutKit.MessageRejection", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvg", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.MessageRejection) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.MessageRejection) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "MessageRejection", + "printedName": "ShopifyCheckoutKit.MessageRejection", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvs", + "mangledName": "$s18ShopifyCheckoutKit13ConfigurationV17onMessageRejectedyAA0F9RejectionVYbcSgvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "set" + } + ] + }, { "kind": "TypeDecl", "name": "Appearance", @@ -6336,6 +6587,221 @@ } ] }, + { + "kind": "TypeDecl", + "name": "MessageRejection", + "printedName": "MessageRejection", + "children": [ + { + "kind": "Var", + "name": "origin", + "printedName": "origin", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6originSSvp", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6originSSvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6originSSvg", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6originSSvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "message", + "printedName": "message", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV7messageSSvp", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV7messageSSvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV7messageSSvg", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV7messageSSvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "reason", + "printedName": "reason", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6reasonSSvp", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6reasonSSvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6reasonSSvg", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6reasonSSvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(origin:message:reason:)", + "children": [ + { + "kind": "TypeNominal", + "name": "MessageRejection", + "printedName": "ShopifyCheckoutKit.MessageRejection", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Constructor", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV6origin7message6reasonACSS_S2Stcfc", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV6origin7message6reasonACSS_S2Stcfc", + "moduleName": "ShopifyCheckoutKit", + "init_kind": "Designated" + } + ], + "declKind": "Struct", + "usr": "s:18ShopifyCheckoutKit16MessageRejectionV", + "mangledName": "$s18ShopifyCheckoutKit16MessageRejectionV", + "moduleName": "ShopifyCheckoutKit", + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, { "kind": "TypeDecl", "name": "MetaData", diff --git a/platforms/web/README.md b/platforms/web/README.md index 849ec075e..66b2ba009 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,59 @@ 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. | + +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 + +``` + +```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