diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 4c91fba96..13efdaeb5 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -347,6 +347,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` | | `[]` | Extra origins trusted to send incoming checkout messages. See [Incoming message origin validation](#incoming-message-origin-validation). | Here's an example of how a fully customized configuration object might look: @@ -468,6 +469,29 @@ function AppWithContext() { } ``` +### 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. + +```tsx +const config: Configuration = { + allowedMessageOrigins: [ + 'https://checkout.example.com', + 'https://*.example.org', + ], +}; +``` + +Entries may be exact origins (`https://example.com`), wildcard subdomains +(`https://*.example.com`, matching subdomains but not the apex), or `'*'` to +explicitly disable origin validation. + +Messages dropped by origin validation are never silently discarded: the native +SDK logs each rejection as a warning with the message origin and the reason it +was dropped. The message body is untrusted and is not logged. + ### Localization #### Checkout Sheet title 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 40a3354b5..2762a65ac 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,10 +13,12 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { @@ -125,6 +127,8 @@ public WritableMap getConfig() { resultConfig.putString("colorScheme", colorSchemeStringFor(checkoutConfig.getAppearance())); resultConfig.putString("logLevel", logLevelStringFor(checkoutConfig.getLogLevel())); resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); + resultConfig.putArray("allowedMessageOrigins", + Arguments.fromList(new ArrayList<>(checkoutConfig.getAllowedMessageOrigins()))); return resultConfig; } @@ -140,6 +144,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 = logLevelFor(config.getString("logLevel")); @@ -168,6 +176,20 @@ public void setConfig(ReadableMap config) { }); } + private static 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; + } + @ReactMethod(isBlockingSynchronousMethod = true) public boolean configureAcceleratedCheckouts( String storefrontDomain, 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 1a35c076d..00e0869f5 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 @@ -164,6 +164,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, let appearance = appearanceFor(colorScheme) { @@ -199,6 +203,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) ] } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json index b1900248d..f02701faf 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json @@ -19,8 +19,8 @@ }, "checkoutKit": { "nativeSdkVersions": { - "ios": "4.0.0-alpha.4", - "android": "4.0.0-alpha.4" + "ios": "4.0.0-alpha.5", + "android": "4.0.0-alpha.5" } }, "scripts": { 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 e274b9993..de437dcbb 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 @@ -113,6 +113,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 never silently dropped: the native SDK logs each + * rejection as a warning with the message origin and reason. + * + * @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..d1eed9157 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; }; @@ -46,6 +47,7 @@ type ConfigurationResultSpec = { tintColor?: string; backgroundColor?: string; closeButtonColor?: string; + allowedMessageOrigins: string[]; }; export interface Spec extends TurboModule { 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 fc949e42a..c254468b0 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 @@ -209,6 +209,18 @@ describe('ShopifyCheckoutKit', () => { instance.setConfig(configWithTitle); expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithTitle); }); + + 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', () => { @@ -668,6 +680,24 @@ describe('ShopifyCheckoutKit', () => { expect(result.logLevel).toBe('trace'); expect(result.colorScheme).toBe('sepia'); }); + + it('returns configured allowed message origins', () => { + NativeModule.getConfig.mockReturnValueOnce({ + colorScheme: 'automatic', + logLevel: 'error', + preloading: true, + allowedMessageOrigins: ['https://example.com'], + }); + + const instance = new ShopifyCheckout(); + + expect(instance.getConfig()).toStrictEqual({ + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + preloading: true, + allowedMessageOrigins: ['https://example.com'], + }); + }); }); 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 1a66e8940..3fbbde26e 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 @@ -78,6 +78,8 @@ public void setup() { mocks = MockitoAnnotations.openMocks(this); mockedArguments = Mockito.mockStatic(Arguments.class); mockedArguments.when(Arguments::createMap).thenAnswer(invocation -> new JavaOnlyMap()); + mockedArguments.when(() -> Arguments.fromList(anyList())) + .thenAnswer(invocation -> JavaOnlyArray.from(invocation.getArgument(0))); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); shopifyCheckoutKitModule = new ShopifyCheckoutKitModule(mockReactContext); @@ -317,6 +319,22 @@ public void testUnknownColorSchemeKeepsTheNativeDefaultAppearance() { .isEqualTo("storefront"); } + @Test + public void testAllowedMessageOriginsRoundTrip() { + JavaOnlyMap config = new JavaOnlyMap(); + JavaOnlyArray allowedMessageOrigins = new JavaOnlyArray(); + allowedMessageOrigins.pushString("https://example.com"); + allowedMessageOrigins.pushString("https://*.example.com"); + config.putArray("allowedMessageOrigins", allowedMessageOrigins); + + shopifyCheckoutKitModule.setConfig(config); + + assertThat(ShopifyCheckoutKitModule.checkoutConfig.getAllowedMessageOrigins()) + .containsExactlyInAnyOrder("https://example.com", "https://*.example.com"); + assertThat(shopifyCheckoutKitModule.getConfig().getArray("allowedMessageOrigins").toArrayList()) + .containsExactlyInAnyOrder("https://example.com", "https://*.example.com"); + } + @Test public void testCanConfigureLightColorSchemeWithValidColors() { JavaOnlyMap androidColors = createValidLightColors(); diff --git a/platforms/react-native/sample/ios/Podfile.lock b/platforms/react-native/sample/ios/Podfile.lock index 09a6a9807..a2b196f5f 100644 --- a/platforms/react-native/sample/ios/Podfile.lock +++ b/platforms/react-native/sample/ios/Podfile.lock @@ -2605,8 +2605,8 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ShopifyCheckoutKit (~> 4.0.0-alpha.4) - - ShopifyCheckoutKit/AcceleratedCheckouts (~> 4.0.0-alpha.4) + - ShopifyCheckoutKit (~> 4.0.0-alpha.5) + - ShopifyCheckoutKit/AcceleratedCheckouts (~> 4.0.0-alpha.5) - SocketRocket - Yoga - RNVectorIcons (10.3.0): @@ -2638,11 +2638,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ShopifyCheckoutKit (4.0.0-alpha.4): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.4) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.4): + - ShopifyCheckoutKit (4.0.0-alpha.5): + - ShopifyCheckoutKit/Core (= 4.0.0-alpha.5) + - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.5): - ShopifyCheckoutKit/Core - - ShopifyCheckoutKit/Core (4.0.0-alpha.4) + - ShopifyCheckoutKit/Core (4.0.0-alpha.5) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2996,9 +2996,9 @@ SPEC CHECKSUMS: RNGestureHandler: eeb622199ef1fb3a076243131095df1c797072f0 RNReanimated: 237d420b7bb4378ef1dacc7d7a5c674fddb4b5d2 RNScreens: 3fc29af06302e1f1c18a7829fe57cbc2c0259912 - RNShopifyCheckoutKit: 2f123d00c3b48120f8e02a3c1c650db61caec677 + RNShopifyCheckoutKit: 25f8126109e2af64a90429370ef0436a2871fbdc RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f - ShopifyCheckoutKit: 95b6402d901c45c2b2b3fc1182a6fe735f1b7c54 + ShopifyCheckoutKit: 7874c8866e6c889d86194398c97e00388111d972 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 diff --git a/platforms/react-native/test/rct-integration-app/Podfile.lock b/platforms/react-native/test/rct-integration-app/Podfile.lock index 4a22c56fc..08006511b 100644 --- a/platforms/react-native/test/rct-integration-app/Podfile.lock +++ b/platforms/react-native/test/rct-integration-app/Podfile.lock @@ -2149,15 +2149,15 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ShopifyCheckoutKit (~> 4.0.0-alpha.4) - - ShopifyCheckoutKit/AcceleratedCheckouts (~> 4.0.0-alpha.4) + - ShopifyCheckoutKit (~> 4.0.0-alpha.5) + - ShopifyCheckoutKit/AcceleratedCheckouts (~> 4.0.0-alpha.5) - SocketRocket - Yoga - - ShopifyCheckoutKit (4.0.0-alpha.4): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.4) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.4): + - ShopifyCheckoutKit (4.0.0-alpha.5): + - ShopifyCheckoutKit/Core (= 4.0.0-alpha.5) + - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.5): - ShopifyCheckoutKit/Core - - ShopifyCheckoutKit/Core (4.0.0-alpha.4) + - ShopifyCheckoutKit/Core (4.0.0-alpha.5) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2464,8 +2464,8 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 ReactCodegen: 3ba2a79bc32ff858814c17ade10931b33b09dcf4 ReactCommon: 592ef441605638b95e533653259254b4bd35ff4f - RNShopifyCheckoutKit: 2f123d00c3b48120f8e02a3c1c650db61caec677 - ShopifyCheckoutKit: 95b6402d901c45c2b2b3fc1182a6fe735f1b7c54 + RNShopifyCheckoutKit: 25f8126109e2af64a90429370ef0436a2871fbdc + ShopifyCheckoutKit: 7874c8866e6c889d86194398c97e00388111d972 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 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 027e8c8d9..c79589600 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.warn ShopifyCheckoutKit.configuration.preloading.enabled = true + ShopifyCheckoutKit.configuration.allowedMessageOrigins = [] } private func getShopifyCheckoutKit() -> RCTShopifyCheckoutKit { @@ -99,6 +100,21 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["title"] as? String, "Custom Checkout") } + func testAllowedMessageOriginsRoundTrip() { + shopifyCheckoutKit.setConfig(["allowedMessageOrigins": ["https://example.com", "https://*.example.com"]]) + + XCTAssertEqual( + ShopifyCheckoutKit.configuration.allowedMessageOrigins, + ["https://example.com", "https://*.example.com"] + ) + + let result = shopifyCheckoutKit.getConfig() as? [String: Any] + XCTAssertEqual( + result?["allowedMessageOrigins"] as? [String], + ["https://example.com", "https://*.example.com"] + ) + } + func testConfigureWithInvalidColors() { let configuration: [AnyHashable: Any] = [ "colors": [