@atlaskit/rovo-triggers 10.22.0 → 10.23.0

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.
Files changed (21) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cjs/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +29 -6
  3. package/dist/cjs/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +8 -1
  4. package/dist/cjs/common/utils/extension-context-bridge/policy.js +21 -1
  5. package/dist/cjs/common/utils/extension-context-bridge/registry.js +5 -0
  6. package/dist/cjs/common/utils/extension-context-bridge/transport.js +10 -2
  7. package/dist/es2019/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +28 -5
  8. package/dist/es2019/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +8 -1
  9. package/dist/es2019/common/utils/extension-context-bridge/policy.js +18 -0
  10. package/dist/es2019/common/utils/extension-context-bridge/registry.js +5 -0
  11. package/dist/es2019/common/utils/extension-context-bridge/transport.js +18 -9
  12. package/dist/esm/common/utils/extension-context-bridge/ExtensionContextBridgeClient.js +29 -6
  13. package/dist/esm/common/utils/extension-context-bridge/ExtensionContextBridgeHost.js +8 -1
  14. package/dist/esm/common/utils/extension-context-bridge/policy.js +20 -0
  15. package/dist/esm/common/utils/extension-context-bridge/registry.js +5 -0
  16. package/dist/esm/common/utils/extension-context-bridge/transport.js +10 -2
  17. package/dist/types/common/utils/extension-context-bridge/ExtensionContextBridgeClient.d.ts +11 -1
  18. package/dist/types/common/utils/extension-context-bridge/ExtensionContextBridgeHost.d.ts +11 -1
  19. package/dist/types/common/utils/extension-context-bridge/policy.d.ts +2 -0
  20. package/dist/types/common/utils/extension-context-bridge/transport.d.ts +11 -1
  21. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @atlaskit/rovo-triggers
2
2
 
3
+ ## 10.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`e07b1d5a5706a`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/e07b1d5a5706a) -
8
+ SECASR-8596 / SECASR-8597: pin the extension context bridge transport to a single peer origin, and
9
+ restrict which `set-message-context` context keys may cross it.
10
+
11
+ `createWindowTransport` gains an optional `acceptOrigin`, checked before a message is dispatched
12
+ to a subscriber. It defaults to `targetOrigin`, and the wildcard is refused. The chat-iframe
13
+ client no longer posts to `'*'`: it resolves the side panel's origin from the `extensionOrigin`
14
+ URL parameter and requires it to be a member of a consumer-supplied `allowedOrigins` list, so the
15
+ channel is pinned to the consumer's own extension rather than to any installed one. Absent either,
16
+ it builds no transport.
17
+
18
+ `set-message-context` now relays only allowlisted `contextKey`s. Its `data.value` is arbitrary
19
+ product-supplied context, so an unrecognised key is dropped before `setContext` is invoked.
20
+
21
+ `ExtensionContextBridgeHost` gains an optional `isAiEnabled`, and relays nothing when it is
22
+ explicitly `false`, so a tenant without Rovo entitlement never has page content cross into the
23
+ extension. `ChatOpenerSubscriber` resolves it from the product's Rovo entitlement store and passes
24
+ it down.
25
+
3
26
  ## 10.22.0
4
27
 
5
28
  ### Minor Changes
@@ -17,6 +17,24 @@ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbol
17
17
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
18
18
  /** Selection shape preserved across editor focus loss. */
19
19
 
20
+ /**
21
+ * The side panel's own origin, supplied by `buildIframeUrl()` in the extension's `sidepanel.ts`.
22
+ * Serves as both the `postMessage` target and the only origin this client accepts messages from.
23
+ *
24
+ * The parameter only selects which of `allowedOrigins` is the peer; it can never introduce one. So
25
+ * an absent parameter, an unknown extension, and an absent list all resolve to `undefined`, leaving
26
+ * the Client without a transport. `useSBSEvaluationBridge` reads the same parameter but falls back to
27
+ * `'*'` — tolerable for telemetry, not for a channel that drives editor writes.
28
+ */
29
+ var getSidePanelOrigin = function getSidePanelOrigin(allowedOrigins) {
30
+ try {
31
+ var value = new URLSearchParams(window.location.search).get('extensionOrigin');
32
+ return value && allowedOrigins !== null && allowedOrigins !== void 0 && allowedOrigins.includes(value) ? value : undefined;
33
+ } catch (_unused) {
34
+ return undefined;
35
+ }
36
+ };
37
+
20
38
  /**
21
39
  * Whether focus currently sits inside the chat. Confluence clears `selection` on
22
40
  * `editor-context-payload` both when the user clicks into chat and when they genuinely deselect;
@@ -40,7 +58,8 @@ var isChatFocused = function isChatFocused() {
40
58
  * Headless.
41
59
  */
42
60
  var ExtensionContextBridgeClient = exports.ExtensionContextBridgeClient = function ExtensionContextBridgeClient(_ref) {
43
- var transport = _ref.transport;
61
+ var transport = _ref.transport,
62
+ allowedOrigins = _ref.allowedOrigins;
44
63
  // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
45
64
  var active = (0, _fg.fg)('rovo-ext_context_bridge');
46
65
  var publish = (0, _main.usePublish)('ai-mate');
@@ -55,14 +74,18 @@ var ExtensionContextBridgeClient = exports.ExtensionContextBridgeClient = functi
55
74
  if (typeof window === 'undefined') {
56
75
  return undefined;
57
76
  }
58
- // `targetOrigin: '*'`: the receiving window (the side panel) is a different origin, so the
59
- // default (`selfWindow.location.origin`) would not match.
77
+ var sidePanelOrigin = getSidePanelOrigin(allowedOrigins);
78
+ if (!sidePanelOrigin) {
79
+ return undefined;
80
+ }
81
+ // The side panel is a different origin to this iframe, so `targetOrigin` cannot fall back to
82
+ // `selfWindow.location.origin`. `acceptOrigin` follows it: the panel is the only peer.
60
83
  return (0, _transport.createWindowTransport)({
61
84
  targetWindow: window.parent,
62
85
  self: window,
63
- targetOrigin: '*'
86
+ targetOrigin: sidePanelOrigin
64
87
  });
65
- }, [active, transport]);
88
+ }, [active, transport, allowedOrigins]);
66
89
 
67
90
  // Inbound (READ): product context -> iframe bus.
68
91
  (0, _react.useEffect)(function () {
@@ -122,7 +145,7 @@ var ExtensionContextBridgeClient = exports.ExtensionContextBridgeClient = functi
122
145
  }
123
146
  try {
124
147
  activeTransport.send((0, _defineProperty2.default)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, _constants.BRIDGE_MESSAGE_MARKER, true), "direction", _constants.BRIDGE_TO_PRODUCT), "payload", serialized));
125
- } catch (_unused) {
148
+ } catch (_unused2) {
126
149
  // Never let bridging break the chat iframe.
127
150
  }
128
151
  });
@@ -41,7 +41,8 @@ var stampProductContentId = function stampProductContentId(serialized, targetCon
41
41
  */
42
42
  var ExtensionContextBridgeHost = exports.ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref) {
43
43
  var transport = _ref.transport,
44
- getContentId = _ref.getContentId;
44
+ getContentId = _ref.getContentId,
45
+ isAiEnabled = _ref.isAiEnabled;
45
46
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
46
47
  // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
47
48
  var active = (0, _fg.fg)('rovo-ext_context_bridge');
@@ -63,6 +64,12 @@ var ExtensionContextBridgeHost = exports.ExtensionContextBridgeHost = function E
63
64
  if (!activeTransport) {
64
65
  return;
65
66
  }
67
+ // Checked at relay time rather than when the transport is built: the product renders its
68
+ // `RovoEntitlementSetter` as a sibling of `ChatOpenerSubscriber`, so the store is still
69
+ // unpopulated on this component's first render. By the time a payload arrives it has settled.
70
+ if (isAiEnabled === false) {
71
+ return;
72
+ }
66
73
  // Loop guard.
67
74
  if (payload.interactionSource === _constants.BRIDGE_SOURCE) {
68
75
  return;
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.shouldRelayToProduct = exports.getInboundDecision = void 0;
6
+ exports.shouldRelayToProduct = exports.isRelayableContextKey = exports.getInboundDecision = void 0;
7
7
  /**
8
8
  * Bridge relay policy, asymmetric by direction:
9
9
  * - Product → extension (context): relayed iff allowlisted and not denylisted. The denylist is a
@@ -18,6 +18,26 @@ exports.shouldRelayToProduct = exports.getInboundDecision = void 0;
18
18
  /** Inbound allowlist — payload types the bridge supports. Widen only as a feature needs it. */
19
19
  var INBOUND_ALLOWLIST = new Set(['editor-context-payload', 'set-message-context']);
20
20
 
21
+ /**
22
+ * Inbound `set-message-context` allowlist, keyed on `contextKey`.
23
+ *
24
+ * The type-level allowlist is not enough for this one payload. Its `data.value` is arbitrary
25
+ * product-supplied context, so — unlike `editor-context-payload`, whose content a co-resident script
26
+ * could equally read from the DOM — it can carry data the extension has no other route to. An
27
+ * allowlist rather than a denylist because a denylist cannot cover keys publishers add later, which
28
+ * is the drift this guards against (SECASR-8597).
29
+ */
30
+ var INBOUND_CONTEXT_KEYS = new Set([
31
+ // The key every Jira view-context publisher uses. Not reachable yet — only Confluence opts into
32
+ // the bridge, and its own publishers are merge-style, which `./registry` drops. Seeded so the
33
+ // Jira read-context work does not have to rediscover this list.
34
+ 'jira_view_context']);
35
+
36
+ /** True if a `set-message-context` payload carrying this `contextKey` may cross to the extension. */
37
+ var isRelayableContextKey = exports.isRelayableContextKey = function isRelayableContextKey(contextKey) {
38
+ return INBOUND_CONTEXT_KEYS.has(contextKey);
39
+ };
40
+
21
41
  /** Inbound denylist — control-plane/auth events that must never reach the extension chat. */
22
42
  var INBOUND_DENYLIST = new Set(['message-send', 'chat-new', 'chat-open', 'chat-close', 'open-smart-creation-modal', 'open-browse-agent-modal', 'open-browse-agent-sidebar', 'open-chat-debug-modal', 'open-chat-feedback-modal', 'forge-auth-success', 'forge-auth-failure', 'chat-smartlink-3p-post-auth-launch']);
23
43
 
@@ -8,6 +8,7 @@ exports.serializePayload = exports.deserializePayload = void 0;
8
8
  var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
9
9
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
10
10
  var _constants = require("./constants");
11
+ var _policy = require("./policy");
11
12
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
12
13
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
13
14
  var base = function base(payload) {
@@ -51,6 +52,10 @@ var SHIMS = {
51
52
  if (!data || typeof data.setContext !== 'function') {
52
53
  return null;
53
54
  }
55
+ // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
56
+ if (!(0, _policy.isRelayableContextKey)(data.contextKey)) {
57
+ return null;
58
+ }
54
59
  var value;
55
60
  try {
56
61
  value = data.setContext((0, _defineProperty2.default)({}, MERGE_PROBE_KEY, true));
@@ -29,16 +29,24 @@ var createWindowTransport = exports.createWindowTransport = function createWindo
29
29
  _ref$self = _ref.self,
30
30
  selfWindow = _ref$self === void 0 ? window : _ref$self,
31
31
  _ref$targetOrigin = _ref.targetOrigin,
32
- targetOrigin = _ref$targetOrigin === void 0 ? selfWindow.location.origin : _ref$targetOrigin;
32
+ targetOrigin = _ref$targetOrigin === void 0 ? selfWindow.location.origin : _ref$targetOrigin,
33
+ _ref$acceptOrigin = _ref.acceptOrigin,
34
+ acceptOrigin = _ref$acceptOrigin === void 0 ? targetOrigin : _ref$acceptOrigin;
33
35
  return {
34
36
  send: function send(message) {
35
37
  targetWindow.postMessage(message, targetOrigin);
36
38
  },
37
- // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
38
39
  subscribe: function subscribe(handler) {
40
+ if (acceptOrigin === '*') {
41
+ return function () {};
42
+ }
43
+ // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
39
44
  return (0, _bindEventListener.bind)(selfWindow, {
40
45
  type: 'message',
41
46
  listener: function listener(event) {
47
+ if (event.origin !== acceptOrigin) {
48
+ return;
49
+ }
42
50
  if (isBridgeMessage(event.data)) {
43
51
  handler(event.data);
44
52
  }
@@ -8,6 +8,24 @@ import { createWindowTransport } from './transport';
8
8
 
9
9
  /** Selection shape preserved across editor focus loss. */
10
10
 
11
+ /**
12
+ * The side panel's own origin, supplied by `buildIframeUrl()` in the extension's `sidepanel.ts`.
13
+ * Serves as both the `postMessage` target and the only origin this client accepts messages from.
14
+ *
15
+ * The parameter only selects which of `allowedOrigins` is the peer; it can never introduce one. So
16
+ * an absent parameter, an unknown extension, and an absent list all resolve to `undefined`, leaving
17
+ * the Client without a transport. `useSBSEvaluationBridge` reads the same parameter but falls back to
18
+ * `'*'` — tolerable for telemetry, not for a channel that drives editor writes.
19
+ */
20
+ const getSidePanelOrigin = allowedOrigins => {
21
+ try {
22
+ const value = new URLSearchParams(window.location.search).get('extensionOrigin');
23
+ return value && allowedOrigins !== null && allowedOrigins !== void 0 && allowedOrigins.includes(value) ? value : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ };
28
+
11
29
  /**
12
30
  * Whether focus currently sits inside the chat. Confluence clears `selection` on
13
31
  * `editor-context-payload` both when the user clicks into chat and when they genuinely deselect;
@@ -31,7 +49,8 @@ const isChatFocused = () => {
31
49
  * Headless.
32
50
  */
33
51
  export const ExtensionContextBridgeClient = ({
34
- transport
52
+ transport,
53
+ allowedOrigins
35
54
  }) => {
36
55
  // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
37
56
  const active = fg('rovo-ext_context_bridge');
@@ -47,14 +66,18 @@ export const ExtensionContextBridgeClient = ({
47
66
  if (typeof window === 'undefined') {
48
67
  return undefined;
49
68
  }
50
- // `targetOrigin: '*'`: the receiving window (the side panel) is a different origin, so the
51
- // default (`selfWindow.location.origin`) would not match.
69
+ const sidePanelOrigin = getSidePanelOrigin(allowedOrigins);
70
+ if (!sidePanelOrigin) {
71
+ return undefined;
72
+ }
73
+ // The side panel is a different origin to this iframe, so `targetOrigin` cannot fall back to
74
+ // `selfWindow.location.origin`. `acceptOrigin` follows it: the panel is the only peer.
52
75
  return createWindowTransport({
53
76
  targetWindow: window.parent,
54
77
  self: window,
55
- targetOrigin: '*'
78
+ targetOrigin: sidePanelOrigin
56
79
  });
57
- }, [active, transport]);
80
+ }, [active, transport, allowedOrigins]);
58
81
 
59
82
  // Inbound (READ): product context -> iframe bus.
60
83
  useEffect(() => {
@@ -33,7 +33,8 @@ const stampProductContentId = (serialized, targetContentId) => {
33
33
  */
34
34
  export const ExtensionContextBridgeHost = ({
35
35
  transport,
36
- getContentId
36
+ getContentId,
37
+ isAiEnabled
37
38
  }) => {
38
39
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
39
40
  // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
@@ -56,6 +57,12 @@ export const ExtensionContextBridgeHost = ({
56
57
  if (!activeTransport) {
57
58
  return;
58
59
  }
60
+ // Checked at relay time rather than when the transport is built: the product renders its
61
+ // `RovoEntitlementSetter` as a sibling of `ChatOpenerSubscriber`, so the store is still
62
+ // unpopulated on this component's first render. By the time a payload arrives it has settled.
63
+ if (isAiEnabled === false) {
64
+ return;
65
+ }
59
66
  // Loop guard.
60
67
  if (payload.interactionSource === BRIDGE_SOURCE) {
61
68
  return;
@@ -12,6 +12,24 @@
12
12
  /** Inbound allowlist — payload types the bridge supports. Widen only as a feature needs it. */
13
13
  const INBOUND_ALLOWLIST = new Set(['editor-context-payload', 'set-message-context']);
14
14
 
15
+ /**
16
+ * Inbound `set-message-context` allowlist, keyed on `contextKey`.
17
+ *
18
+ * The type-level allowlist is not enough for this one payload. Its `data.value` is arbitrary
19
+ * product-supplied context, so — unlike `editor-context-payload`, whose content a co-resident script
20
+ * could equally read from the DOM — it can carry data the extension has no other route to. An
21
+ * allowlist rather than a denylist because a denylist cannot cover keys publishers add later, which
22
+ * is the drift this guards against (SECASR-8597).
23
+ */
24
+ const INBOUND_CONTEXT_KEYS = new Set([
25
+ // The key every Jira view-context publisher uses. Not reachable yet — only Confluence opts into
26
+ // the bridge, and its own publishers are merge-style, which `./registry` drops. Seeded so the
27
+ // Jira read-context work does not have to rediscover this list.
28
+ 'jira_view_context']);
29
+
30
+ /** True if a `set-message-context` payload carrying this `contextKey` may cross to the extension. */
31
+ export const isRelayableContextKey = contextKey => INBOUND_CONTEXT_KEYS.has(contextKey);
32
+
15
33
  /** Inbound denylist — control-plane/auth events that must never reach the extension chat. */
16
34
  const INBOUND_DENYLIST = new Set(['message-send', 'chat-new', 'chat-open', 'chat-close', 'open-smart-creation-modal', 'open-browse-agent-modal', 'open-browse-agent-sidebar', 'open-chat-debug-modal', 'open-chat-feedback-modal', 'forge-auth-success', 'forge-auth-failure', 'chat-smartlink-3p-post-auth-launch']);
17
35
 
@@ -1,4 +1,5 @@
1
1
  import { BRIDGE_SOURCE } from './constants';
2
+ import { isRelayableContextKey } from './policy';
2
3
  const base = payload => ({
3
4
  type: payload.type,
4
5
  source: payload.source,
@@ -37,6 +38,10 @@ const SHIMS = {
37
38
  if (!data || typeof data.setContext !== 'function') {
38
39
  return null;
39
40
  }
41
+ // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
42
+ if (!isRelayableContextKey(data.contextKey)) {
43
+ return null;
44
+ }
40
45
  let value;
41
46
  try {
42
47
  value = data.setContext({
@@ -18,18 +18,27 @@ export const isBridgeMessage = value => typeof value === 'object' && value !== n
18
18
  export const createWindowTransport = ({
19
19
  targetWindow,
20
20
  self: selfWindow = window,
21
- targetOrigin = selfWindow.location.origin
21
+ targetOrigin = selfWindow.location.origin,
22
+ acceptOrigin = targetOrigin
22
23
  }) => ({
23
24
  send: message => {
24
25
  targetWindow.postMessage(message, targetOrigin);
25
26
  },
26
- // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
27
- subscribe: handler => bind(selfWindow, {
28
- type: 'message',
29
- listener: event => {
30
- if (isBridgeMessage(event.data)) {
31
- handler(event.data);
32
- }
27
+ subscribe: handler => {
28
+ if (acceptOrigin === '*') {
29
+ return () => {};
33
30
  }
34
- })
31
+ // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
32
+ return bind(selfWindow, {
33
+ type: 'message',
34
+ listener: event => {
35
+ if (event.origin !== acceptOrigin) {
36
+ return;
37
+ }
38
+ if (isBridgeMessage(event.data)) {
39
+ handler(event.data);
40
+ }
41
+ }
42
+ });
43
+ }
35
44
  });
@@ -11,6 +11,24 @@ import { createWindowTransport } from './transport';
11
11
 
12
12
  /** Selection shape preserved across editor focus loss. */
13
13
 
14
+ /**
15
+ * The side panel's own origin, supplied by `buildIframeUrl()` in the extension's `sidepanel.ts`.
16
+ * Serves as both the `postMessage` target and the only origin this client accepts messages from.
17
+ *
18
+ * The parameter only selects which of `allowedOrigins` is the peer; it can never introduce one. So
19
+ * an absent parameter, an unknown extension, and an absent list all resolve to `undefined`, leaving
20
+ * the Client without a transport. `useSBSEvaluationBridge` reads the same parameter but falls back to
21
+ * `'*'` — tolerable for telemetry, not for a channel that drives editor writes.
22
+ */
23
+ var getSidePanelOrigin = function getSidePanelOrigin(allowedOrigins) {
24
+ try {
25
+ var value = new URLSearchParams(window.location.search).get('extensionOrigin');
26
+ return value && allowedOrigins !== null && allowedOrigins !== void 0 && allowedOrigins.includes(value) ? value : undefined;
27
+ } catch (_unused) {
28
+ return undefined;
29
+ }
30
+ };
31
+
14
32
  /**
15
33
  * Whether focus currently sits inside the chat. Confluence clears `selection` on
16
34
  * `editor-context-payload` both when the user clicks into chat and when they genuinely deselect;
@@ -34,7 +52,8 @@ var isChatFocused = function isChatFocused() {
34
52
  * Headless.
35
53
  */
36
54
  export var ExtensionContextBridgeClient = function ExtensionContextBridgeClient(_ref) {
37
- var transport = _ref.transport;
55
+ var transport = _ref.transport,
56
+ allowedOrigins = _ref.allowedOrigins;
38
57
  // Self-gated. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
39
58
  var active = fg('rovo-ext_context_bridge');
40
59
  var publish = usePublish('ai-mate');
@@ -49,14 +68,18 @@ export var ExtensionContextBridgeClient = function ExtensionContextBridgeClient(
49
68
  if (typeof window === 'undefined') {
50
69
  return undefined;
51
70
  }
52
- // `targetOrigin: '*'`: the receiving window (the side panel) is a different origin, so the
53
- // default (`selfWindow.location.origin`) would not match.
71
+ var sidePanelOrigin = getSidePanelOrigin(allowedOrigins);
72
+ if (!sidePanelOrigin) {
73
+ return undefined;
74
+ }
75
+ // The side panel is a different origin to this iframe, so `targetOrigin` cannot fall back to
76
+ // `selfWindow.location.origin`. `acceptOrigin` follows it: the panel is the only peer.
54
77
  return createWindowTransport({
55
78
  targetWindow: window.parent,
56
79
  self: window,
57
- targetOrigin: '*'
80
+ targetOrigin: sidePanelOrigin
58
81
  });
59
- }, [active, transport]);
82
+ }, [active, transport, allowedOrigins]);
60
83
 
61
84
  // Inbound (READ): product context -> iframe bus.
62
85
  useEffect(function () {
@@ -116,7 +139,7 @@ export var ExtensionContextBridgeClient = function ExtensionContextBridgeClient(
116
139
  }
117
140
  try {
118
141
  activeTransport.send(_defineProperty(_defineProperty(_defineProperty({}, BRIDGE_MESSAGE_MARKER, true), "direction", BRIDGE_TO_PRODUCT), "payload", serialized));
119
- } catch (_unused) {
142
+ } catch (_unused2) {
120
143
  // Never let bridging break the chat iframe.
121
144
  }
122
145
  });
@@ -34,7 +34,8 @@ var stampProductContentId = function stampProductContentId(serialized, targetCon
34
34
  */
35
35
  export var ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref) {
36
36
  var transport = _ref.transport,
37
- getContentId = _ref.getContentId;
37
+ getContentId = _ref.getContentId,
38
+ isAiEnabled = _ref.isAiEnabled;
38
39
  // Self-gated (not taken from the consumer) so this cannot mount by mistake wherever
39
40
  // `ChatOpenerSubscriber` renders. Matches `BRIDGE_FEATURE_GATE` in `./constants`.
40
41
  var active = fg('rovo-ext_context_bridge');
@@ -56,6 +57,12 @@ export var ExtensionContextBridgeHost = function ExtensionContextBridgeHost(_ref
56
57
  if (!activeTransport) {
57
58
  return;
58
59
  }
60
+ // Checked at relay time rather than when the transport is built: the product renders its
61
+ // `RovoEntitlementSetter` as a sibling of `ChatOpenerSubscriber`, so the store is still
62
+ // unpopulated on this component's first render. By the time a payload arrives it has settled.
63
+ if (isAiEnabled === false) {
64
+ return;
65
+ }
59
66
  // Loop guard.
60
67
  if (payload.interactionSource === BRIDGE_SOURCE) {
61
68
  return;
@@ -12,6 +12,26 @@
12
12
  /** Inbound allowlist — payload types the bridge supports. Widen only as a feature needs it. */
13
13
  var INBOUND_ALLOWLIST = new Set(['editor-context-payload', 'set-message-context']);
14
14
 
15
+ /**
16
+ * Inbound `set-message-context` allowlist, keyed on `contextKey`.
17
+ *
18
+ * The type-level allowlist is not enough for this one payload. Its `data.value` is arbitrary
19
+ * product-supplied context, so — unlike `editor-context-payload`, whose content a co-resident script
20
+ * could equally read from the DOM — it can carry data the extension has no other route to. An
21
+ * allowlist rather than a denylist because a denylist cannot cover keys publishers add later, which
22
+ * is the drift this guards against (SECASR-8597).
23
+ */
24
+ var INBOUND_CONTEXT_KEYS = new Set([
25
+ // The key every Jira view-context publisher uses. Not reachable yet — only Confluence opts into
26
+ // the bridge, and its own publishers are merge-style, which `./registry` drops. Seeded so the
27
+ // Jira read-context work does not have to rediscover this list.
28
+ 'jira_view_context']);
29
+
30
+ /** True if a `set-message-context` payload carrying this `contextKey` may cross to the extension. */
31
+ export var isRelayableContextKey = function isRelayableContextKey(contextKey) {
32
+ return INBOUND_CONTEXT_KEYS.has(contextKey);
33
+ };
34
+
15
35
  /** Inbound denylist — control-plane/auth events that must never reach the extension chat. */
16
36
  var INBOUND_DENYLIST = new Set(['message-send', 'chat-new', 'chat-open', 'chat-close', 'open-smart-creation-modal', 'open-browse-agent-modal', 'open-browse-agent-sidebar', 'open-chat-debug-modal', 'open-chat-feedback-modal', 'forge-auth-success', 'forge-auth-failure', 'chat-smartlink-3p-post-auth-launch']);
17
37
 
@@ -3,6 +3,7 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
3
3
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
4
4
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
5
5
  import { BRIDGE_SOURCE } from './constants';
6
+ import { isRelayableContextKey } from './policy';
6
7
  var base = function base(payload) {
7
8
  return {
8
9
  type: payload.type,
@@ -44,6 +45,10 @@ var SHIMS = {
44
45
  if (!data || typeof data.setContext !== 'function') {
45
46
  return null;
46
47
  }
48
+ // Checked before `setContext` runs, so a non-allowlisted key never invokes product code.
49
+ if (!isRelayableContextKey(data.contextKey)) {
50
+ return null;
51
+ }
47
52
  var value;
48
53
  try {
49
54
  value = data.setContext(_defineProperty({}, MERGE_PROBE_KEY, true));
@@ -23,16 +23,24 @@ export var createWindowTransport = function createWindowTransport(_ref) {
23
23
  _ref$self = _ref.self,
24
24
  selfWindow = _ref$self === void 0 ? window : _ref$self,
25
25
  _ref$targetOrigin = _ref.targetOrigin,
26
- targetOrigin = _ref$targetOrigin === void 0 ? selfWindow.location.origin : _ref$targetOrigin;
26
+ targetOrigin = _ref$targetOrigin === void 0 ? selfWindow.location.origin : _ref$targetOrigin,
27
+ _ref$acceptOrigin = _ref.acceptOrigin,
28
+ acceptOrigin = _ref$acceptOrigin === void 0 ? targetOrigin : _ref$acceptOrigin;
27
29
  return {
28
30
  send: function send(message) {
29
31
  targetWindow.postMessage(message, targetOrigin);
30
32
  },
31
- // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
32
33
  subscribe: function subscribe(handler) {
34
+ if (acceptOrigin === '*') {
35
+ return function () {};
36
+ }
37
+ // `bind` returns its own unbind function, which is exactly the `subscribe` contract.
33
38
  return bind(selfWindow, {
34
39
  type: 'message',
35
40
  listener: function listener(event) {
41
+ if (event.origin !== acceptOrigin) {
42
+ return;
43
+ }
36
44
  if (isBridgeMessage(event.data)) {
37
45
  handler(event.data);
38
46
  }
@@ -2,6 +2,16 @@ import { type Transport } from './transport';
2
2
  type Props = {
3
3
  /** Override the transport (tests). Defaults to iframe<->parent `postMessage`. */
4
4
  transport?: Transport;
5
+ /**
6
+ * Full origins the chat will talk to, e.g. `chrome-extension://<id>` — not bare ids. Supplied by
7
+ * the consumer, which knows its own published ids, so a new extension channel needs no platform
8
+ * release. Must be a stable reference; the transport is memoised on it.
9
+ *
10
+ * Omitted, the Client is inert. `frame-ancestors` on this app already limits who can frame it, but
11
+ * any extension holding `declarativeNetRequest` can strip that header, so this list is what
12
+ * actually pins the channel to our own extension rather than any installed one.
13
+ */
14
+ allowedOrigins?: readonly string[];
5
15
  };
6
16
  /**
7
17
  * Chat-iframe half of the bridge.
@@ -15,5 +25,5 @@ type Props = {
15
25
  *
16
26
  * Headless.
17
27
  */
18
- export declare const ExtensionContextBridgeClient: ({ transport }: Props) => null;
28
+ export declare const ExtensionContextBridgeClient: ({ transport, allowedOrigins }: Props) => null;
19
29
  export {};
@@ -4,6 +4,16 @@ type Props = {
4
4
  transport?: Transport;
5
5
  /** Resolves the current content id at relay time (a getter, so the Host reads it lazily). */
6
6
  getContentId?: () => string | undefined;
7
+ /**
8
+ * Whether the viewer is entitled to Rovo. `false` relays nothing, so a tenant with AI turned off
9
+ * never has page content cross into the extension (SECASR-8597).
10
+ *
11
+ * Supplied by the consumer for the same reason as `getContentId`: the signal lives in
12
+ * `conversation-assistant-entitlement` and this package stays clear of that dependency.
13
+ * `undefined` means the consumer expressed no opinion, and relaying proceeds — every in-tree
14
+ * mount passes it explicitly, so that only covers bespoke embeddings.
15
+ */
16
+ isAiEnabled?: boolean;
7
17
  };
8
18
  /**
9
19
  * Product-window half of the bridge. Observes the `ai-mate` bus and mirrors allowlisted context out
@@ -12,5 +22,5 @@ type Props = {
12
22
  * The reverse direction is handled by the sibling `RovoPostMessagePubsubListener` in
13
23
  * `ChatOpenerSubscriber`, not here. Headless; mounts at product root via `ChatOpenerSubscriber`.
14
24
  */
15
- export declare const ExtensionContextBridgeHost: ({ transport, getContentId }: Props) => null;
25
+ export declare const ExtensionContextBridgeHost: ({ transport, getContentId, isAiEnabled, }: Props) => null;
16
26
  export {};
@@ -1,4 +1,6 @@
1
1
  import { type Payload } from '../../../types';
2
+ /** True if a `set-message-context` payload carrying this `contextKey` may cross to the extension. */
3
+ export declare const isRelayableContextKey: (contextKey: string) => boolean;
2
4
  /** Why a product-bus payload was or was not relayed. */
3
5
  type InboundDecision = 'allow' | 'not-allowlisted' | 'denylisted';
4
6
  /** Classify a product-bus payload. Relay iff `allow`. */
@@ -30,9 +30,19 @@ export type Transport = {
30
30
  };
31
31
  export declare const isBridgeMessage: (value: unknown) => value is BridgeMessage;
32
32
  /** `postMessage`-based transport. Callers override `targetWindow`/`self` for the iframe side. */
33
- export declare const createWindowTransport: ({ targetWindow, self: selfWindow, targetOrigin, }: {
33
+ export declare const createWindowTransport: ({ targetWindow, self: selfWindow, targetOrigin, acceptOrigin, }: {
34
34
  targetWindow: Window;
35
35
  self?: Window;
36
36
  targetOrigin?: string;
37
+ /**
38
+ * The only origin whose messages `subscribe` dispatches. Defaults to `targetOrigin`, which is
39
+ * right for both halves of the bridge: each has exactly one peer, and it is the same peer in
40
+ * both directions.
41
+ *
42
+ * `'*'` is refused. A wildcard here would let anything able to reach this window inject context
43
+ * that drives editor writes. It disables `subscribe` rather than throwing, because the bridge
44
+ * must never break the page it runs on.
45
+ */
46
+ acceptOrigin?: string;
37
47
  }) => Transport;
38
48
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/rovo-triggers",
3
- "version": "10.22.0",
3
+ "version": "10.23.0",
4
4
  "description": "Provides various trigger events to drive Rovo Chat functionality, such as a publish-subscribe and URL parameter hooks",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "publishConfig": {
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "atlaskit:src": "src/index.ts",
30
30
  "dependencies": {
31
- "@atlaskit/adf-schema": "^57.1.0",
31
+ "@atlaskit/adf-schema": "^57.2.0",
32
32
  "@atlaskit/platform-feature-flags": "^2.1.0",
33
33
  "@atlaskit/react-compiler-gating": "^0.2.0",
34
34
  "@babel/runtime": "^7.0.0",
@@ -40,11 +40,11 @@
40
40
  "devDependencies": {
41
41
  "@af/integration-testing": "workspace:^",
42
42
  "@af/visual-regression": "workspace:^",
43
- "@atlaskit/css": "^1.0.0",
43
+ "@atlaskit/css": "^1.1.0",
44
44
  "@atlaskit/primitives": "^22.4.0",
45
45
  "@atlaskit/ssr": "workspace:^",
46
46
  "@atlassian/a11y-jest-testing": "^0.17.0",
47
- "@atlassian/conversation-assistant-service-api": "^17.0.0",
47
+ "@atlassian/conversation-assistant-service-api": "^17.1.0",
48
48
  "@atlassian/feature-flags-test-utils": "^1.2.0",
49
49
  "@atlassian/testing-library": "^0.11.0",
50
50
  "@testing-library/react": "^16.3.0",