diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3206e62eb..07376f4d4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,31 +12,6 @@ concurrency: env: rid: ${{ github.run_id }}-${{ github.run_number }} - PW_SANDBOX_PASSWORD: ${{ secrets.PW_SANDBOX_PASSWORD }} - PW_SENDER_EMAIL_PASSWORD: ${{ secrets.PW_SENDER_EMAIL_PASSWORD }} - PW_SANDBOX: ${{ secrets.PW_SANDBOX }} - PW_ENTRY_POINT1: ${{ secrets.PW_ENTRY_POINT1 }} - PW_ENTRY_POINT2: ${{ secrets.PW_ENTRY_POINT2 }} - PW_ENTRY_POINT3: ${{ secrets.PW_ENTRY_POINT3 }} - PW_ENTRY_POINT4: ${{ secrets.PW_ENTRY_POINT4 }} - PW_ENTRY_POINT5: ${{ secrets.PW_ENTRY_POINT5 }} - PW_ENTRY_POINT6: ${{ secrets.PW_ENTRY_POINT6 }} - PW_ENTRY_POINT7: ${{ secrets.PW_ENTRY_POINT7 }} - PW_ENTRY_POINT8: ${{ secrets.PW_ENTRY_POINT8 }} - PW_ENTRY_POINT9: ${{ secrets.PW_ENTRY_POINT9 }} - PW_ENTRY_POINT10: ${{ secrets.PW_ENTRY_POINT10 }} - PW_ENTRY_POINT11: ${{ secrets.PW_ENTRY_POINT11 }} - PW_ENTRY_POINT12: ${{ secrets.PW_ENTRY_POINT12 }} - PW_SENDER_EMAIL: ${{ secrets.PW_SENDER_EMAIL }} - PW_CHAT_URL: ${{ secrets.PW_CHAT_URL }} - PW_DIAL_NUMBER_LOGIN_USERNAME: ${{ secrets.PW_DIAL_NUMBER_LOGIN_USERNAME }} - PW_DIAL_NUMBER_LOGIN_PASSWORD: ${{ secrets.PW_DIAL_NUMBER_LOGIN_PASSWORD }} - PW_DIAL_NUMBER: ${{ secrets.PW_DIAL_NUMBER }} - PW_DIAL_NUMBER_NAME: ${{ secrets.PW_DIAL_NUMBER_NAME }} - PW_ENTRYPOINT_NAME: ${{ secrets.PW_ENTRYPOINT_NAME }} - PW_MEETING_USERID: ${{ secrets.PW_MEETING_USERID }} - PW_MEETING_PASSWORD: ${{ secrets.PW_MEETING_PASSWORD }} - PW_MEETING_DESTINATION: ${{ secrets.PW_MEETING_DESTINATION}} jobs: validate: @@ -140,6 +115,32 @@ jobs: concurrency: group: e2e-meetings cancel-in-progress: false + env: + PW_SANDBOX_PASSWORD: ${{ secrets.PW_SANDBOX_PASSWORD }} + PW_SENDER_EMAIL_PASSWORD: ${{ secrets.PW_SENDER_EMAIL_PASSWORD }} + PW_SANDBOX: ${{ secrets.PW_SANDBOX }} + PW_ENTRY_POINT1: ${{ secrets.PW_ENTRY_POINT1 }} + PW_ENTRY_POINT2: ${{ secrets.PW_ENTRY_POINT2 }} + PW_ENTRY_POINT3: ${{ secrets.PW_ENTRY_POINT3 }} + PW_ENTRY_POINT4: ${{ secrets.PW_ENTRY_POINT4 }} + PW_ENTRY_POINT5: ${{ secrets.PW_ENTRY_POINT5 }} + PW_ENTRY_POINT6: ${{ secrets.PW_ENTRY_POINT6 }} + PW_ENTRY_POINT7: ${{ secrets.PW_ENTRY_POINT7 }} + PW_ENTRY_POINT8: ${{ secrets.PW_ENTRY_POINT8 }} + PW_ENTRY_POINT9: ${{ secrets.PW_ENTRY_POINT9 }} + PW_ENTRY_POINT10: ${{ secrets.PW_ENTRY_POINT10 }} + PW_ENTRY_POINT11: ${{ secrets.PW_ENTRY_POINT11 }} + PW_ENTRY_POINT12: ${{ secrets.PW_ENTRY_POINT12 }} + PW_SENDER_EMAIL: ${{ secrets.PW_SENDER_EMAIL }} + PW_CHAT_URL: ${{ secrets.PW_CHAT_URL }} + PW_DIAL_NUMBER_LOGIN_USERNAME: ${{ secrets.PW_DIAL_NUMBER_LOGIN_USERNAME }} + PW_DIAL_NUMBER_LOGIN_PASSWORD: ${{ secrets.PW_DIAL_NUMBER_LOGIN_PASSWORD }} + PW_DIAL_NUMBER: ${{ secrets.PW_DIAL_NUMBER }} + PW_DIAL_NUMBER_NAME: ${{ secrets.PW_DIAL_NUMBER_NAME }} + PW_ENTRYPOINT_NAME: ${{ secrets.PW_ENTRYPOINT_NAME }} + PW_MEETING_USERID: ${{ secrets.PW_MEETING_USERID }} + PW_MEETING_PASSWORD: ${{ secrets.PW_MEETING_PASSWORD }} + PW_MEETING_DESTINATION: ${{ secrets.PW_MEETING_DESTINATION }} steps: - name: Checkout Project @@ -194,6 +195,32 @@ jobs: concurrency: group: e2e-cc-widgets cancel-in-progress: false + env: + PW_SANDBOX_PASSWORD: ${{ secrets.PW_SANDBOX_PASSWORD }} + PW_SENDER_EMAIL_PASSWORD: ${{ secrets.PW_SENDER_EMAIL_PASSWORD }} + PW_SANDBOX: ${{ secrets.PW_SANDBOX }} + PW_ENTRY_POINT1: ${{ secrets.PW_ENTRY_POINT1 }} + PW_ENTRY_POINT2: ${{ secrets.PW_ENTRY_POINT2 }} + PW_ENTRY_POINT3: ${{ secrets.PW_ENTRY_POINT3 }} + PW_ENTRY_POINT4: ${{ secrets.PW_ENTRY_POINT4 }} + PW_ENTRY_POINT5: ${{ secrets.PW_ENTRY_POINT5 }} + PW_ENTRY_POINT6: ${{ secrets.PW_ENTRY_POINT6 }} + PW_ENTRY_POINT7: ${{ secrets.PW_ENTRY_POINT7 }} + PW_ENTRY_POINT8: ${{ secrets.PW_ENTRY_POINT8 }} + PW_ENTRY_POINT9: ${{ secrets.PW_ENTRY_POINT9 }} + PW_ENTRY_POINT10: ${{ secrets.PW_ENTRY_POINT10 }} + PW_ENTRY_POINT11: ${{ secrets.PW_ENTRY_POINT11 }} + PW_ENTRY_POINT12: ${{ secrets.PW_ENTRY_POINT12 }} + PW_SENDER_EMAIL: ${{ secrets.PW_SENDER_EMAIL }} + PW_CHAT_URL: ${{ secrets.PW_CHAT_URL }} + PW_DIAL_NUMBER_LOGIN_USERNAME: ${{ secrets.PW_DIAL_NUMBER_LOGIN_USERNAME }} + PW_DIAL_NUMBER_LOGIN_PASSWORD: ${{ secrets.PW_DIAL_NUMBER_LOGIN_PASSWORD }} + PW_DIAL_NUMBER: ${{ secrets.PW_DIAL_NUMBER }} + PW_DIAL_NUMBER_NAME: ${{ secrets.PW_DIAL_NUMBER_NAME }} + PW_ENTRYPOINT_NAME: ${{ secrets.PW_ENTRYPOINT_NAME }} + PW_MEETING_USERID: ${{ secrets.PW_MEETING_USERID }} + PW_MEETING_PASSWORD: ${{ secrets.PW_MEETING_PASSWORD }} + PW_MEETING_DESTINATION: ${{ secrets.PW_MEETING_DESTINATION }} steps: - name: Checkout Project diff --git a/tooling/src/publish.js b/tooling/src/publish.js index afa8fb1b7..4d4d6ddd7 100644 --- a/tooling/src/publish.js +++ b/tooling/src/publish.js @@ -1,4 +1,4 @@ -const {execSync} = require('child_process'); +const {execFileSync} = require('child_process'); const fs = require('fs'); const path = require('path'); @@ -42,6 +42,17 @@ function versionAndPublish() { ); process.exit(1); } + + // Validate branchName (used as npm dist-tag): must match npm tag naming rules. + // Reject any value containing shell metacharacters or characters outside the allowed set. + const validTagPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + if (!validTagPattern.test(branchName)) { + console.error( + `Error: Invalid branchName/tag value "${branchName}". Must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/` + ); + process.exit(1); + return; + } const contactCenterPath = './packages/contact-center'; try { @@ -61,10 +72,16 @@ function versionAndPublish() { return packageData.name; }); - // Publish the package + // Validate workspace names (npm package name pattern) and publish via no-shell execFileSync. + // Using execFileSync instead of execSync prevents shell interpretation of workspace or tag values. + const validPackageNamePattern = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; + const publishWorkspace = (workspace) => { + if (!validPackageNamePattern.test(workspace)) { + throw new Error(`Invalid package name "${workspace}": does not match npm package name pattern`); + } console.log(`Publishing new version for ${workspace}: ${newVersion}`); - execSync(`yarn workspace ${workspace} npm publish --tag ${branchName}`, {stdio: 'inherit'}); + execFileSync('yarn', ['workspace', workspace, 'npm', 'publish', '--tag', branchName], {stdio: 'inherit'}); }; const denyList = ['@webex/test-fixtures']; // Add workspace names to exclude from publishing diff --git a/tooling/tests/publish.js b/tooling/tests/publish.js index 4c8cdb367..3fdf4486f 100644 --- a/tooling/tests/publish.js +++ b/tooling/tests/publish.js @@ -173,8 +173,8 @@ describe('versionAndPublish', () => { mockFs.readFileSync.mockReturnValueOnce(packageJsonContent).mockReturnValueOnce(packageJsonContent2); mockFs.writeFileSync.mockImplementation(() => {}); - const mockExecSync = require('child_process').execSync; - mockExecSync.mockImplementation(() => {}); + const mockExecFileSync = require('child_process').execFileSync; + mockExecFileSync.mockImplementation(() => {}); const processArgvMock = ['node', 'script.js', 'main', '1.0.1']; process.argv = processArgvMock; @@ -195,12 +195,18 @@ describe('versionAndPublish', () => { 'utf-8' ); - expect(mockExecSync).toHaveBeenNthCalledWith(1, 'yarn workspace @webex/cc-store npm publish --tag main', { - stdio: 'inherit', - }); - expect(mockExecSync).toHaveBeenNthCalledWith(2, 'yarn workspace @webex/cc-station-login npm publish --tag main', { - stdio: 'inherit', - }); + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['workspace', '@webex/cc-store', 'npm', 'publish', '--tag', 'main'], + {stdio: 'inherit'} + ); + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 2, + 'yarn', + ['workspace', '@webex/cc-station-login', 'npm', 'publish', '--tag', 'main'], + {stdio: 'inherit'} + ); }); it('should not publish packages in deny list P.S we only have test-fixtures in deny list right now', () => { @@ -221,8 +227,8 @@ describe('versionAndPublish', () => { mockFs.readFileSync.mockReturnValueOnce(packageJsonContent).mockReturnValueOnce(packageJsonContent2); mockFs.writeFileSync.mockImplementation(() => {}); - const mockExecSync = require('child_process').execSync; - mockExecSync.mockImplementation(() => {}); + const mockExecFileSync = require('child_process').execFileSync; + mockExecFileSync.mockImplementation(() => {}); const processArgvMock = ['node', 'script.js', 'main', '1.0.1']; process.argv = processArgvMock; @@ -243,12 +249,17 @@ describe('versionAndPublish', () => { 'utf-8' ); - expect(mockExecSync).not.toHaveBeenCalledWith('yarn workspace @webex/test-fixtures npm publish --tag main', { - stdio: 'inherit', - }); - expect(mockExecSync).toHaveBeenNthCalledWith(1, 'yarn workspace @webex/cc-station-login npm publish --tag main', { - stdio: 'inherit', - }); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'yarn', + ['workspace', '@webex/test-fixtures', 'npm', 'publish', '--tag', 'main'], + {stdio: 'inherit'} + ); + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['workspace', '@webex/cc-station-login', 'npm', 'publish', '--tag', 'main'], + {stdio: 'inherit'} + ); }); it('error occurred while reading package.json data', () => { @@ -261,14 +272,65 @@ describe('versionAndPublish', () => { throw new Error('Error while reading from file'); }); - const mockExecSync = require('child_process').execSync; + const mockExecFileSync = require('child_process').execFileSync; const processArgvMock = ['node', 'script.js', 'main', '1.0.1']; process.argv = processArgvMock; versionAndPublish(); expect(console.error).toHaveBeenCalledWith('Failed to process workspaces:', 'Error while reading from file'); - expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('WF-02: rejects a malicious branchName containing shell metacharacters', () => { + const packageJsonContent = JSON.stringify({ + name: '@webex/cc-store', + version: '1.0.0', + }); + + mockFs.readdirSync.mockReturnValue([{name: 'store', isDirectory: () => true}]); + mockFs.readFileSync.mockReturnValue(packageJsonContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + const mockExecFileSync = require('child_process').execFileSync; + mockExecFileSync.mockImplementation(() => {}); + + // A tag with shell metacharacters must be rejected before any publish call + process.argv = ['node', 'script.js', 'main; rm -rf /', '1.0.1']; + + versionAndPublish(); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Error') + ); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('WF-01: rejects a workspace name containing shell metacharacters', () => { + const packageJsonContent = JSON.stringify({ + name: '@webex/cc-store$(malicious)', + version: '1.0.0', + }); + + mockFs.readdirSync.mockReturnValue([{name: 'store', isDirectory: () => true}]); + mockFs.readFileSync.mockReturnValue(packageJsonContent); + mockFs.writeFileSync.mockImplementation(() => {}); + + const mockExecFileSync = require('child_process').execFileSync; + mockExecFileSync.mockImplementation(() => {}); + + process.argv = ['node', 'script.js', 'main', '1.0.1']; + + versionAndPublish(); + + // The malicious workspace should either be rejected (process.exit) or + // skipped without ever being passed to execFileSync + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'yarn', + expect.arrayContaining(['@webex/cc-store$(malicious)']), + expect.anything() + ); }); it('should export versionAndPublish when required as a module', () => { @@ -289,7 +351,7 @@ describe('versionAndPublish', () => { throw new Error('Error while reading from file'); }); - const mockExecSync = require('child_process').execSync; + const mockExecFileSync = require('child_process').execFileSync; const processArgvMock = ['node', 'script.js', 'main', '1.0.1']; process.argv = processArgvMock; @@ -299,6 +361,6 @@ describe('versionAndPublish', () => { versionAndPublish(); expect(console.error).toHaveBeenCalledWith('Failed to process workspaces:', 'package.json not found in store'); - expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); }); }); diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 442769e5a..66969f4d5 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -29,10 +29,6 @@ import './App.scss'; import {observer} from 'mobx-react-lite'; import EngageWidget from './EngageWidget'; -// This is not to be included to a production app. -// Have added here for debugging purposes -window['store'] = store; - const defaultWidgets = { stationLogin: true, stationLoginProfile: false, @@ -131,22 +127,19 @@ function App() { }; useEffect(() => { + // Remove token persisted by older sample builds — no longer read or written (SPARK-833336) + window.localStorage.removeItem('accessToken'); + if (window.location.hash) { const urlParams = new URLSearchParams(window.location.hash.replace('#', '?')); const accessToken = urlParams.get('access_token'); if (accessToken) { - window.localStorage.setItem('accessToken', accessToken); setAccessToken(accessToken); // Clear the hash from the URL to remove the token from browser history window.history.replaceState({}, document.title, window.location.pathname + window.location.search); } - } else { - const storedAccessToken = window.localStorage.getItem('accessToken'); - if (storedAccessToken) { - setAccessToken(storedAccessToken); - } } }, []); @@ -379,13 +372,6 @@ function App() { }); }; - // Store accessToken changes in local storage - useEffect(() => { - if (accessToken.trim() !== '') { - window.localStorage.setItem('accessToken', accessToken); - } - }, [accessToken]); - useEffect(() => { window.localStorage.setItem('selectedWidgets', JSON.stringify(selectedWidgets)); }, [selectedWidgets]); diff --git a/widgets-samples/cc/samples-cc-wc-app/app.js b/widgets-samples/cc/samples-cc-wc-app/app.js index ffdda096d..a558f2c12 100644 --- a/widgets-samples/cc/samples-cc-wc-app/app.js +++ b/widgets-samples/cc/samples-cc-wc-app/app.js @@ -150,6 +150,9 @@ const updateButtonState = () => { accessTokenElem.addEventListener('keyup', updateButtonState); window.addEventListener('load', () => { + // Remove token persisted by older sample builds — no longer read or written (SPARK-833336) + localStorage.removeItem('accessToken'); + changeLoginType(); loadintegrationEnvSetting(); // Load the setting on page load if (window.location.hash) { @@ -158,18 +161,11 @@ window.addEventListener('load', () => { const accessToken = urlParams.get('access_token'); if (accessToken) { - localStorage.setItem('accessToken', accessToken); accessTokenElem.value = accessToken; updateButtonState(); // Clear the hash from the URL to remove the token from browser history window.history.replaceState({}, document.title, window.location.pathname + window.location.search); } - } else { - const storedAccessToken = window.localStorage.getItem('accessToken'); - if (storedAccessToken) { - accessTokenElem.value = storedAccessToken; - updateButtonState(); - } } });