Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function TokenInput(props) {
placeholder={tokenType === 'JWT' ? 'JWT' : 'Access Token'}
value={userAccessToken}
/>
<p>You can get an access token from <a href="http://developer.webex.com">developer.webex.com</a></p>
<p>You can get an access token from <a href="https://developer.webex.com" target="_blank" rel="noopener noreferrer">developer.webex.com</a></p>
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<TokenInput onLogin={jest.fn()} />);
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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;

Expand Down Expand Up @@ -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: ''});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<DemoWidget cookies={cookies} />);
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(<DemoWidget cookies={cookies} />);
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(<DemoWidget cookies={cookies} />);
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(<DemoWidget cookies={cookies} />);
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());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -123,6 +127,12 @@ export function createSDKInstance(accessToken, options = {}) {
* @returns {Promise<object>}
*/
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)
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 0 additions & 3 deletions packages/node_modules/@webex/widget-demo/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,5 @@
<body>
<div id="widget"></div>
<div id="main"></div>
<script>
window.ciscoSparkEvents = [];
</script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function handleHangup(props) {
};
}

function handleCall(props) {
export function handleCall(props) {
const cleanUp = () => props.updateWidgetStatus({hasInitiatedCall: false});

return async () => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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});
});
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});