-
Notifications
You must be signed in to change notification settings - Fork 58
SPARK-843491: Remediate 8 High security findings in components #862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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'; | ||
|
|
@@ -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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor · maintainability
|
||
| 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'); | ||
|
|
@@ -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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. major · correctness The |
||
| const sessionData = JSON.stringify({token: accessToken, expiry: sessionExpiry}); | ||
|
|
||
| sessionStorage.setItem(name, sessionData); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. major · compatibility The session/local token is now written as |
||
| 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; | ||
| } | ||
|
|
||
| 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', () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit · tests The |
||
| 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()); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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=highruns beforenpm 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.