@trackunit/react-modal 2.1.22 → 2.1.23

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/index.cjs.js CHANGED
@@ -581,6 +581,69 @@ const ModalHeader = react.forwardRef(({ heading, subHeading, onClickClose, "data
581
581
  }), "data-testid": dataTestId, id: id, ref: ref, style: style, children: [jsxRuntime.jsxs("div", { className: cvaHeadingContainer(), children: [jsxRuntime.jsxs("div", { className: cvaTitleContainer(), children: [jsxRuntime.jsx(reactComponents.Heading, { variant: "tertiary", children: heading }), accessories] }), Boolean(subHeading) ? (jsxRuntime.jsx(reactComponents.Text, { size: "small", subtle: true, children: subHeading })) : null, children] }), jsxRuntime.jsx("div", { className: cvaIconContainer(), children: jsxRuntime.jsx(reactComponents.IconButton, { className: "!h-min", "data-testid": dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "XMark", size: "small" }), onClick: onClickClose, size: "small", title: t("modalHeader.close"), variant: "ghost-neutral" }) })] }));
582
582
  });
583
583
 
584
+ /**
585
+ * Trust anchor for the modal registry's outbound (iframe → host) postMessage.
586
+ *
587
+ * `@trackunit/react-modal` is a standalone, reusable UI library that must not
588
+ * depend on `iris-app-runtime` (see `modalStackRegistry.ts` for why the modal
589
+ * stack bridge uses raw postMessage rather than penpal). So, exactly as the
590
+ * dependency-free `iris-app-loader` does, the trusted-host-origin resolver is
591
+ * duplicated here instead of imported.
592
+ *
593
+ * The `BRANDED_HOST_DOMAINS` list is the single security-relevant piece that
594
+ * must stay in sync across all copies (this file, the loader copy, the runtime
595
+ * core resolver, and `BrandedUrls` in
596
+ * `libs/iris-app-sdk/iris-app-api/src/types/irisAppCspInput.ts`); a
597
+ * `platform-test` (`branded-host-origins-in-sync.spec.ts`) fails the build on
598
+ * drift.
599
+ */
600
+ const BRANDED_HOST_DOMAINS = [
601
+ "trackunit.com",
602
+ "wackerneuson.com",
603
+ "manitou.com",
604
+ "niftylinkmanager.com",
605
+ "skyjack.com",
606
+ "ahernaccess.com",
607
+ "magnith.com",
608
+ "terberg.com",
609
+ "mymecalac.com",
610
+ "delille.be",
611
+ ];
612
+ const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
613
+ const brandedHostPattern = (domain) => new RegExp(`^https:\\/\\/([a-z0-9-]+\\.)*${escapeForRegExp(domain)}$`);
614
+ /** Static whitelist of trusted host origins (branded domains + dev/localhost). */
615
+ const trustedHostOrigins = [
616
+ ...BRANDED_HOST_DOMAINS.map(brandedHostPattern),
617
+ /^https:\/\/([a-z0-9-]+\.)*trackunit\.app$/,
618
+ /^http:\/\/localhost(:\d+)?$/,
619
+ ];
620
+ /** Whether the given window origin belongs to a trusted Trackunit host. */
621
+ const isWhitelistedHostOrigin = (origin) => trustedHostOrigins.some(entry => (typeof entry === "string" ? entry === origin : entry.test(origin)));
622
+ // Capture-once cache: the iframe never switches domain, so the embedding host
623
+ // origin is resolved on first read and reused for the document's lifetime.
624
+ let cachedHostOrigin;
625
+ /**
626
+ * Resolves the embedding host's origin from `document.referrer`, returning it
627
+ * only when it is whitelisted; otherwise `undefined` (non-whitelisted, blank,
628
+ * or unreadable referrer). Memoized on first call.
629
+ */
630
+ const getTrustedHostOrigin = () => {
631
+ if (cachedHostOrigin) {
632
+ return cachedHostOrigin.value;
633
+ }
634
+ let value;
635
+ try {
636
+ const referrer = document.referrer;
637
+ const origin = referrer ? new URL(referrer).origin : undefined;
638
+ value = origin && isWhitelistedHostOrigin(origin) ? origin : undefined;
639
+ }
640
+ catch {
641
+ value = undefined;
642
+ }
643
+ cachedHostOrigin = { value };
644
+ return cachedHostOrigin.value;
645
+ };
646
+
584
647
  /**
585
648
  * Modal Stack Registry - Cross-Bundle Modal Awareness
586
649
  *
@@ -668,15 +731,29 @@ const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
668
731
  * The host aggregates these per-iframe for accurate total tracking.
669
732
  */
670
733
  const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
671
- const isInIframe = typeof window !== "undefined" && window.parent !== window;
672
734
  /**
673
735
  * Broadcast this iframe's modal count to the parent (host).
674
- * Only runs when in an iframe context. Called automatically on register/unregister.
736
+ *
737
+ * Only runs when in an iframe context. Called automatically on
738
+ * register/unregister.
739
+ *
740
+ * Security: the message is posted to the *resolved trusted host origin*, not
741
+ * `"*"`. When the embedding host can't be resolved to a whitelisted Trackunit
742
+ * origin (non-Trackunit / blank / unreadable referrer) we fail closed and drop
743
+ * the broadcast rather than leaking modal-stack data to an arbitrary embedder.
744
+ * This mirrors the loader's `postMessageToHost` chokepoint and uses the same
745
+ * (dependency-safe, duplicated) trusted-host-origin resolver.
675
746
  */
676
747
  const broadcastToParent = (modalCount) => {
677
- if (isInIframe) {
678
- window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
748
+ const isInIframe = typeof window !== "undefined" && window.parent !== window;
749
+ if (!isInIframe) {
750
+ return;
751
+ }
752
+ const targetOrigin = getTrustedHostOrigin();
753
+ if (!targetOrigin) {
754
+ return;
679
755
  }
756
+ window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, targetOrigin);
680
757
  };
681
758
  /**
682
759
  * Set up the global message listener for cross-bundle modal stack communication.
package/index.esm.js CHANGED
@@ -579,6 +579,69 @@ const ModalHeader = forwardRef(({ heading, subHeading, onClickClose, "data-testi
579
579
  }), "data-testid": dataTestId, id: id, ref: ref, style: style, children: [jsxs("div", { className: cvaHeadingContainer(), children: [jsxs("div", { className: cvaTitleContainer(), children: [jsx(Heading, { variant: "tertiary", children: heading }), accessories] }), Boolean(subHeading) ? (jsx(Text, { size: "small", subtle: true, children: subHeading })) : null, children] }), jsx("div", { className: cvaIconContainer(), children: jsx(IconButton, { className: "!h-min", "data-testid": dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: onClickClose, size: "small", title: t("modalHeader.close"), variant: "ghost-neutral" }) })] }));
580
580
  });
581
581
 
582
+ /**
583
+ * Trust anchor for the modal registry's outbound (iframe → host) postMessage.
584
+ *
585
+ * `@trackunit/react-modal` is a standalone, reusable UI library that must not
586
+ * depend on `iris-app-runtime` (see `modalStackRegistry.ts` for why the modal
587
+ * stack bridge uses raw postMessage rather than penpal). So, exactly as the
588
+ * dependency-free `iris-app-loader` does, the trusted-host-origin resolver is
589
+ * duplicated here instead of imported.
590
+ *
591
+ * The `BRANDED_HOST_DOMAINS` list is the single security-relevant piece that
592
+ * must stay in sync across all copies (this file, the loader copy, the runtime
593
+ * core resolver, and `BrandedUrls` in
594
+ * `libs/iris-app-sdk/iris-app-api/src/types/irisAppCspInput.ts`); a
595
+ * `platform-test` (`branded-host-origins-in-sync.spec.ts`) fails the build on
596
+ * drift.
597
+ */
598
+ const BRANDED_HOST_DOMAINS = [
599
+ "trackunit.com",
600
+ "wackerneuson.com",
601
+ "manitou.com",
602
+ "niftylinkmanager.com",
603
+ "skyjack.com",
604
+ "ahernaccess.com",
605
+ "magnith.com",
606
+ "terberg.com",
607
+ "mymecalac.com",
608
+ "delille.be",
609
+ ];
610
+ const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
611
+ const brandedHostPattern = (domain) => new RegExp(`^https:\\/\\/([a-z0-9-]+\\.)*${escapeForRegExp(domain)}$`);
612
+ /** Static whitelist of trusted host origins (branded domains + dev/localhost). */
613
+ const trustedHostOrigins = [
614
+ ...BRANDED_HOST_DOMAINS.map(brandedHostPattern),
615
+ /^https:\/\/([a-z0-9-]+\.)*trackunit\.app$/,
616
+ /^http:\/\/localhost(:\d+)?$/,
617
+ ];
618
+ /** Whether the given window origin belongs to a trusted Trackunit host. */
619
+ const isWhitelistedHostOrigin = (origin) => trustedHostOrigins.some(entry => (typeof entry === "string" ? entry === origin : entry.test(origin)));
620
+ // Capture-once cache: the iframe never switches domain, so the embedding host
621
+ // origin is resolved on first read and reused for the document's lifetime.
622
+ let cachedHostOrigin;
623
+ /**
624
+ * Resolves the embedding host's origin from `document.referrer`, returning it
625
+ * only when it is whitelisted; otherwise `undefined` (non-whitelisted, blank,
626
+ * or unreadable referrer). Memoized on first call.
627
+ */
628
+ const getTrustedHostOrigin = () => {
629
+ if (cachedHostOrigin) {
630
+ return cachedHostOrigin.value;
631
+ }
632
+ let value;
633
+ try {
634
+ const referrer = document.referrer;
635
+ const origin = referrer ? new URL(referrer).origin : undefined;
636
+ value = origin && isWhitelistedHostOrigin(origin) ? origin : undefined;
637
+ }
638
+ catch {
639
+ value = undefined;
640
+ }
641
+ cachedHostOrigin = { value };
642
+ return cachedHostOrigin.value;
643
+ };
644
+
582
645
  /**
583
646
  * Modal Stack Registry - Cross-Bundle Modal Awareness
584
647
  *
@@ -666,15 +729,29 @@ const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
666
729
  * The host aggregates these per-iframe for accurate total tracking.
667
730
  */
668
731
  const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
669
- const isInIframe = typeof window !== "undefined" && window.parent !== window;
670
732
  /**
671
733
  * Broadcast this iframe's modal count to the parent (host).
672
- * Only runs when in an iframe context. Called automatically on register/unregister.
734
+ *
735
+ * Only runs when in an iframe context. Called automatically on
736
+ * register/unregister.
737
+ *
738
+ * Security: the message is posted to the *resolved trusted host origin*, not
739
+ * `"*"`. When the embedding host can't be resolved to a whitelisted Trackunit
740
+ * origin (non-Trackunit / blank / unreadable referrer) we fail closed and drop
741
+ * the broadcast rather than leaking modal-stack data to an arbitrary embedder.
742
+ * This mirrors the loader's `postMessageToHost` chokepoint and uses the same
743
+ * (dependency-safe, duplicated) trusted-host-origin resolver.
673
744
  */
674
745
  const broadcastToParent = (modalCount) => {
675
- if (isInIframe) {
676
- window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
746
+ const isInIframe = typeof window !== "undefined" && window.parent !== window;
747
+ if (!isInIframe) {
748
+ return;
749
+ }
750
+ const targetOrigin = getTrustedHostOrigin();
751
+ if (!targetOrigin) {
752
+ return;
677
753
  }
754
+ window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, targetOrigin);
678
755
  };
679
756
  /**
680
757
  * Set up the global message listener for cross-bundle modal stack communication.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-modal",
3
- "version": "2.1.22",
3
+ "version": "2.1.23",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -8,12 +8,12 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@floating-ui/react": "^0.26.25",
11
- "@trackunit/react-components": "2.1.20",
11
+ "@trackunit/react-components": "2.1.21",
12
12
  "@trackunit/css-class-variance-utilities": "1.13.28",
13
13
  "@trackunit/shared-utils": "1.15.28",
14
14
  "@floating-ui/react-dom": "2.1.2",
15
15
  "@trackunit/react-core-contexts-api": "1.17.30",
16
- "@trackunit/i18n-library-translation": "2.0.22"
16
+ "@trackunit/i18n-library-translation": "2.0.23"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@tanstack/react-router": "^1.114.29",
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Trust anchor for the modal registry's outbound (iframe → host) postMessage.
3
+ *
4
+ * `@trackunit/react-modal` is a standalone, reusable UI library that must not
5
+ * depend on `iris-app-runtime` (see `modalStackRegistry.ts` for why the modal
6
+ * stack bridge uses raw postMessage rather than penpal). So, exactly as the
7
+ * dependency-free `iris-app-loader` does, the trusted-host-origin resolver is
8
+ * duplicated here instead of imported.
9
+ *
10
+ * The `BRANDED_HOST_DOMAINS` list is the single security-relevant piece that
11
+ * must stay in sync across all copies (this file, the loader copy, the runtime
12
+ * core resolver, and `BrandedUrls` in
13
+ * `libs/iris-app-sdk/iris-app-api/src/types/irisAppCspInput.ts`); a
14
+ * `platform-test` (`branded-host-origins-in-sync.spec.ts`) fails the build on
15
+ * drift.
16
+ */
17
+ /**
18
+ * Resolves the embedding host's origin from `document.referrer`, returning it
19
+ * only when it is whitelisted; otherwise `undefined` (non-whitelisted, blank,
20
+ * or unreadable referrer). Memoized on first call.
21
+ */
22
+ export declare const getTrustedHostOrigin: () => string | undefined;
23
+ /** Test-only: clears the capture-once cache so a test can vary `document.referrer`. */
24
+ export declare const resetTrustedHostOriginCacheForTests: () => void;