diff --git a/packages/node_modules/@webex/private-react-component-token-input/src/index.js b/packages/node_modules/@webex/private-react-component-token-input/src/index.js index 472c4adb4..52d1dd531 100644 --- a/packages/node_modules/@webex/private-react-component-token-input/src/index.js +++ b/packages/node_modules/@webex/private-react-component-token-input/src/index.js @@ -126,7 +126,7 @@ function TokenInput(props) { placeholder={tokenType === 'JWT' ? 'JWT' : 'Access Token'} value={userAccessToken} /> -

You can get an access token from developer.webex.com

+

You can get an access token from developer.webex.com

} diff --git a/packages/node_modules/@webex/private-react-component-token-input/src/index.test.js b/packages/node_modules/@webex/private-react-component-token-input/src/index.test.js new file mode 100644 index 000000000..44dbfc8c1 --- /dev/null +++ b/packages/node_modules/@webex/private-react-component-token-input/src/index.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +import TokenInput from './index'; + +describe('developer portal link', () => { + it('developer link uses https and safe attributes', () => { + const testRenderer = TestRenderer.create(); + const anchors = testRenderer.root.findAllByType('a'); + const developerLink = anchors.find((anchor) => anchor.props.href.includes('developer.webex.com')); + + expect(developerLink.props.href).toBe('https://developer.webex.com'); + expect(developerLink.props.href).not.toMatch(/^http:\/\//); + expect(developerLink.props.rel).toContain('noopener'); + expect(developerLink.props.rel).toContain('noreferrer'); + }); +}); diff --git a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.js b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.js index 35a7eabd8..38e6ee7d9 100644 --- a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.js +++ b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.js @@ -29,6 +29,38 @@ const { const spaceWidgetElementId = 'my-webex-space-widget'; const recentsWidgetElementId = 'my-webex-recents-widget'; +// Comfortably captures a debugging window without unbounded memory growth. +const MAX_CAPTURED_EVENTS = 50; +const REDACTED_EVENT_DETAIL_KEYS = ['id', 'personId', 'personEmail', 'displayName', 'email', 'text', 'message', 'content']; + +function redactEventDetail(detail) { + if (!detail || typeof detail !== 'object') { + return detail; + } + + const redacted = {...detail}; + + REDACTED_EVENT_DETAIL_KEYS.forEach((key) => { + delete redacted[key]; + }); + + return redacted; +} + +// Development-only capture; no-op in production so no global buffer exists there. +function captureDemoEvent(eventName, detail) { + if (process.env.NODE_ENV === 'production') { + return; + } + + window.ciscoSparkEvents = window.ciscoSparkEvents || []; + window.ciscoSparkEvents.push({eventName, detail: redactEventDetail(detail)}); + + if (window.ciscoSparkEvents.length > MAX_CAPTURED_EVENTS) { + window.ciscoSparkEvents.splice(0, window.ciscoSparkEvents.length - MAX_CAPTURED_EVENTS); + } +} + class DemoWidget extends Component { constructor(props) { super(props); @@ -53,8 +85,8 @@ class DemoWidget extends Component { message: isMeetOnly ? false : activities.message, people: activities.people }, - accessToken: cookies.get('accessToken') || '', - accessTokenType: cookies.get('accessTokenType') || '', + accessToken: '', + accessTokenType: '', composerActions, destinationId, disableFlags: false, @@ -98,8 +130,6 @@ class DemoWidget extends Component { e.preventDefault(); const {cookies} = this.props; - cookies.set('accessToken', this.state.accessToken); - cookies.set('accessTokenType', this.state.accessTokenType); cookies.set('activities', this.state.activities); cookies.set('destinationId', this.state.destinationId); cookies.set('destinationMode', this.state.mode); @@ -124,8 +154,6 @@ class DemoWidget extends Component { e.preventDefault(); const {cookies} = this.props; - cookies.set('accessToken', this.state.accessToken); - cookies.set('accessTokenType', this.state.accessTokenType); cookies.set('fedramp', this.state.fedramp); cookies.set('enableSpaceListFilter', this.state.enableSpaceListFilter); cookies.set('recentsBasicMode', this.state.recentsBasicMode); @@ -143,7 +171,7 @@ class DemoWidget extends Component { fedramp: this.state.fedramp, spaceLoadCount: Number(this.state.spaceLoadCount), onEvent: (eventName, detail) => { - window.ciscoSparkEvents.push({eventName, detail}); + captureDemoEvent(eventName, detail); if (eventName === 'rooms:selected') { const spaceId = detail.id; @@ -353,7 +381,7 @@ class DemoWidget extends Component { secondaryActivitiesFullWidth: this.state.secondaryActivitiesFullWidth, spaceActivities: this.state.activities, onEvent: (eventName, detail) => { - window.ciscoSparkEvents.push({eventName, detail}); + captureDemoEvent(eventName, detail); if (eventName === spaceEvents.ACTIVITY_CHANGED) { this.setState({setCurrentActivity: ''}); } diff --git a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.test.js b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.test.js new file mode 100644 index 000000000..e2e7b4a18 --- /dev/null +++ b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/index.test.js @@ -0,0 +1,130 @@ +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +import WrappedDemoWidget from './index'; + +jest.mock('@webex/widget-space', () => () => null); +jest.mock('@webex/widget-recents', () => () => null); +jest.mock('@webex/private-react-component-token-input', () => () => null, {virtual: true}); +jest.mock('@webex/private-react-component-example-code', () => () => null, {virtual: true}); +jest.mock('@webex/private-react-component-space-destination', () => ({ + __esModule: true, + default: () => null, + constants: { + DESTINATION_PROP_MODE_LEGACY: 'DESTINATION_PROP_MODE_LEGACY', + DESTINATION_PROP_MODE_MAIN: 'DESTINATION_PROP_MODE_MAIN', + MODE_ONE_ON_ONE: 'email', + MODE_ONE_ON_ONE_ID: 'userId', + MODE_SPACE: 'spaceId', + MODE_SIP: 'sip', + MODE_PSTN: 'pstn' + } +}), {virtual: true}); + +const DemoWidget = WrappedDemoWidget.WrapperComponent; + +function createCookiesStub() { + const store = {}; + + return { + get: jest.fn((key) => store[key]), + set: jest.fn((key, value) => { + store[key] = value; + }), + store + }; +} + +function preventDefaultEvent() { + return {preventDefault: jest.fn()}; +} + +describe('ciscoSparkEvents capture', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + delete window.ciscoSparkEvents; + }); + + it('no global buffer capture in production', async () => { + process.env.NODE_ENV = 'production'; + + const cookies = createCookiesStub(); + const testRenderer = TestRenderer.create(); + const instance = testRenderer.getInstance(); + + await instance.handleOpenRecentsWidget(preventDefaultEvent()); + instance.state.recentsWidgetProps.onEvent('rooms:selected', {id: 'space-1'}); + + expect(window.ciscoSparkEvents).toBeUndefined(); + }); + + it('development capture is bounded and redacted', async () => { + process.env.NODE_ENV = 'test'; + + const cookies = createCookiesStub(); + const testRenderer = TestRenderer.create(); + const instance = testRenderer.getInstance(); + + await instance.handleOpenRecentsWidget(preventDefaultEvent()); + const {onEvent} = instance.state.recentsWidgetProps; + + for (let i = 0; i < 60; i += 1) { + onEvent('messages:created', { + id: `message-${i}`, + personEmail: 'guest@example.com', + text: 'sensitive message body' + }); + } + + expect(window.ciscoSparkEvents.length).toBeLessThanOrEqual(50); + window.ciscoSparkEvents.forEach((entry) => { + expect(entry.detail).not.toHaveProperty('id'); + expect(entry.detail).not.toHaveProperty('personEmail'); + expect(entry.detail).not.toHaveProperty('text'); + }); + }); +}); + +describe('token cookie storage', () => { + it('no raw token cookie or hardened options', async () => { + const cookies = createCookiesStub(); + + cookies.store.accessToken = 'raw-access-token-value'; + cookies.store.accessTokenType = 'Bearer'; + + const testRenderer = TestRenderer.create(); + const instance = testRenderer.getInstance(); + + expect(instance.state.accessToken).toBe(''); + expect(instance.state.accessTokenType).toBe(''); + + instance.handleOpenSpaceWidget(preventDefaultEvent()); + await instance.handleOpenRecentsWidget(preventDefaultEvent()); + + expect(cookies.set).not.toHaveBeenCalledWith('accessToken', expect.anything()); + expect(cookies.set).not.toHaveBeenCalledWith('accessTokenType', expect.anything()); + }); + + it('non-token preference cookies preserved', async () => { + const cookies = createCookiesStub(); + const testRenderer = TestRenderer.create(); + const instance = testRenderer.getInstance(); + + instance.handleOpenSpaceWidget(preventDefaultEvent()); + await instance.handleOpenRecentsWidget(preventDefaultEvent()); + + expect(cookies.set).toHaveBeenCalledWith('destinationMode', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('activities', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('destinationId', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('fedramp', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('initialActivity', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('enableSpaceListFilter', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('recentsBasicMode', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('recentsEnableAddButton', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('recentsEnableUserProfile', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('recentsEnableUserProfileMenu', expect.anything()); + expect(cookies.set).toHaveBeenCalledWith('spaceLoadCount', expect.anything()); + }); +}); diff --git a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.js b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.js index 1ffa81dc1..bd6b3ef14 100644 --- a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.js +++ b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.js @@ -113,6 +113,10 @@ export function createSDKInstance(accessToken, options = {}) { return Promise.resolve(webexSDKInstance); } +// Comfortably exceeds real guest-issuer JWT lengths; bounds client-side input +// before it reaches the Identity Broker, without parsing or trusting claims. +const MAX_GUEST_JWT_LENGTH = 8192; + /** * Creates a webex instance with the jwt token generated * by a guest issuer. @@ -123,6 +127,12 @@ export function createSDKInstance(accessToken, options = {}) { * @returns {Promise} */ export function createSDKGuestInstance(jwt, options = {}) { + if (typeof jwt !== 'string' || jwt.length === 0 || jwt.length > MAX_GUEST_JWT_LENGTH) { + return Promise.reject( + new Error(`createSDKGuestInstance: jwt must be a non-empty string no longer than ${MAX_GUEST_JWT_LENGTH} characters`) + ); + } + const webexSDKInstance = new Webex({ config: defaultConfig(options) }); diff --git a/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.test.js b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.test.js new file mode 100644 index 000000000..ab8866e44 --- /dev/null +++ b/packages/node_modules/@webex/widget-demo/src/components/demo-widget/sdk.test.js @@ -0,0 +1,44 @@ +import Webex from '@webex/webex-core'; + +import {createSDKGuestInstance} from './sdk'; + +jest.mock('@webex/webex-core', () => { + const actual = jest.requireActual('@webex/webex-core'); + + return { + ...actual, + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + authorization: { + requestAccessTokenFromJwt: jest.fn(() => Promise.resolve()) + } + })) + }; +}); + +describe('createSDKGuestInstance', () => { + beforeEach(() => { + Webex.mockClear(); + }); + + it('accepts valid guest jwt', async () => { + const jwt = 'a'.repeat(100); + const instance = await createSDKGuestInstance(jwt); + + expect(instance.authorization.requestAccessTokenFromJwt).toHaveBeenCalledWith({jwt}); + }); + + it('rejects oversized jwt', async () => { + const jwt = 'a'.repeat(10000); + + await expect(createSDKGuestInstance(jwt)).rejects.toThrow(); + expect(Webex).not.toHaveBeenCalled(); + }); + + it('rejects malformed or empty jwt', async () => { + await expect(createSDKGuestInstance('')).rejects.toThrow(); + await expect(createSDKGuestInstance(null)).rejects.toThrow(); + await expect(createSDKGuestInstance(undefined)).rejects.toThrow(); + expect(Webex).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node_modules/@webex/widget-demo/src/index.html b/packages/node_modules/@webex/widget-demo/src/index.html index c33d64f58..0f9830667 100644 --- a/packages/node_modules/@webex/widget-demo/src/index.html +++ b/packages/node_modules/@webex/widget-demo/src/index.html @@ -16,8 +16,5 @@
- diff --git a/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.js b/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.js index 61e4bdefc..c35d5ee03 100644 --- a/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.js +++ b/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.js @@ -93,7 +93,7 @@ function handleHangup(props) { }; } -function handleCall(props) { +export function handleCall(props) { const cleanUp = () => props.updateWidgetStatus({hasInitiatedCall: false}); return async () => { @@ -142,8 +142,6 @@ function handleCall(props) { // Use sipAddress (full SIP URI like "26621729979@webex.com") const meetingDestination = response.body.sipAddress || response.body.webLink; - // eslint-disable-next-line no-console - console.log('[UnifiedMeeting] Created:', meetingDestination); destination = meetingDestination; } catch (error) { @@ -168,8 +166,6 @@ function handleCall(props) { props.updateWidgetStatus({hasInitiatedCall: true}); props.placeCall(sdkAdapter, {destination, options: callOptions, cleanUp}) .then((result) => { - // eslint-disable-next-line no-console - console.log('[PlaceCall] callId:', result.id); props.storeMeetDetails({callId: result.id}); }); }; diff --git a/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.test.js b/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.test.js new file mode 100644 index 000000000..7316e705e --- /dev/null +++ b/packages/node_modules/@webex/widget-meet/src/enhancers/withCallHandlers.test.js @@ -0,0 +1,44 @@ +import {handleCall} from './withCallHandlers'; + +jest.mock('../', () => ({ + destinationTypes: { + SIP: 'sip', + EMAIL: 'email', + USERID: 'userId', + SPACEID: 'spaceId', + PSTN: 'pstn' + } +})); + +describe('handlePlaceCall logging', () => { + it('no destination or call id logged', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const sipDestination = '26621729979@webex.com'; + const callId = 'call-id-abc123'; + const storeMeetDetails = jest.fn(); + + const props = { + sdkAdapter: { + sdk: { + meetings: {config: {experimental: {enableUnifiedMeetings: true}}}, + request: jest.fn(() => Promise.resolve({body: {sipAddress: sipDestination}})) + } + }, + widgetMeet: {toType: 'spaceId', toValue: 'room-1'}, + updateWidgetStatus: jest.fn(), + placeCall: jest.fn(() => Promise.resolve({id: callId})), + storeMeetDetails + }; + + await handleCall(props)(); + + expect(storeMeetDetails).toHaveBeenCalledWith({callId}); + + const loggedText = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join(' '); + + expect(loggedText).not.toContain(sipDestination); + expect(loggedText).not.toContain(callId); + + consoleLogSpy.mockRestore(); + }); +});