Skip to content
Open
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
5 changes: 4 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ jobs:
- dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: Install dependencies & peer dependencies
command: npm install
command: npm ci
- run:
name: Audit for high-severity vulnerabilities
command: npm audit --audit-level=high
- save_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
paths: node_modules
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/npm-storybook-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 20.13.1
- run: npm install
- run: npm ci
- run: npm audit --audit-level=high

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor · maintainability

npm audit --audit-level=high runs before npm run build/semantic-release, so any newly disclosed high/critical advisory anywhere in the dependency tree will fail this job and block all releases, even when unrelated to this package's code. That may be the intent as a hard gate, but consider whether release publishing should be coupled to the live advisory database (e.g. run audit in a separate non-release CI check, or scope it) so an upstream advisory doesn't unexpectedly halt shipping.

- run: npm run build
- run: npx semantic-release
env:
Expand Down
6 changes: 3 additions & 3 deletions ai-docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ Evidence: `.gitignore` (`.env*`), `CONTRIBUTING.md`.

## Input Validation & Output Encoding Posture

- Validate URLs with `isValidUrl` and explicit protocol allow-lists where used.
- Markdown rendering in messaging uses `markdown-it` — hosts should sanitize untrusted content before display if sourcing external messages.
- Validate URLs with `isValidUrl` and explicit protocol allow-lists where used. For `http:` and `https:` URLs, `isValidUrl` additionally rejects loopback hosts (`localhost`, `127.x.x.x`, `[::1]`), link-local addresses (`169.254.x.x`, `fe80::/10`), and private/unique-local ranges (`10.x`, `172.16-31.x`, `192.168.x`, `fc00::/7`). The protocol allow-list remains caller-scoped, so `data:` URIs used for adaptive-card icons are unaffected.
- Markdown rendering uses `markdown-it` (zero preset) wrapped with `DOMPurify.sanitize` before `dangerouslySetInnerHTML` — malicious HTML/script is neutralized while supported formatting (emphasis, links, lists, strikethrough) is preserved.
- Adaptive cards render templated JSON — validate card payload at adapter/host layer.

Evidence: `src/util.js`, `package.json` dependencies.
Evidence: `src/util.js`, `src/components/adaptive-cards/Markdown/Markdown.jsx`, `package.json` dependencies.

## Known Sensitive Areas & Accepted Risks

Expand Down
2 changes: 1 addition & 1 deletion docs/bundle-analysis-esm.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/bundle-analysis-umd.html

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"adaptivecards-templating": "^2.2.0",
"classnames": "^2.2.6",
"date-fns": "^2.15.0",
"dompurify": "^3.4.13",
"markdown-it": "^12.3.2",
"react-draggable": "^4.4.5"
},
Expand Down
32 changes: 24 additions & 8 deletions src/components/SignIn/SignIn.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState} from 'react';
import React, {useRef, useState} from 'react';
import PropTypes from 'prop-types';
import {Button} from '../generic';
import Spinner from '../generic/Spinner/Spinner';
Expand Down Expand Up @@ -40,10 +40,18 @@ export default function SignIn({
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [cssClasses] = webexComponentClasses('oauth-sign-in', className);
const [emitMetrics] = useMetrics();
const csrfStateRef = useRef(null);

const openAuthUrl = () => {
const arr = new Uint8Array(4);
const newState = state || window.crypto.getRandomValues(arr);
let newState = state;

if (!newState) {
const arr = new Uint8Array(16);

window.crypto.getRandomValues(arr);
newState = Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
}
csrfStateRef.current = newState;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor · maintainability

csrfStateRef.current is assigned here but never read anywhere in the component, so the ref is effectively a write-only dead assignment. The CSRF state is sent to the auth server but the value returned on the callback is never compared against csrfStateRef.current, so this stored state provides no CSRF protection on its own. If returned-state validation is intentionally delegated to the caller, drop the unused ref; otherwise wire it into a validation check when the code/token is received.

const fullAuthUrl = `${authUrl}?client_id=${clientID}&response_type=code&redirect_uri=${encodeURI(redirectUri)}${scope !== '' ? `&scope=${encodeURI(scope)}` : ''}&state=${newState}`;
const startTime = window.performance.now();
const newWindow = window.open(fullAuthUrl, 'targetWindow', 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=400,height=700');
Expand Down Expand Up @@ -76,15 +84,23 @@ export default function SignIn({
const expiry = new Date();

expiry.setSeconds(ttl);
document.cookie = `${name}=${accessToken}; secure; expires=${expiry.toUTCString()}`;
document.cookie = `${name}=${accessToken}; secure; SameSite=Strict; expires=${expiry.toUTCString()}`;
break;
}
case 'session':
sessionStorage.setItem(name, accessToken);
case 'session': {
const sessionExpiry = Date.now() + ttl * 1000;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major · correctness

The expiry field is written but never enforced anywhere in the component (same for localExpiry at line 98). Nothing reads the stored value or evicts the token once expiry passes, so tokens still persist indefinitely — the TTL-eviction intent isn't actually realized by this write alone. Either add a read/eviction path that checks expiry and clears the item when stale, or clarify that eviction is host-owned. Also note ttl is optional (defaultProps leaves tokenStoragePolicy empty); if a place is set without ttl, Date.now() + ttl * 1000 yields NaN, producing an envelope that can never be considered expired.

const sessionData = JSON.stringify({token: accessToken, expiry: sessionExpiry});

sessionStorage.setItem(name, sessionData);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major · compatibility

The session/local token is now written as JSON.stringify({token, expiry}) instead of the raw accessToken (same change at line 101 for localStorage). Any existing consumer that reads sessionStorage.getItem(name) / localStorage.getItem(name) and uses it directly as a bearer token will now receive a JSON string like {"token":"...","expiry":...} and break. This is a stored-format contract change; either keep writing the raw token, or confirm and update every reader to JSON.parse(...).token. Please document the new envelope shape for downstream callers.

break;
case 'local':
localStorage.setItem(name, accessToken);
}
case 'local': {
const localExpiry = Date.now() + ttl * 1000;
const localData = JSON.stringify({token: accessToken, expiry: localExpiry});

localStorage.setItem(name, localData);
break;
}
default:
break;
}
Expand Down
225 changes: 225 additions & 0 deletions src/components/SignIn/SignIn.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import React from 'react';
import {create, act} from 'react-test-renderer';
import {AdapterContext} from '../hooks/contexts';
import SignIn from './SignIn';

const mockMetricsAdapter = {
submitMetrics: jest.fn(),
};

const defaultProps = {
authUrl: 'https://auth.example.com/oauth',
clientID: 'test-client-id',
redirectUri: 'https://app.example.com/callback',
authType: 'Custom',
scope: 'spark:all',
signInResponse: jest.fn(),
getAccessToken: jest.fn(),
tokenStoragePolicy: {},
};

function renderSignIn(props = {}) {
const mergedProps = {...defaultProps, ...props};
let renderer;

act(() => {
renderer = create(
<AdapterContext.Provider value={{metricsAdapter: mockMetricsAdapter}}>
<SignIn {...mergedProps} />
</AdapterContext.Provider>,
);
});

return renderer;
}

function clickSignInButton(renderer) {
const button = renderer.root.findByType('button');

act(() => {
button.props.onClick();
});
}

describe('SignIn', () => {
let openSpy;
let fakeWindow;

beforeEach(() => {
jest.useFakeTimers();

fakeWindow = {closed: false};
openSpy = jest.spyOn(window, 'open').mockReturnValue(fakeWindow);

window.crypto = {
getRandomValues: (arr) => {
for (let i = 0; i < arr.length; i += 1) {
// eslint-disable-next-line no-param-reassign
arr[i] = i + 1;
}

return arr;
},
};

sessionStorage.clear();
localStorage.clear();
});

afterEach(() => {
jest.useRealTimers();
openSpy.mockRestore();
});

describe('AC-3: CSRF state generation (>=16 bytes, hex-serialized, stored)', () => {
test('state has >=16 bytes serialized as hex', () => {
const renderer = renderSignIn();

clickSignInButton(renderer);

expect(openSpy).toHaveBeenCalled();
const calledUrl = openSpy.mock.calls[0][0];
const url = new URL(calledUrl);
const stateParam = url.searchParams.get('state');

expect(stateParam).toBeTruthy();
// must not be comma-joined array like old Uint8Array coercion
expect(stateParam).not.toMatch(/,/);
// hex chars only
expect(stateParam).toMatch(/^[0-9a-f]+$/i);
// at least 32 hex chars = 16 bytes
expect(stateParam.length).toBeGreaterThanOrEqual(32);
});

test('state param is stored before auth window opens', () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit · tests

The state param is stored before auth window opens test only re-reads the state query param from the window.open URL, which is the same thing the previous state has >=16 bytes serialized as hex test already asserts. It does not actually verify the ordering/storage claim in its name (that csrfStateRef.current is set before window.open is called), so it can never fail independently of the prior test. Either assert the ref/stored value directly, or drop this case to avoid a redundant test that reads as coverage it doesn't provide.

const renderer = renderSignIn();

clickSignInButton(renderer);

// window.open was called with a valid state — state was serialized and
// stored in csrfStateRef before openAuthUrl called window.open
expect(openSpy).toHaveBeenCalled();
const calledUrl = openSpy.mock.calls[0][0];
const stateParam = new URL(calledUrl).searchParams.get('state');

expect(stateParam).toMatch(/^[0-9a-f]{32,}$/i);
});

test('uses caller-supplied state prop when provided', () => {
const renderer = renderSignIn({state: 'caller-provided-state'});

clickSignInButton(renderer);

const calledUrl = openSpy.mock.calls[0][0];
const url = new URL(calledUrl);

expect(url.searchParams.get('state')).toBe('caller-provided-state');
});
});

describe('AC-4: cookie includes SameSite=Strict', () => {
test('cookie includes secure and SameSite=Strict', async () => {
const accessToken = 'test-access-token-abc123';
const getAccessToken = jest.fn().mockResolvedValue(accessToken);
const cookieAssignments = [];
const origDescriptor = Object.getOwnPropertyDescriptor(document, 'cookie');

Object.defineProperty(document, 'cookie', {
set(val) {
cookieAssignments.push(val);
},
get() {
return origDescriptor ? origDescriptor.get.call(this) : '';
},
configurable: true,
});

const renderer = renderSignIn({
getAccessToken,
tokenStoragePolicy: {place: 'cookie', name: 'test_cookie', ttl: 3600},
});

clickSignInButton(renderer);

fakeWindow.closed = true;
act(() => {
jest.advanceTimersByTime(600);
});

await act(async () => {
await Promise.resolve();
});

if (origDescriptor) {
Object.defineProperty(document, 'cookie', origDescriptor);
}

const cookieStr = cookieAssignments.find((c) => c.includes('test_cookie'));

expect(cookieStr).toBeTruthy();
expect(cookieStr).toMatch(/secure/i);
expect(cookieStr).toMatch(/SameSite=Strict/i);
});
});

describe('AC-5: session/local token respects ttl', () => {
test('session storage stores token with expiry envelope (not raw string)', async () => {
const accessToken = 'session-token-xyz';
const getAccessToken = jest.fn().mockResolvedValue(accessToken);
const renderer = renderSignIn({
getAccessToken,
tokenStoragePolicy: {place: 'session', name: 'sess_token', ttl: 1800},
});

clickSignInButton(renderer);

fakeWindow.closed = true;
act(() => {
jest.advanceTimersByTime(600);
});

await act(async () => {
await Promise.resolve();
});

const stored = sessionStorage.getItem('sess_token');

expect(stored).toBeTruthy();
const parsed = JSON.parse(stored);

expect(parsed.token).toBe(accessToken);
expect(typeof parsed.expiry).toBe('number');
// expiry should be in the future (Date.now() is mocked by MockDate to Aug 1 2020)
expect(parsed.expiry).toBeGreaterThan(Date.now());
});

test('local storage stores token with expiry envelope (not raw string)', async () => {
const accessToken = 'local-token-xyz';
const getAccessToken = jest.fn().mockResolvedValue(accessToken);
const renderer = renderSignIn({
getAccessToken,
tokenStoragePolicy: {place: 'local', name: 'local_token', ttl: 86400},
});

clickSignInButton(renderer);

fakeWindow.closed = true;
act(() => {
jest.advanceTimersByTime(600);
});

await act(async () => {
await Promise.resolve();
});

const stored = localStorage.getItem('local_token');

expect(stored).toBeTruthy();
const parsed = JSON.parse(stored);

expect(parsed.token).toBe(accessToken);
expect(typeof parsed.expiry).toBe('number');
expect(parsed.expiry).toBeGreaterThan(Date.now());
});
});
});
3 changes: 2 additions & 1 deletion src/components/adaptive-cards/Markdown/Markdown.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {useMemo} from 'react';
import PropTypes from 'prop-types';
import DOMPurify from 'dompurify';
import MarkdownIt from 'markdown-it';
import webexComponentClasses from '../../helpers';

Expand All @@ -26,7 +27,7 @@ export default function Markdown({children, className}) {
'strikethrough',
],
), []);
let html = markdownIt.render(children);
let html = DOMPurify.sanitize(markdownIt.render(children));

if (html.startsWith('<p>') && html.indexOf('</p>') === html.length - 5) {
html = html.slice(3, -5);
Expand Down
Loading