@webex/contact-center 3.12.0-next.157 → 3.12.0-next.159

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 (41) hide show
  1. package/ai-docs/contact-center-spec.md +3 -0
  2. package/dist/cc.js +10 -2
  3. package/dist/cc.js.map +1 -1
  4. package/dist/services/ApiAiAssistant.js +18 -0
  5. package/dist/services/ApiAiAssistant.js.map +1 -1
  6. package/dist/services/WebCallingService.js +10 -2
  7. package/dist/services/WebCallingService.js.map +1 -1
  8. package/dist/services/constants.js +11 -1
  9. package/dist/services/constants.js.map +1 -1
  10. package/dist/services/core/Utils.js +21 -1
  11. package/dist/services/core/Utils.js.map +1 -1
  12. package/dist/services/core/aqm-reqs.js +7 -15
  13. package/dist/services/core/aqm-reqs.js.map +1 -1
  14. package/dist/services/core/constants.js +9 -1
  15. package/dist/services/core/constants.js.map +1 -1
  16. package/dist/services/core/websocket/WebSocketManager.js +17 -1
  17. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  18. package/dist/types/services/ApiAiAssistant.d.ts +9 -0
  19. package/dist/types/services/constants.d.ts +9 -0
  20. package/dist/types/services/core/Utils.d.ts +8 -0
  21. package/dist/types/services/core/constants.d.ts +7 -0
  22. package/dist/webex.js +1 -1
  23. package/package.json +2 -2
  24. package/src/cc.ts +15 -2
  25. package/src/services/ApiAiAssistant.ts +24 -0
  26. package/src/services/WebCallingService.ts +16 -2
  27. package/src/services/ai-docs/services-spec.md +8 -1
  28. package/src/services/constants.ts +10 -0
  29. package/src/services/core/Utils.ts +24 -0
  30. package/src/services/core/ai-docs/core-spec.md +23 -13
  31. package/src/services/core/aqm-reqs.ts +6 -15
  32. package/src/services/core/constants.ts +8 -0
  33. package/src/services/core/websocket/WebSocketManager.ts +26 -2
  34. package/test/unit/spec/cc.ts +21 -0
  35. package/test/unit/spec/services/ApiAiAssistant.ts +13 -0
  36. package/test/unit/spec/services/WebCallingService.ts +38 -0
  37. package/test/unit/spec/services/core/Utils.ts +22 -0
  38. package/test/unit/spec/services/core/aqm-reqs.ts +29 -0
  39. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +64 -14
  40. package/umd/contact-center.min.js +2 -2
  41. package/umd/contact-center.min.js.map +1 -1
@@ -67,6 +67,18 @@ export class ApiAIAssistant {
67
67
  this.aiFeature = aiFeature;
68
68
  }
69
69
 
70
+ /**
71
+ * Validates a caller-supplied identifier (agentId/interactionId) before it is used to
72
+ * construct a request. Accepts a non-empty alphanumeric identifier that may contain
73
+ * hyphens or underscores.
74
+ * @param value - the identifier to validate
75
+ * @returns {boolean} true when the identifier is a non-empty, well-formed string
76
+ * @private
77
+ */
78
+ private isValidTranscriptIdentifier(value: unknown): boolean {
79
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
80
+ }
81
+
70
82
  private async sendWellnessBreakAction(
71
83
  action: WellnessBreakUserAction,
72
84
  method: string
@@ -625,6 +637,18 @@ export class ApiAIAssistant {
625
637
  throw detailedError;
626
638
  }
627
639
 
640
+ if (
641
+ !this.isValidTranscriptIdentifier(agentId) ||
642
+ !this.isValidTranscriptIdentifier(interactionId)
643
+ ) {
644
+ const {error: detailedError} = getErrorDetails(
645
+ new Error('INVALID_AGENT_ID_OR_INTERACTION_ID'),
646
+ METHODS.FETCH_HISTORIC_TRANSCRIPTS,
647
+ CC_FILE
648
+ );
649
+ throw detailedError;
650
+ }
651
+
628
652
  try {
629
653
  const baseUrl = this.getBaseUrl();
630
654
  const orgId = this.webex.credentials.getOrgId();
@@ -15,11 +15,13 @@ import {TIMEOUT_DURATION, WEB_CALLING_SERVICE_FILE} from '../constants';
15
15
  import LoggerProxy from '../logger-proxy';
16
16
  import {
17
17
  DEFAULT_RTMS_DOMAIN,
18
+ ALLOWED_RTMS_DOMAIN,
18
19
  POST_AUTH,
19
20
  WCC_CALLING_RTMS_DOMAIN,
20
21
  DEREGISTER_WEBCALLING_LINE_MSG,
21
22
  METHODS,
22
23
  } from './constants';
24
+ import {isAllowedUrlHost} from './core/Utils';
23
25
 
24
26
  /**
25
27
  * WebCallingService provides WebRTC calling functionality for Contact Center agents.
@@ -141,9 +143,21 @@ export default class WebCallingService extends EventEmitter {
141
143
  const rtmsURL = this.webex.internal.services.get(WCC_CALLING_RTMS_DOMAIN);
142
144
 
143
145
  try {
144
- const url = new URL(rtmsURL);
146
+ const hostname = new URL(rtmsURL).hostname.toLowerCase();
147
+
148
+ if (!isAllowedUrlHost(rtmsURL, [ALLOWED_RTMS_DOMAIN])) {
149
+ LoggerProxy.error(
150
+ `Non-allow-listed RTMS host from u2c catalogue: ${hostname} so falling back to default domain`,
151
+ {
152
+ module: WEB_CALLING_SERVICE_FILE,
153
+ method: METHODS.GET_RTMS_DOMAIN,
154
+ }
155
+ );
156
+
157
+ return DEFAULT_RTMS_DOMAIN;
158
+ }
145
159
 
146
- return url.hostname;
160
+ return hostname;
147
161
  } catch (error) {
148
162
  LoggerProxy.error(
149
163
  `Invalid URL from u2c catalogue: ${rtmsURL} so falling back to default domain`,
@@ -70,7 +70,7 @@ Each service folder contains its own `ai-docs/` with detailed documentation. **A
70
70
 
71
71
  **Paginated data services** (AddressBook, Queue, EntryPoint) do not have dedicated ai-docs. Read their source files directly — they follow shared REST/pagination/caching patterns documented in [`ai-docs/patterns/typescript-patterns.md`](../../../ai-docs/patterns/typescript-patterns.md). `UserPreference` is also a direct REST service, but it has CRUD semantics and does not use their PageCache pattern.
72
72
 
73
- **WebCallingService** also has no dedicated ai-docs, but it follows a different pattern: EventEmitter-based call lifecycle orchestration around `@webex/calling` (`createClient`, line registration/deregistration, `ICall` events), `callTaskMap` tracking, and async registration flows with timeout handling. Read [`WebCallingService.ts`](../WebCallingService.ts) directly when changing browser calling behavior.
73
+ **WebCallingService** also has no dedicated ai-docs, but it follows a different pattern: EventEmitter-based call lifecycle orchestration around `@webex/calling` (`createClient`, line registration/deregistration, `ICall` events), `callTaskMap` tracking, and async registration flows with timeout handling. Before registering the calling line it resolves the RTMS domain from the u2c service catalog through `getRTMSDomain()`, which trusts the catalog host only when it is on the `ALLOWED_RTMS_DOMAIN` allow-list and otherwise falls back to `DEFAULT_RTMS_DOMAIN`. Read [`WebCallingService.ts`](../WebCallingService.ts) directly when changing browser calling behavior.
74
74
 
75
75
  The `ContactCenter` plugin class (`cc.ts`) is the **only public entry point**. It delegates all backend work to the services layer:
76
76
 
@@ -212,6 +212,8 @@ Use [`constants.ts`](../constants.ts) as the canonical source for service-level
212
212
 
213
213
  - `DEFAULT_RTMS_DOMAIN`, `WCC_CALLING_RTMS_DOMAIN` — RTMS/WebRTC domain constants
214
214
 
215
+ - `ALLOWED_RTMS_DOMAIN` — trusted RTMS domain suffix (`rtmsprod.net`); a catalog-provided RTMS host is used only when its hostname equals or is a subdomain of this value, otherwise `DEFAULT_RTMS_DOMAIN` is used
216
+
215
217
  - `METHODS` — method name constants used by `WebCallingService`
216
218
 
217
219
  ## Key Files (source of truth)
@@ -267,6 +269,8 @@ ContactCenter READY callback
267
269
  | SERVICES-R-006 | Treat Services composition as unconditionally created by the ContactCenter READY callback; Services owns no rollout or feature-flag decision. | Capability flags belong to the consuming config/task/calling collaborators, so the composition root must not silently gate construction. | `src/services/index.ts`, `src/cc.ts` | `test/unit/spec/cc.ts` | None; rollout applicability is explicitly N/A for Services. | PRESENT |
268
270
  | SERVICES-R-007 | Existing Queue and EntryPoint list methods must apply inbound, active, telephony, profile/agent-view, and `name,ASC` defaults while retaining their established parameter and full-record response types. Caller-supplied existing filter, sort, or profile inputs override defaults. Queue must also treat `sortOrder` without `sortBy` as a name sort and bypass the simple-page cache. | Defaults on the established methods let thin consumers request lists without a parallel API, while existing parameters preserve specialized behavior for other consumers and full-record types remain truthful because no field projection is requested. | `src/services/Queue.ts`, `src/services/EntryPoint.ts`, `src/types.ts` | `test/unit/spec/services/Queue.ts`, `test/unit/spec/services/EntryPoint.ts`, `test/unit/spec/cc.ts` | The backend honors the view flags and combined CMS sort value. | PRESENT |
269
271
  | SERVICES-R-008 | ApiAIAssistant wellness requests must accept no public identity or context-mutating input, read effective enablement and non-empty SDK-owned organization/agent/session context from a read-only provider supplied at construction, then POST the exact `WellnessBreakAction` custom-event body directly to `/event`; only HTTP 202 resolves successfully. Public action unions derive from exported `as const` action-value objects. | SDK-owned live context prevents callers from supplying stale/different login identities to action calls, one value/type source prevents wire drift, and direct HTTP matches the AI event endpoint rather than AQM completion semantics. | `src/services/ApiAiAssistant.ts`, `src/types.ts`, `src/index.ts`, `src/cc.ts` | `test/unit/spec/services/ApiAiAssistant.ts`, `test/unit/spec/cc.ts` | None. | PRESENT |
272
+ | SERVICES-R-009 | `WebCallingService.getRTMSDomain()` returns a catalog-provided RTMS hostname only when it equals `ALLOWED_RTMS_DOMAIN` (`rtmsprod.net`) or is a subdomain of it; a parseable but non-allow-listed host, or a catalog value that is not a valid URL, falls back to `DEFAULT_RTMS_DOMAIN`. The rejected host is logged as a diagnostic before fallback. | The u2c service catalog is an untrusted input at the calling trust boundary; allow-listing the RTMS host prevents WebRTC registration against an attacker-controlled domain. | `src/services/WebCallingService.ts`, `src/services/constants.ts` | `test/unit/spec/services/WebCallingService.ts` | The `@webex/calling` client accepts the resolved domain string. | PRESENT |
273
+ | SERVICES-R-010 | `ApiAIAssistant.fetchHistoricTranscripts(agentId: string, interactionId: string): Promise<HistoricTranscriptsResponse>` validates both caller-supplied identifiers before building the authenticated request, accepting only non-empty strings matching the identifier format `^[A-Za-z0-9_-]{1,128}$`. Invalid inputs reject with a typed error and no request is sent. | `agentId`/`interactionId` are caller-controlled inputs on an authenticated request boundary; validating them before use rejects malformed identifiers instead of forwarding them to the backend. | `src/services/ApiAiAssistant.ts` | `test/unit/spec/services/ApiAiAssistant.ts` | Real-time transcription feature flag must already be enabled; format validation runs after that gate. | PRESENT |
270
274
 
271
275
  ## Design Overview
272
276
  `Services` is a singleton composition root for transport-facing capabilities only. It constructs two `WebSocketManager` instances (primary Contact Center and RTD), creates `AqmReqs` on the primary manager, then creates config, agent, contact, dialer, and ConnectionService collaborators.
@@ -415,6 +419,7 @@ classDiagram
415
419
  - ApiAIAssistant, TaskManager, and UserPreference are READY-time ContactCenter collaborators, not fields constructed by Services.
416
420
  - Authentication is inherited from the host SDK through Core/WebexRequest; Services owns no credential lifecycle.
417
421
  - Rollout applicability is N/A for the Services composition root: it is created at READY and does not evaluate a feature flag.
422
+ - Untrusted external inputs are validated at the service trust boundary: catalog-provided RTMS hosts must pass the `ALLOWED_RTMS_DOMAIN` allow-list before use, and caller-supplied `agentId`/`interactionId` must match the accepted identifier format before an authenticated transcript request is built.
418
423
  - ApiAIAssistant evaluates the current effective wellness flag and identities through its constructor-supplied read-only provider; the public object exposes no context mutator, and the Services singleton itself remains unconditionally composed.
419
424
 
420
425
  ## Concurrency & Reactive Flow
@@ -489,6 +494,8 @@ Unit tests mirror module paths under `test/unit/spec/services`. Preserve positiv
489
494
  | `SERVICES-R-006` | `test/unit/spec/cc.ts` | None. |
490
495
  | `SERVICES-R-007` | `test/unit/spec/services/Queue.ts`, `test/unit/spec/services/EntryPoint.ts`, `test/unit/spec/cc.ts` | None. |
491
496
  | `SERVICES-R-008` | `test/unit/spec/services/ApiAiAssistant.ts` | None. |
497
+ | `SERVICES-R-009` | `test/unit/spec/services/WebCallingService.ts` | Cover allowed host, subdomain, non-allow-listed host, and unparseable catalog value fallback. |
498
+ | `SERVICES-R-010` | `test/unit/spec/services/ApiAiAssistant.ts` | Cover valid identifiers plus empty, over-length, and disallowed-character rejections. |
492
499
 
493
500
  ## Traceability
494
501
  - Repo architecture: `../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../ai-docs/SPEC_INDEX.md`
@@ -32,6 +32,16 @@ export const WCC_CALLING_RTMS_DOMAIN = 'wcc-calling-rtms-domain';
32
32
  */
33
33
  export const DEFAULT_RTMS_DOMAIN = 'rtw.prod-us1.rtmsprod.net';
34
34
 
35
+ /**
36
+ * Trusted domain suffix for RTMS hosts returned by the u2c service catalog. A catalog-provided
37
+ * RTMS host is only used when its hostname is, or is a subdomain of, this domain; otherwise
38
+ * `DEFAULT_RTMS_DOMAIN` is used.
39
+ * @type {string}
40
+ * @public
41
+ * @ignore
42
+ */
43
+ export const ALLOWED_RTMS_DOMAIN = 'rtmsprod.net';
44
+
35
45
  /**
36
46
  * Timeout in milliseconds for WebSocket events.
37
47
  * @type {number}
@@ -103,6 +103,30 @@ export const isValidDialNumber = (
103
103
  });
104
104
  };
105
105
 
106
+ /**
107
+ * Checks whether a URL's hostname is, or is a subdomain of, one of the allowed domains.
108
+ *
109
+ * @param url - The candidate URL to validate
110
+ * @param allowedDomains - Domains (or domain suffixes) the URL's hostname must match
111
+ * @returns true when the URL parses and its hostname matches an allowed domain, false otherwise
112
+ */
113
+ export const isAllowedUrlHost = (
114
+ url: string | undefined | null,
115
+ allowedDomains: string[]
116
+ ): boolean => {
117
+ if (!url) {
118
+ return false;
119
+ }
120
+
121
+ try {
122
+ const hostname = new URL(url).hostname.toLowerCase();
123
+
124
+ return allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
125
+ } catch (error) {
126
+ return false;
127
+ }
128
+ };
129
+
106
130
  export const getStationLoginErrorData = (failure: Failure, loginOption: LoginOption) => {
107
131
  let duplicateLocationMessage = 'This value is already in use';
108
132
 
@@ -37,13 +37,13 @@ The Core service provides the foundational infrastructure layer that all other s
37
37
 
38
38
  | Component | File | Description |
39
39
  |---|---|---|
40
- | `WebSocketManager` | [`WebSocketManager.ts`](../websocket/WebSocketManager.ts) | Manages the WebSocket connection lifecycle including initialization, message dispatch, and graceful shutdown. Emits `message` events for incoming data and `socketClose` when the connection drops while reconnect is allowed. |
40
+ | `WebSocketManager` | [`WebSocketManager.ts`](../websocket/WebSocketManager.ts) | Manages the WebSocket connection lifecycle including initialization, message dispatch, and graceful shutdown. Validates the subscribe/register `webSocketUrl` against the RoutingNotifs allow-list before opening a socket. Emits `message` events for incoming data and `socketClose` when the connection drops while reconnect is allowed. |
41
41
  | `ConnectionService` | [`connection-service.ts`](../websocket/connection-service.ts) | Orchestrates reconnection logic and keepalive heartbeats on top of `WebSocketManager`. Detects connection loss/recovery and emits `connectionLost` details; ContactCenter owns any `silentRelogin()` policy. |
42
42
  | `WebexRequest` | [`WebexRequest.ts`](../WebexRequest.ts) | Singleton HTTP client that forwards service/resource/method/body options to the authenticated host request API and provides `uploadLogs` diagnostics. |
43
43
  | `AqmReqs` | [`aqm-reqs.ts`](../aqm-reqs.ts) | Factory for creating request methods that send HTTP requests and wait for correlated WebSocket notifications (success or failure). Used by routing and task services to implement their API methods. |
44
44
  | `Utils` | [`Utils.ts`](../Utils.ts) | Shared utility functions including `getErrorDetails()` for standardized error handling, `generateTaskErrorObject()` for task-specific errors, and `createErrDetailsObject()` for constructing error detail objects. |
45
45
  | `Err` | [`Err.ts`](../Err.ts) | Error class definitions. `Err.Details` carries structured error metadata (status, type, trackingId) for consistent error propagation. |
46
- | `constants` | [`constants.ts`](../constants.ts) | Timeout values, interval durations, participant types, interaction states, and method name constants used throughout the core layer. Any new constants for core should be defined here. |
46
+ | `constants` | [`constants.ts`](../constants.ts) | Timeout values, interval durations, participant types, interaction states, method name constants, and the `ALLOWED_ROUTING_NOTIFS_DOMAINS` WebSocket host allow-list used throughout the core layer. Any new constants for core should be defined here. |
47
47
 
48
48
  ## Purpose / Responsibility
49
49
  Own authenticated HTTP, realtime WebSocket lifecycle, AQM request correlation, reconnect/keepalive behavior, and shared error normalization.
@@ -259,13 +259,14 @@ connectionService.on('connectionLost', (details: ConnectionLostDetails) => {
259
259
  | CORE-R-004 | ConnectionService must emit transport-state details and retry `initWebSocket({body, resource})`; ContactCenter owns relogin policy. | Separating transport detection from agent recovery prevents Core from mutating package-level session state. | `src/services/core/websocket/connection-service.ts` | `test/unit/spec/services/core/websocket/connection-service.ts` | None; source and test evidence rechecked during the 2026-07-09 remediation; independent document revalidation pending. | PRESENT |
260
260
  | CORE-R-005 | The keepalive worker must use the configured 4-second interval and 16-second close-socket timeout; AQM defaults to 20 seconds unless disabled/overridden. | Accurate timing is required for predictable recovery and request failure behavior. | `src/services/core/constants.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None; source and test evidence rechecked during the 2026-07-09 remediation; independent document revalidation pending. | PRESENT |
261
261
  | CORE-R-006 | Treat Core timeout, keepalive, and recovery constants as fixed behavior controls, not rollout flags; Core owns no feature-gate evaluation. | Conflating operational constants with rollout policy could disable transport or correlation paths unexpectedly. | `src/services/core/constants.ts`, `src/services/index.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None; rollout applicability is explicitly N/A for Core. | PRESENT |
262
- | CORE-R-007 | When an AQM request sets `redactSensitiveLogs`, routing-failure and timeout logs must omit the request URL, bind key, response, and raw routing payload while preserving settlement and cleanup behavior. | Dynamic request paths and routing messages can contain participant identifiers or phone numbers that must not enter diagnostic logs. | `src/services/core/aqm-reqs.ts`, `src/services/core/types.ts` | `test/unit/spec/services/core/aqm-reqs.ts` | The flag is internal and opt-in; ordinary AQM logging remains backward compatible. | PRESENT |
262
+ | CORE-R-007 | AQM routing-failure logging never emits the raw routing message payload or error verbatim; the failure-bind path logs only a fixed redacted diagnostic regardless of `redactSensitiveLogs`. When an AQM request additionally sets `redactSensitiveLogs`, timeout logs must also omit the request URL, bind key, and response while preserving settlement and cleanup behavior. | Dynamic request paths and routing messages can contain participant identifiers or phone numbers that must not enter diagnostic logs. | `src/services/core/aqm-reqs.ts`, `src/services/core/types.ts` | `test/unit/spec/services/core/aqm-reqs.ts` | The timeout flag is internal and opt-in; ordinary AQM logging remains backward compatible while routing-failure logging is unconditionally redacted. | PRESENT |
263
+ | CORE-R-008 | `WebSocketManager` opens a socket only for a subscribe/register `webSocketUrl` that parses as a `wss` URL whose host equals, or is a subdomain of, an entry in `ALLOWED_ROUTING_NOTIFS_DOMAINS`; a malformed, non-`wss`, or non-allow-listed URL is rejected with an error and no socket is opened. | A server-controlled subscribe response could otherwise direct the realtime connection to an untrusted or non-secure endpoint. | `src/services/core/websocket/WebSocketManager.ts`, `src/services/core/constants.ts` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | The allow-list is a fixed domain set; subdomain matching is exact suffix matching on the parsed hostname. | PRESENT |
263
264
 
264
265
  ## Design Overview
265
266
  Core separates four responsibilities:
266
267
 
267
268
  1. `WebexRequest` wraps the authenticated host request API and service-catalog routing.
268
- 2. `WebSocketManager` registers a subscription using both `body` and `resource`, connects the socket, owns the keepalive worker, and emits raw messages/socket lifecycle events.
269
+ 2. `WebSocketManager` registers a subscription using both `body` and `resource`, validates the returned `webSocketUrl` against the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list before connecting the socket, owns the keepalive worker, and emits raw messages/socket lifecycle events.
269
270
  3. `AqmReqs` registers bind matchers on the primary WebSocket, sends HTTP through WebexRequest, and settles requests only from matching notifications, HTTP failure, or timeout.
270
271
  4. `ConnectionService` observes message/socket liveness, emits connection-state details, and retries socket initialization. ContactCenter listens to those details and owns optional silent relogin.
271
272
 
@@ -294,7 +295,7 @@ const setState = aqmReqs.req((p: {data: Agent.StateChange}) => ({
294
295
  await setState({data: stateChangePayload});
295
296
  ```
296
297
 
297
- `CLOSE_SOCKET_TIMEOUT` is 16000 ms. `CONNECTIVITY_CHECK_INTERVAL` drives reconnect attempts separately. `TIMEOUT_REQ` is the 20000 ms default AQM timeout; `WEBSOCKET_EVENT_TIMEOUT` is not the active AqmReqs default. A request configuration may set internal `redactSensitiveLogs: true`; this changes only routing-failure and timeout diagnostics, not correlation, errors, timing, or cleanup.
298
+ `CLOSE_SOCKET_TIMEOUT` is 16000 ms. `CONNECTIVITY_CHECK_INTERVAL` drives reconnect attempts separately. `TIMEOUT_REQ` is the 20000 ms default AQM timeout; `WEBSOCKET_EVENT_TIMEOUT` is not the active AqmReqs default. Routing-failure diagnostics are always redacted to a fixed message that omits the raw payload and error. A request configuration may additionally set internal `redactSensitiveLogs: true` to redact timeout diagnostics (request URL, bind key, and response); neither redaction changes correlation, errors, timing, or cleanup.
298
299
 
299
300
  ## Data Flow
300
301
  ```mermaid
@@ -365,12 +366,17 @@ sequenceDiagram
365
366
  Caller->>WSM: initWebSocket({body, resource})
366
367
  WSM->>Host: register(body, resource)
367
368
  Host-->>WSM: WebSocket URL/subscription
368
- WSM->>WS: connect()
369
- alt welcome
370
- WS-->>WSM: Welcome event
371
- WSM-->>Caller: WelcomeResponse
372
- else register/connect failure
373
- WSM-->>Caller: throw error
369
+ WSM->>WSM: validate webSocketUrl against wss + allow-list
370
+ alt allow-listed wss URL
371
+ WSM->>WS: connect()
372
+ alt welcome
373
+ WS-->>WSM: Welcome event
374
+ WSM-->>Caller: WelcomeResponse
375
+ else connect failure
376
+ WSM-->>Caller: throw error
377
+ end
378
+ else malformed / non-wss / non-allow-listed URL
379
+ WSM-->>Caller: throw error (no socket opened)
374
380
  end
375
381
  ```
376
382
 
@@ -448,6 +454,8 @@ WebSocketManager owns socket/welcome/worker state. AqmReqs owns pending success/
448
454
 
449
455
  ## Business Rules & Invariants
450
456
  - Core must preserve its typed public/event contracts and must not invent backend states or responses. Enforced in `src/services/core/WebexRequest.ts`.
457
+ - A subscribe/register `webSocketUrl` is trusted only when it is a `wss` URL on the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list; otherwise `WebSocketManager` throws and never opens a socket. Enforced in `src/services/core/websocket/WebSocketManager.ts`.
458
+ - AQM routing-failure logs never contain the raw routing payload or error verbatim. Enforced in `src/services/core/aqm-reqs.ts`.
451
459
  - Rollout applicability is N/A for Core: keepalive, timeout, and reconnect constants control behavior, while Services constructs Core without a feature gate.
452
460
 
453
461
  ## Concurrency & Reactive Flow
@@ -468,7 +476,7 @@ stateDiagram-v2
468
476
  ```
469
477
 
470
478
  ## Protocol / Wire Format
471
- `WebSocketManager.initWebSocket` accepts `{body: SubscribeRequest, resource: string}`. Subscription uses the host Webex request API; AqmReqs operational HTTP uses the WebexRequest wrapper. AQM request configs carry `host`, `url`, optional `method`/`data`, `notifSuccess.bind`, optional `notifFail.bind`, optional cancel bind, and optional timeout. HTTP acknowledgement never substitutes for the matching WebSocket operation result.
479
+ `WebSocketManager.initWebSocket` accepts `{body: SubscribeRequest, resource: string}`. Subscription uses the host Webex request API; the returned `webSocketUrl` must be a `wss` URL on the `ALLOWED_ROUTING_NOTIFS_DOMAINS` allow-list or the socket is not opened. AqmReqs operational HTTP uses the WebexRequest wrapper. AQM request configs carry `host`, `url`, optional `method`/`data`, `notifSuccess.bind`, optional `notifFail.bind`, optional cancel bind, and optional timeout. HTTP acknowledgement never substitutes for the matching WebSocket operation result.
472
480
 
473
481
  ## Error Handling & Failure Modes
474
482
  | Condition | Signal (error/code/result) | Caller recovery |
@@ -712,7 +720,8 @@ export const generateTaskErrorObject = (
712
720
  ## Pitfalls
713
721
  - `initWebSocket` requires both `body` and `resource`; omitting `resource` registers against no durable subscription endpoint.
714
722
  - AqmReqs installs notification binds before HTTP and never resolves from acknowledgement; duplicate binds, timeout, and cleanup ordering are correctness-critical.
715
- - Sensitive AQM configurations must use the opt-in redaction flag so dynamic URLs and raw routing messages are not included in timeout/failure logs.
723
+ - AQM routing-failure logs are always redacted; sensitive AQM configurations must also use the opt-in `redactSensitiveLogs` flag so dynamic URLs and responses are not included in timeout logs.
724
+ - Do not open the WebSocket directly from a subscribe/register response; the `webSocketUrl` must first pass `wss` scheme and allow-listed host validation.
716
725
  - Keepalive closure (16 seconds), lost-connection detection (8 seconds), reconnect interval (5 seconds), and recovery timeout (50 seconds) are separate controls and must not be conflated.
717
726
 
718
727
  ## Module Do's / Don'ts
@@ -751,6 +760,7 @@ Unit tests mirror module paths under `test/unit/spec/services/core`. Preserve po
751
760
  | `CORE-R-005` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | Keep timer-value assertions synchronized with constants. |
752
761
  | `CORE-R-006` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | Feature-gate absence is verified from construction/source rather than a dedicated negative test. |
753
762
  | `CORE-R-007` | `test/unit/spec/services/core/aqm-reqs.ts` | None. |
763
+ | `CORE-R-008` | `test/unit/spec/services/core/websocket/WebSocketManager.ts` | None. |
754
764
 
755
765
  ## Traceability
756
766
  - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md`
@@ -119,21 +119,12 @@ export default class AqmReqs {
119
119
  if ('errId' in notifFail) {
120
120
  const error = new Err.Details(notifFail.errId, msg as any);
121
121
 
122
- if (c.redactSensitiveLogs) {
123
- LoggerProxy.log('Routing request failed (sensitive details redacted)', {
124
- module: AQM_REQS_FILE,
125
- method: METHODS.CREATE_PROMISE,
126
- });
127
- } else {
128
- LoggerProxy.log(`Routing request failed: ${JSON.stringify(msg)}`, {
129
- module: AQM_REQS_FILE,
130
- method: METHODS.CREATE_PROMISE,
131
- });
132
- LoggerProxy.log(`Routing request failed: ${error}`, {
133
- module: AQM_REQS_FILE,
134
- method: METHODS.CREATE_PROMISE,
135
- });
136
- }
122
+ // The raw routing-failure payload/error is never logged verbatim; only a
123
+ // redacted, non-sensitive diagnostic is emitted regardless of redactSensitiveLogs.
124
+ LoggerProxy.log('Routing request failed (sensitive details redacted)', {
125
+ module: AQM_REQS_FILE,
126
+ method: METHODS.CREATE_PROMISE,
127
+ });
137
128
  reject(error);
138
129
  } else {
139
130
  reject(notifFail.err(msg as any));
@@ -80,6 +80,14 @@ export const PARTICIPANT_TYPES = {
80
80
  /** Interaction state for consultation */
81
81
  export const STATE_CONSULT = 'consult';
82
82
 
83
+ /**
84
+ * Trusted domains for the RoutingNotifs WebSocket connection. A `webSocketUrl` returned by the
85
+ * subscribe/register API is only opened when its hostname matches one of these domains exactly
86
+ * or is a subdomain of one of them.
87
+ * @ignore
88
+ */
89
+ export const ALLOWED_ROUTING_NOTIFS_DOMAINS = ['cisco.com', 'ciscoccservice.com'];
90
+
83
91
  // Method names for core services
84
92
  export const METHODS = {
85
93
  // WebexRequest methods
@@ -5,8 +5,14 @@ import {ConnectionLostDetails} from './types';
5
5
  import {CC_EVENTS, SubscribeResponse, WelcomeResponse} from '../../config/types';
6
6
  import LoggerProxy from '../../../logger-proxy';
7
7
  import workerScript from './keepalive.worker';
8
- import {KEEPALIVE_WORKER_INTERVAL, CLOSE_SOCKET_TIMEOUT, METHODS} from '../constants';
8
+ import {
9
+ KEEPALIVE_WORKER_INTERVAL,
10
+ CLOSE_SOCKET_TIMEOUT,
11
+ METHODS,
12
+ ALLOWED_ROUTING_NOTIFS_DOMAINS,
13
+ } from '../constants';
9
14
  import {WEB_SOCKET_MANAGER_FILE} from '../../../constants';
15
+ import {isAllowedUrlHost} from '../Utils';
10
16
 
11
17
  /**
12
18
  * WebSocketManager handles the WebSocket connection for Contact Center operations.
@@ -107,7 +113,25 @@ export class WebSocketManager extends EventEmitter {
107
113
  body: connectionConfig,
108
114
  headers: isIntEnv && orgId ? {'X-ORGANIZATION-ID': orgId} : undefined,
109
115
  });
110
- this.url = subscribeResponse.body.webSocketUrl;
116
+ const {webSocketUrl} = subscribeResponse.body;
117
+
118
+ // Only a `wss` URL whose hostname is, or is a subdomain of, one of the allow-listed
119
+ // RoutingNotifs domains is considered trusted.
120
+ let isWssUrl = false;
121
+
122
+ try {
123
+ isWssUrl = !!webSocketUrl && new URL(webSocketUrl).protocol === 'wss:';
124
+ } catch (error) {
125
+ isWssUrl = false;
126
+ }
127
+
128
+ if (!isWssUrl || !isAllowedUrlHost(webSocketUrl, ALLOWED_ROUTING_NOTIFS_DOMAINS)) {
129
+ throw new Error(
130
+ 'Rejected webSocketUrl from RoutingNotifs registration: scheme or host not allow-listed'
131
+ );
132
+ }
133
+
134
+ this.url = webSocketUrl;
111
135
  } catch (e) {
112
136
  LoggerProxy.error(
113
137
  `Register API Failed, Request to RoutingNotifs websocket registration API failed ${e}`,
@@ -4868,6 +4868,27 @@ describe('webex.cc', () => {
4868
4868
  });
4869
4869
  });
4870
4870
 
4871
+ it('does not emit an unknown server-controlled event type', () => {
4872
+ const payload = {
4873
+ type: 'UnhandledMessage',
4874
+ data: {foo: 'bar', type: 'MaliciousInjectedType'},
4875
+ };
4876
+
4877
+ messageCallback(JSON.stringify(payload));
4878
+
4879
+ expect(emitSpy).not.toHaveBeenCalledWith('MaliciousInjectedType', expect.anything());
4880
+ });
4881
+
4882
+ it('emits a known agent event type verbatim from data.type', () => {
4883
+ const sample = {foo: 'bar', type: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS};
4884
+
4885
+ messageCallback(
4886
+ JSON.stringify({type: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, data: sample})
4887
+ );
4888
+
4889
+ expect(emitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, sample);
4890
+ });
4891
+
4871
4892
  it('should call webCallingService.setLoginOption with correct deviceType on AGENT_STATION_LOGIN_SUCCESS', () => {
4872
4893
  const setLoginOptionSpy = jest.spyOn(webex.cc.webCallingService, 'setLoginOption');
4873
4894
  const deviceType = LoginOption.EXTENSION;
@@ -219,6 +219,19 @@ describe('ApiAIAssistant', () => {
219
219
  expect(result).toEqual(responseBody as any);
220
220
  });
221
221
 
222
+ it('rejects an invalid agentId/interactionId and does not call webex.request', async () => {
223
+ apiAIAssistant.setAIFeatureFlags({realtimeTranscripts: {enable: true}} as any);
224
+
225
+ await expect(
226
+ apiAIAssistant.fetchHistoricTranscripts('', 'interaction-1')
227
+ ).rejects.toBeDefined();
228
+ await expect(
229
+ apiAIAssistant.fetchHistoricTranscripts('test-agent-id', 'bad id!')
230
+ ).rejects.toBeDefined();
231
+
232
+ expect(mockWebex.request).not.toHaveBeenCalled();
233
+ });
234
+
222
235
  it('should request real-time assistance without extra context using sendEvent', async () => {
223
236
  const sendEventSpy = jest.spyOn(apiAIAssistant, 'sendEvent').mockResolvedValue({ok: true});
224
237
  apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: true}} as any);
@@ -198,6 +198,44 @@ describe('WebCallingService', () => {
198
198
 
199
199
  });
200
200
 
201
+ it('should fall back to default domain for a non-allow-listed catalog RTMS host', async () => {
202
+ webex.internal.services.get.mockReturnValue('wss://evil.attacker.com');
203
+
204
+ line = callingClient.getLines().line1 as ILine;
205
+ const deviceInfo = {
206
+ mobiusDeviceId: 'device123',
207
+ status: 'registered',
208
+ setError: jest.fn(),
209
+ getError: jest.fn(),
210
+ type: 'line',
211
+ id: 'line1',
212
+ };
213
+
214
+ const registeredHandler = jest.fn();
215
+ const lineOnSpy = jest.spyOn(line, 'on').mockImplementation((event, handler) => {
216
+ if (event === LINE_EVENTS.REGISTERED) {
217
+ registeredHandler.mockImplementation(handler);
218
+ handler(deviceInfo);
219
+ }
220
+ });
221
+ await expect(webRTCCalling.registerWebCallingLine()).resolves.toBeUndefined();
222
+ expect(createClient).toHaveBeenCalledWith(webex, {
223
+ logger: {
224
+ level: 'info',
225
+ },
226
+ serviceData: {
227
+ indicator: 'contactcenter',
228
+ domain: 'rtw.prod-us1.rtmsprod.net',
229
+ },
230
+ });
231
+ expect(lineOnSpy).toHaveBeenCalledWith(LINE_EVENTS.REGISTERED, expect.any(Function));
232
+ expect(line.register).toHaveBeenCalled();
233
+ expect(LoggerProxy.error).toHaveBeenCalledWith(
234
+ 'Non-allow-listed RTMS host from u2c catalogue: evil.attacker.com so falling back to default domain',
235
+ {module: WEB_CALLING_SERVICE_FILE, method: 'getRTMSDomain'}
236
+ );
237
+ });
238
+
201
239
  it('should reject if registration times out', async () => {
202
240
  line = callingClient.getLines().line1 as ILine;
203
241
  jest.spyOn(global, 'setTimeout').mockImplementation(((handler: TimerHandler) => {
@@ -1036,4 +1036,26 @@ describe('Utils', () => {
1036
1036
  });
1037
1037
  });
1038
1038
 
1039
+ describe('isAllowedUrlHost', () => {
1040
+ it('returns true when the hostname exactly matches an allowed domain', () => {
1041
+ expect(Utils.isAllowedUrlHost('wss://cisco.com', ['cisco.com'])).toBe(true);
1042
+ });
1043
+
1044
+ it('returns true when the hostname is a subdomain of an allowed domain', () => {
1045
+ expect(Utils.isAllowedUrlHost('wss://notifs.wxcc-us1.cisco.com', ['cisco.com'])).toBe(true);
1046
+ });
1047
+
1048
+ it('returns false when the hostname is not in the allowed domains list', () => {
1049
+ expect(Utils.isAllowedUrlHost('wss://evil.attacker.com', ['cisco.com'])).toBe(false);
1050
+ });
1051
+
1052
+ it('returns false for an unparsable url', () => {
1053
+ expect(Utils.isAllowedUrlHost('not-a-url', ['cisco.com'])).toBe(false);
1054
+ });
1055
+
1056
+ it('returns false for a null/undefined url', () => {
1057
+ expect(Utils.isAllowedUrlHost(null as any, ['cisco.com'])).toBe(false);
1058
+ expect(Utils.isAllowedUrlHost(undefined as any, ['cisco.com'])).toBe(false);
1059
+ });
1060
+ });
1039
1061
  });
@@ -610,6 +610,35 @@ describe('AqmReqs', () => {
610
610
  expect(aqm['pendingRequests']).toEqual({});
611
611
  });
612
612
 
613
+ it('never logs the raw routing-failure payload even when redactSensitiveLogs is not set', async () => {
614
+ const participantId = '+1/plain-failure-secret';
615
+ webexRequestInstance.request.mockResolvedValueOnce(mockWebexRequestResolvedValue);
616
+ const conf = {
617
+ ...participantDropConfig(participantId, 'disabled'),
618
+ redactSensitiveLogs: false,
619
+ };
620
+ const promise = aqm['createPromise'](conf);
621
+
622
+ webSocketManagerInstance.emit(
623
+ 'message',
624
+ JSON.stringify({
625
+ type: 'RoutingMessage',
626
+ data: {type: 'ParticipantDropConferenceFailed', participantId},
627
+ })
628
+ );
629
+
630
+ await expect(promise).rejects.toBeDefined();
631
+ expect(LoggerProxy.log).toHaveBeenCalledWith(
632
+ 'Routing request failed (sensitive details redacted)',
633
+ expect.objectContaining({module: AQM_REQS_FILE})
634
+ );
635
+
636
+ const loggedCalls = JSON.stringify((LoggerProxy.log as jest.Mock).mock.calls);
637
+ expect(loggedCalls).not.toContain(participantId);
638
+ expect(loggedCalls).not.toContain('ParticipantDropConferenceFailed');
639
+ expect(aqm['pendingRequests']).toEqual({});
640
+ });
641
+
613
642
  it('resolves a correlated participant-left event and clears its binds', async () => {
614
643
  jest.useFakeTimers();
615
644
  (global.window as any).setTimeout = global.setTimeout;