@shopgate/engage 7.31.7 → 7.31.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopgate/engage",
3
- "version": "7.31.7",
3
+ "version": "7.31.8",
4
4
  "description": "Shopgate's ENGAGE library.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Shopgate <support@shopgate.com>",
@@ -18,12 +18,12 @@
18
18
  "dependencies": {
19
19
  "@emotion/react": "^11.14.0",
20
20
  "@shopgate/native-modules": "1.0.0-beta.34",
21
- "@shopgate/pwa-common": "7.31.7",
22
- "@shopgate/pwa-common-commerce": "7.31.7",
23
- "@shopgate/pwa-core": "7.31.7",
24
- "@shopgate/pwa-ui-ios": "7.31.7",
25
- "@shopgate/pwa-ui-material": "7.31.7",
26
- "@shopgate/pwa-ui-shared": "7.31.7",
21
+ "@shopgate/pwa-common": "7.31.8",
22
+ "@shopgate/pwa-common-commerce": "7.31.8",
23
+ "@shopgate/pwa-core": "7.31.8",
24
+ "@shopgate/pwa-ui-ios": "7.31.8",
25
+ "@shopgate/pwa-ui-material": "7.31.8",
26
+ "@shopgate/pwa-ui-shared": "7.31.8",
27
27
  "@stripe/react-stripe-js": "^1.16.5",
28
28
  "@stripe/stripe-js": "^1.44.1",
29
29
  "@virtuous/conductor": "~2.5.0",
@@ -1,7 +1,15 @@
1
1
  /**
2
- * List of allowed origins for cms page preview iFrame communication.
2
+ * List of allowed origin patterns for cms page preview iFrame communication. Both directions are
3
+ * validated against these patterns: incoming messages are only processed when their origin matches,
4
+ * and outgoing messages are only posted to a matching origin - either the origin of the last
5
+ * accepted incoming message, or the origin of the embedding document.
6
+ *
7
+ * A "*" acts as a wildcard for one or more domain labels, so "https://*.shopgate.com" matches
8
+ * "https://app.shopgate.com" as well as "https://next.us.admin.shopgate.com", but neither
9
+ * "https://shopgate.com" nor "https://evil.shopgate.com.attacker.example". Every other character
10
+ * is matched literally, which means that scheme and port always have to match exactly.
3
11
  */
4
- export const ALLOWED_PAGE_PREVIEW_ORIGINS = ['https://next.admin.shopgatedev.com', 'https://next.admin.shopgatepg.com', 'https://next.admin.shopgate.com', 'https://next.us.admin.shopgate.com', 'https://admin-mono.shopgatedev.com', 'https://admin-mono.shopgatepg.com', 'https://admin-mono.shopgate.com', 'https://admin-mono-us.shopgate.com', 'http://localhost.localdev.cc', 'http://localhost:1337'];
12
+ export const ALLOWED_PAGE_PREVIEW_ORIGINS = ['https://*.shopgate.com', 'https://*.shopgatedev.com', 'https://*.shopgatepg.com', 'http://localhost.localdev.cc', 'http://localhost:1337'];
5
13
 
6
14
  // Whether to consider vertical margins when calculating the overlay position.
7
15
  export const CONSIDER_CONTAINER_MARGINS_ON_SCROLL_DEFAULT = false;
@@ -1,4 +1,8 @@
1
+ import "core-js/modules/es.array.includes.js";
1
2
  import "core-js/modules/es.string.replace.js";
3
+ import "core-js/modules/web.url.js";
4
+ import "core-js/modules/web.url.to-json.js";
5
+ import "core-js/modules/web.url-search-params.js";
2
6
  import { PAGE_PREVIEW_PATTERN } from '@shopgate/engage/page/constants';
3
7
 
4
8
  /**
@@ -50,4 +54,62 @@ export function checkScheduled({
50
54
  isActive,
51
55
  isExpired
52
56
  };
53
- }
57
+ }
58
+
59
+ /**
60
+ * Regular expression source that a "*" within an allowed origin pattern is replaced with. It
61
+ * matches one or more domain labels, but never a dot at the very end, so that the suffix of the
62
+ * pattern stays anchored to the end of the origin.
63
+ */
64
+ const ORIGIN_WILDCARD_SOURCE = '(?:[a-zA-Z0-9-]+\\.)*[a-zA-Z0-9-]+';
65
+
66
+ // Cache for regular expressions that were already created from an origin pattern.
67
+ const originPatternCache = new Map();
68
+
69
+ /**
70
+ * Converts an allowed origin pattern into an anchored regular expression. Every character except
71
+ * the "*" wildcard is matched literally.
72
+ * @param {string} pattern The origin pattern e.g. "https://*.shopgate.com".
73
+ * @returns {RegExp} The regular expression for the pattern.
74
+ */
75
+ const createOriginRegExp = pattern => {
76
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ return new RegExp(`^${escaped.split('\\*').join(ORIGIN_WILDCARD_SOURCE)}$`);
78
+ };
79
+
80
+ /**
81
+ * Checks if an origin is covered by a list of allowed origin patterns.
82
+ * @param {string} origin The origin to check e.g. "https://app.shopgate.com".
83
+ * @param {Array<string>} [patterns] The allowed origin patterns.
84
+ * @returns {boolean} Whether the origin is allowed.
85
+ */
86
+ export const isAllowedOrigin = (origin, patterns = []) => {
87
+ // Opaque origins are serialized as "null" and must never be trusted.
88
+ if (typeof origin !== 'string' || origin === '' || origin === 'null') {
89
+ return false;
90
+ }
91
+ return patterns.some(pattern => {
92
+ if (typeof pattern !== 'string' || pattern === '') {
93
+ return false;
94
+ }
95
+ if (!pattern.includes('*')) {
96
+ return pattern === origin;
97
+ }
98
+ if (!originPatternCache.has(pattern)) {
99
+ originPatternCache.set(pattern, createOriginRegExp(pattern));
100
+ }
101
+ return originPatternCache.get(pattern).test(origin);
102
+ });
103
+ };
104
+
105
+ /**
106
+ * Determines the origin of the document that embeds the current page.
107
+ * @returns {string|null} The referrer origin, or null when it can't be determined.
108
+ */
109
+ export const getReferrerOrigin = () => {
110
+ try {
111
+ return new URL(document.referrer).origin;
112
+ } catch (e) {
113
+ return null;
114
+ }
115
+ };
@@ -1,7 +1,3 @@
1
- import "core-js/modules/es.array.includes.js";
2
- import "core-js/modules/web.url.js";
3
- import "core-js/modules/web.url.to-json.js";
4
- import "core-js/modules/web.url-search-params.js";
5
1
  import { useEffect, useCallback, useRef, useContext, useMemo } from 'react';
6
2
  import { logger } from '@shopgate/engage/core/helpers';
7
3
  import { useDispatch } from 'react-redux';
@@ -9,7 +5,7 @@ import { useRoute } from '@shopgate/engage/core/hooks';
9
5
  import { receivePageConfigV2 } from '@shopgate/engage/page/action-creators';
10
6
  import { PAGE_PREVIEW_SLUG } from '@shopgate/engage/page/constants';
11
7
  import { ALLOWED_PAGE_PREVIEW_ORIGINS, CONSIDER_CONTAINER_MARGINS_ON_SCROLL_DEFAULT } from "./constants";
12
- import { getScrollContainer } from "./helpers";
8
+ import { getScrollContainer, isAllowedOrigin, getReferrerOrigin } from "./helpers";
13
9
  import { WidgetsPreviewContext } from "./WidgetsPreviewContext";
14
10
  import { dispatchWidgetPreviewEvent, useWidgetPreviewEvent } from "./events";
15
11
 
@@ -30,22 +26,26 @@ import { dispatchWidgetPreviewEvent, useWidgetPreviewEvent } from "./events";
30
26
  /**
31
27
  * Hook for postMessage communication when your component is inside an iframe.
32
28
  *
33
- * Listens on window for "message" events from any origin in parentOrigins,
34
- * and only calls onMessage(data, rawEvent) if both origin and source match.
29
+ * Listens on window for "message" events, and only calls onMessage(data, rawEvent) if the origin
30
+ * of the event is covered by parentOrigins and the message actually came from window.parent.
35
31
  *
36
32
  * @param {function(MessageData, any): void} onMessage
37
33
  * Callback invoked when a trusted message arrives. Receives data and the
38
34
  * raw event (so you can inspect origin, source, etc.).
39
35
  * @param {string[]} parentOrigins
40
- * Array of allowed parent origin strings (e.g.
41
- * ['https://a.example.com','https://b.example.com']).
36
+ * Array of allowed parent origin patterns (e.g.
37
+ * ['https://a.example.com','https://*.example.com']). See ALLOWED_PAGE_PREVIEW_ORIGINS for
38
+ * details about the supported wildcard syntax.
39
+ * @param {boolean} [enabled]
40
+ * Whether the message listener is supposed to be attached.
42
41
  * @returns {IframeMessengerResult}
43
42
  * An object with a single method:
44
43
  * • sendToParent(data, [targetOrigin]): void
45
44
  * – Posts data up to window.parent. By default it uses the most recently
46
- * seen origin (from an incoming message). If none, uses parentOrigins[0].
45
+ * seen origin (from an incoming message), otherwise the origin of the embedding document.
46
+ * Nothing is sent when neither of them is covered by parentOrigins.
47
47
  */
48
- function useIframeMessenger(onMessage, parentOrigins) {
48
+ function useIframeMessenger(onMessage, parentOrigins, enabled = true) {
49
49
  // Keep a ref to the latest onMessage callback so the listener always has it.
50
50
  const onMessageRef = useRef(onMessage);
51
51
  useEffect(() => {
@@ -59,15 +59,15 @@ function useIframeMessenger(onMessage, parentOrigins) {
59
59
  * Send a message up to the parent window.
60
60
  * @param {MessageData} data - The data object to post.
61
61
  * @param {string} [targetOrigin]
62
- * Optional override for the origin to post to. Must be one of
63
- * parentOrigins. If omitted, uses the last seen origin (lastOriginRef),
64
- * or parentOrigins[0], or "*" if array is empty.
62
+ * Optional override for the origin to post to. Must be covered by parentOrigins. If omitted,
63
+ * the last seen origin (lastOriginRef) or the origin of the embedding document is used.
65
64
  */
66
65
  const sendToParent = useCallback((data, targetOrigin) => {
67
- // Determine which origin to use: explicit, then last seen, then first, then "*".
68
- const originToUse = typeof targetOrigin === 'string' ? targetOrigin : lastOriginRef.current || new URL(document.referrer).origin || parentOrigins[0] || '*';
66
+ // Determine which origin to use: explicit, then last seen, then the embedding document.
67
+ // Patterns are no valid postMessage targets, so an unresolved origin aborts the send.
68
+ const originToUse = [targetOrigin, lastOriginRef.current, getReferrerOrigin()].find(origin => isAllowedOrigin(origin, parentOrigins));
69
69
  if (!originToUse) {
70
- logger.warn('useIframeMessenger: no targetOrigin available. ' + 'Provide parentOrigins or pass targetOrigin.');
70
+ logger.warn('useIframeMessenger: no allowed targetOrigin available. ' + 'Provide parentOrigins or pass an allowed targetOrigin.');
71
71
  return;
72
72
  }
73
73
  window.parent.postMessage(data, originToUse);
@@ -75,13 +75,17 @@ function useIframeMessenger(onMessage, parentOrigins) {
75
75
 
76
76
  // Attach / detach the "message" listener.
77
77
  useEffect(() => {
78
+ if (!enabled) {
79
+ return undefined;
80
+ }
81
+
78
82
  /**
79
83
  * Handler for incoming postMessage events.
80
84
  * @param {any} rawEvent – The original MessageEvent object.
81
85
  */
82
86
  function handler(rawEvent) {
83
- // Only proceed if the origin is in our whitelist.
84
- if (!parentOrigins.includes(rawEvent.origin)) return;
87
+ // Only proceed if the origin is covered by our whitelist.
88
+ if (!isAllowedOrigin(rawEvent.origin, parentOrigins)) return;
85
89
  // Ensure the message actually came from window.parent.
86
90
  if (rawEvent.source !== window.parent) return;
87
91
 
@@ -95,7 +99,7 @@ function useIframeMessenger(onMessage, parentOrigins) {
95
99
  return () => {
96
100
  window.removeEventListener('message', handler);
97
101
  };
98
- }, [parentOrigins, sendToParent]);
102
+ }, [enabled, parentOrigins]);
99
103
  return {
100
104
  sendToParent
101
105
  };
@@ -123,6 +127,10 @@ export const usePreviewIframeCommunication = (isActive = false) => {
123
127
  const {
124
128
  sendToParent
125
129
  } = useIframeMessenger(data => {
130
+ // Allowed parents can also emit unrelated messages with arbitrary payloads.
131
+ if (typeof data !== 'object' || data === null) {
132
+ return;
133
+ }
126
134
  if (data.type === 'receivePageConfig') {
127
135
  // Page preview config received from the parent window.
128
136
  dispatch(receivePageConfigV2({
@@ -194,7 +202,7 @@ export const usePreviewIframeCommunication = (isActive = false) => {
194
202
  });
195
203
  }
196
204
  }
197
- }, ALLOWED_PAGE_PREVIEW_ORIGINS);
205
+ }, ALLOWED_PAGE_PREVIEW_ORIGINS, isActive);
198
206
  useWidgetPreviewEvent('widget-clicked', e => {
199
207
  if (!isActive) {
200
208
  return;