diff --git a/__mocks__/webextension-polyfill.ts b/__mocks__/webextension-polyfill.ts index 6c067473..41862c39 100644 --- a/__mocks__/webextension-polyfill.ts +++ b/__mocks__/webextension-polyfill.ts @@ -56,6 +56,16 @@ const Browser = { }, tabs: { query: jest.fn(), + sendMessage: jest.fn(), + onCreated: { + addListener: jest.fn(), + }, + onUpdated: { + addListener: jest.fn(), + }, + onRemoved: { + addListener: jest.fn(), + }, }, }; diff --git a/source/Background/index.ts b/source/Background/index.ts index 9401275a..078210cf 100644 --- a/source/Background/index.ts +++ b/source/Background/index.ts @@ -21,8 +21,13 @@ import 'emoji-log'; import Browser, {Cookies, Runtime} from 'webextension-polyfill'; import {ReportedStorage} from '../types/ReportedModel'; import {ZestScript, ZestScriptMessage} from '../types/zestScript/ZestScript'; -import {ZestStatementWindowClose} from '../types/zestScript/ZestStatement'; import { + ZestStatementActionSleep, + ZestStatementWindowClose, + ZestStatementWindowHandle, +} from '../types/zestScript/ZestStatement'; +import { + DEFAULT_WINDOW_HANDLE, GET_ZEST_SCRIPT, IS_FULL_EXTENSION, LOCAL_STORAGE, @@ -31,6 +36,7 @@ import { RESET_ZEST_SCRIPT, SESSION_STORAGE, STOP_RECORDING, + ZAP_REGISTER_POPUP, ZEST_SCRIPT, } from '../utils/constants'; @@ -41,6 +47,16 @@ console.log('ZAP Service Worker 👋'); */ const reportedStorage = new Set(); const zestScript = new ZestScript(); + +let windowHandleCounter = 1; +const popupTabHandles = new Map(); + +const POPUP_WINDOW_SLEEP_MS = 10000; + +interface RegisterPopupMessage { + type: string; + url?: string; +} /* A callback URL will only be available if the browser has been launched from ZAP, otherwise call the individual endpoints */ @@ -233,6 +249,8 @@ async function handleMessage( case RESET_ZEST_SCRIPT: zestScript.reset(); + windowHandleCounter = 1; + popupTabHandles.clear(); break; case STOP_RECORDING: { @@ -246,6 +264,8 @@ async function handleMessage( sendZestScriptToZAP(data, zapkey, zapurl); } } + windowHandleCounter = 1; + popupTabHandles.clear(); break; } @@ -260,19 +280,72 @@ async function handleMessage( async function onMessageHandler( message: unknown, _sender: Runtime.MessageSender -): Promise { +): Promise { + const msg = message as MessageEvent; + + if (msg.type === ZAP_REGISTER_POPUP) { + // Called by the content script when it detects window.opener !== null. + // Assign a new window handle for this popup tab if not already registered. + const tabId = _sender.tab?.id; + const popupUrl = (msg as unknown as RegisterPopupMessage).url ?? ''; + + if (tabId !== undefined) { + const cached = popupTabHandles.get(tabId); + if (cached !== undefined) { + return Promise.resolve(cached); + } + windowHandleCounter += 1; + const handle = `windowHandle${windowHandleCounter}`; + popupTabHandles.set(tabId, handle); + + // Emit ZestClientWindowHandle so ZAP's runner can locate and register + // this popup window by URL before replaying any popup interactions. + // Use regex=true with the origin (scheme+host) so that URL parameters + // that change between sessions (nonce, state, etc.) don't break the match. + let urlPattern = '.*'; + try { + const parsed = new URL(popupUrl); + urlPattern = `${parsed.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*`; + } catch { + // popupUrl is empty or invalid; fall back to match-all + } + const sleepStmt = new ZestStatementActionSleep(POPUP_WINDOW_SLEEP_MS); + const sleepStmtData = zestScript.addStatement(sleepStmt.toJSON()); + const stmt = new ZestStatementWindowHandle(handle, urlPattern, true); + const stmtData = zestScript.addStatement(stmt.toJSON()); + if (IS_FULL_EXTENSION) { + const items = await Browser.storage.sync.get({ + zapurl: 'http://zap/', + zapkey: 'not set', + }); + sendZestScriptToZAP( + sleepStmtData, + items.zapkey as string, + items.zapurl as string + ); + sendZestScriptToZAP( + stmtData, + items.zapkey as string, + items.zapurl as string + ); + } + + return Promise.resolve(handle); + } + return Promise.resolve(DEFAULT_WINDOW_HANDLE); + } let val: number | ZestScriptMessage = 2; const items = await Browser.storage.sync.get({ zapurl: 'http://zap/', zapkey: 'not set', }); - const msg = await handleMessage( - message as MessageEvent, + const result = await handleMessage( + msg, items.zapurl as string, items.zapkey as string ); - if (!(typeof msg === 'boolean')) { - val = msg; + if (!(typeof result === 'boolean')) { + val = result; } return Promise.resolve(val); } @@ -296,6 +369,22 @@ function cookieChangeHandler( Browser.runtime.onMessage.addListener(onMessageHandler); +Browser.tabs.onRemoved.addListener(async (tabId) => { + const handle = popupTabHandles.get(tabId); + if (!handle) return; + popupTabHandles.delete(tabId); + + if (IS_FULL_EXTENSION) { + const items = await Browser.storage.sync.get({ + zapurl: 'http://zap/', + zapkey: 'not set', + }); + const stmt = new ZestStatementWindowClose(0, handle); + const data = zestScript.addStatement(stmt.toJSON()); + sendZestScriptToZAP(data, items.zapkey as string, items.zapurl as string); + } +}); + if (IS_FULL_EXTENSION) { Browser.action.onClicked.addListener((_tab: Browser.Tabs.Tab) => { Browser.runtime.openOptionsPage(); diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 77346404..61e36c99 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -26,6 +26,7 @@ import { } from '../types/ReportedModel'; import Recorder from './recorder'; import { + DEFAULT_WINDOW_HANDLE, IS_FULL_EXTENSION, LOCAL_STORAGE, LOCAL_ZAP_ENABLE, @@ -35,6 +36,7 @@ import { SESSION_STORAGE, URL_ZAP_ENABLE, URL_ZAP_RECORD, + ZAP_REGISTER_POPUP, ZAP_START_RECORDING, ZAP_STOP_RECORDING, } from '../utils/constants'; @@ -302,18 +304,37 @@ function injectScript(): Promise { return new Promise((resolve) => { configureExtension(); withZapRecordingActive(() => { - Browser.storage.sync - .get({initScript: false, loginUrl: '', startTime: 0}) - .then((items) => { - console.log( - `ZAP injectScript items ${items.initScript} ${items.loginUrl}` - ); - recorder.recordUserInteractions( - items.initScript === true, - items.loginUrl as string, - items.startTime as number - ); - }); + // Detect popup windows using window.opener: if non-null, this page was + // opened via window.open() (e.g. OAuth login popup). This works in all + // modes (private, normal) and all browsers, unlike background-side detection. + const isWindowPopup = window.opener !== null; + const handlePromise = isWindowPopup + ? Browser.runtime + .sendMessage({type: ZAP_REGISTER_POPUP, url: window.location.href}) + .catch(() => DEFAULT_WINDOW_HANDLE) + : Promise.resolve(DEFAULT_WINDOW_HANDLE); + + Promise.all([ + Browser.storage.sync.get({ + initScript: false, + loginUrl: '', + startTime: 0, + }), + handlePromise, + ]).then(([items, handle]) => { + const windowHandle = (handle as string) || DEFAULT_WINDOW_HANDLE; + console.log( + `ZAP injectScript items ${items.initScript} ${items.loginUrl} handle=${windowHandle} popup=${isWindowPopup}` + ); + if (isWindowPopup) { + recorder.setWindowHandle(windowHandle); + } + recorder.recordUserInteractions( + isWindowPopup ? false : items.initScript === true, + items.loginUrl as string, + items.startTime as number + ); + }); }); withZapEnableSetting(() => { enableExtension(); diff --git a/source/ContentScript/recorder.ts b/source/ContentScript/recorder.ts index c28deebb..c3e45a73 100644 --- a/source/ContentScript/recorder.ts +++ b/source/ContentScript/recorder.ts @@ -34,6 +34,7 @@ import {ZestScriptMessage} from '../types/zestScript/ZestScript'; import {getPath} from './util'; import {downloadJson} from '../utils/util'; import { + DEFAULT_WINDOW_HANDLE, GET_ZEST_SCRIPT, STOP_RECORDING, ZAP_FLOATING_DIV, @@ -57,6 +58,8 @@ class Recorder { active = false; + windowHandle: string = DEFAULT_WINDOW_HANDLE; + haveListenersBeenAdded = false; floatingWindowInserted = false; @@ -104,7 +107,11 @@ class Recorder { sendScrollToToZap(elementLocator: ElementLocator, waitForMsec: number): void { this.sendZestScriptToZAP( - new ZestStatementElementScrollTo(elementLocator, waitForMsec), + new ZestStatementElementScrollTo( + elementLocator, + waitForMsec, + this.windowHandle + ), {sendCache: false, notify: false} ); } @@ -131,20 +138,20 @@ class Recorder { } if (this.curLevel > level) { while (this.curLevel > level) { - this.sendZestScriptToZAP(new ZestStatementSwitchToFrame(-1), { - sendCache: true, - notify: true, - }); + this.sendZestScriptToZAP( + new ZestStatementSwitchToFrame(-1, '', this.windowHandle), + {sendCache: true, notify: true} + ); this.curLevel -= 1; } this.curFrame = frameIndex; } else { this.curLevel += 1; this.curFrame = frameIndex; - this.sendZestScriptToZAP(new ZestStatementSwitchToFrame(frameIndex), { - sendCache: true, - notify: true, - }); + this.sendZestScriptToZAP( + new ZestStatementSwitchToFrame(frameIndex, '', this.windowHandle), + {sendCache: true, notify: true} + ); } if (this.curLevel !== level) { console.log('ZAP Error in switching frames'); @@ -172,7 +179,7 @@ class Recorder { this.sendScrollToToZap(elementLocator, waited); this.sendZestScriptToZAP( - new ZestStatementElementClick(elementLocator, waited), + new ZestStatementElementClick(elementLocator, waited, this.windowHandle), {sendCache: true, notify: true} ); // click on target element @@ -225,7 +232,8 @@ class Recorder { new ZestStatementElementSendKeys( elementLocator, (event.target as HTMLInputElement).value, - waited + waited, + this.windowHandle ), {sendCache: false, notify: true} ); @@ -250,7 +258,8 @@ class Recorder { // Cache the statement as it often occurs before the change event occurs this.cachedSubmit = new ZestStatementElementSubmit( elementLocator, - this.getWaited() + this.getWaited(), + this.windowHandle ); this.cachedTimeStamp = event.timeStamp; // console.log('ZAP Caching submit', this.cachedSubmit); @@ -485,7 +494,8 @@ class Recorder { this.sendZestScriptToZAP( new ZestStatementLaunchBrowser( this.getBrowserName(), - loginUrl !== '' ? loginUrl : window.location.href + loginUrl !== '' ? loginUrl : window.location.href, + this.windowHandle ), {sendCache: true, notify: true} ); @@ -771,6 +781,10 @@ class Recorder { }, 100); }); } + + setWindowHandle(handle: string): void { + this.windowHandle = handle; + } } export default Recorder; diff --git a/source/manifest.json b/source/manifest.json index 2cc0c21a..9ab653ef 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ZAP by Checkmarx Browser Extension", - "version": "0.1.8", + "version": "0.1.9", "icons": { "16": "assets/icons/zap16x16.png", diff --git a/source/manifest.rec.json b/source/manifest.rec.json index 522b755e..aec05209 100644 --- a/source/manifest.rec.json +++ b/source/manifest.rec.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ZAP by Checkmarx Recorder", - "version": "0.1.8", + "version": "0.1.9", "icons": { "16": "assets/icons/zap16x16.png", @@ -16,7 +16,8 @@ "incognito": "spanning", "permissions": [ - "storage" + "storage", + "tabs" ], "host_permissions": [ diff --git a/source/types/zestScript/ZestStatement.ts b/source/types/zestScript/ZestStatement.ts index cfbea65a..0d1d235d 100644 --- a/source/types/zestScript/ZestStatement.ts +++ b/source/types/zestScript/ZestStatement.ts @@ -27,7 +27,9 @@ import { ZEST_CLIENT_ELEMENT_SUBMIT, ZEST_CLIENT_LAUNCH, ZEST_CLIENT_SWITCH_TO_FRAME, + ZEST_ACTION_SLEEP, ZEST_CLIENT_WINDOW_CLOSE, + ZEST_CLIENT_WINDOW_HANDLE, ZEST_COMMENT, } from '../../utils/constants'; @@ -273,6 +275,50 @@ class ZestStatementWindowClose extends ZestStatement { } } +class ZestStatementWindowHandle extends ZestStatement { + windowHandle: string; + + url: string; + + regex: boolean; + + constructor(windowHandle: string, url: string, regex = false) { + super(ZEST_CLIENT_WINDOW_HANDLE); + this.windowHandle = windowHandle; + this.url = url; + this.regex = regex; + } + + toJSON(): string { + return JSON.stringify({ + windowHandle: this.windowHandle, + url: this.url, + regex: this.regex, + index: this.index, + enabled: true, + elementType: this.elementType, + }); + } +} + +class ZestStatementActionSleep extends ZestStatement { + milliseconds: number; + + constructor(milliseconds = 10000) { + super(ZEST_ACTION_SLEEP); + this.milliseconds = milliseconds; + } + + toJSON(): string { + return JSON.stringify({ + milliseconds: this.milliseconds, + index: this.index, + enabled: true, + elementType: this.elementType, + }); + } +} + class ZestStatementSwitchToFrame extends ZestStatement { frameIndex: number; @@ -329,4 +375,6 @@ export { ZestStatementElementSubmit, ZestStatementElementClear, ZestStatementWindowClose, + ZestStatementWindowHandle, + ZestStatementActionSleep, }; diff --git a/source/utils/constants.ts b/source/utils/constants.ts index bc5bbc82..b258feb3 100644 --- a/source/utils/constants.ts +++ b/source/utils/constants.ts @@ -32,12 +32,15 @@ export const ZEST_CLIENT_ELEMENT_SUBMIT = 'ZestClientElementSubmit'; export const ZEST_CLIENT_LAUNCH = 'ZestClientLaunch'; export const ZEST_CLIENT_ELEMENT_CLEAR = 'ZestClientElementClear'; export const ZEST_CLIENT_WINDOW_CLOSE = 'ZestClientWindowClose'; +export const ZEST_CLIENT_WINDOW_HANDLE = 'ZestClientWindowHandle'; export const ZEST_CLIENT_ELEMENT_MOUSE_OVER = 'ZestClientElementMouseOver'; +export const ZEST_ACTION_SLEEP = 'ZestActionSleep'; export const ZEST_COMMENT = 'ZestComment'; export const DEFAULT_WINDOW_HANDLE = 'windowHandle1'; export const ZAP_STOP_RECORDING = 'zapStopRecording'; export const ZAP_START_RECORDING = 'zapStartRecording'; +export const ZAP_REGISTER_POPUP = 'zapRegisterPopup'; export const ZEST_SCRIPT = 'zestScript'; export const STOP_RECORDING = 'stopRecording'; diff --git a/test/Background/unitTests.test.ts b/test/Background/unitTests.test.ts index a2779bbb..d217618a 100644 --- a/test/Background/unitTests.test.ts +++ b/test/Background/unitTests.test.ts @@ -22,6 +22,11 @@ */ import {TextEncoder, TextDecoder} from 'util'; import * as src from '../../source/Background/index'; +import { + DEFAULT_WINDOW_HANDLE, + RESET_ZEST_SCRIPT, + ZAP_REGISTER_POPUP, +} from '../../source/utils/constants'; console.log(src); console.log(TextEncoder); @@ -36,6 +41,8 @@ global.TextDecoder = TextDecoder as typeof global.TextDecoder; // eslint-disable-next-line import/order,import/first import Browser from 'webextension-polyfill'; +global.fetch = jest.fn().mockResolvedValue({ok: true}); + test('Report storage', () => { // Given @@ -59,3 +66,74 @@ test('Report storage', () => { expect(success).toBe(true); }); }); + +describe('ZAP_REGISTER_POPUP handler', () => { + // eslint-disable-next-line @typescript-eslint/ban-types + let onMessageHandler: Function; + + beforeAll(() => { + const addListenerMock = Browser.runtime.onMessage + .addListener as jest.Mock; + onMessageHandler = addListenerMock.mock.calls[0][0]; + }); + + beforeEach(async () => { + (Browser.storage.sync.get as jest.Mock).mockResolvedValue({ + zapurl: 'http://zap/', + zapkey: 'testkey', + zapenable: false, + }); + // Reset zest script state between tests (resets counter and popup map) + await onMessageHandler({type: RESET_ZEST_SCRIPT}, {}); + }); + + test('assigns a new window handle for a new popup tab', async () => { + const result = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup'}, + {tab: {id: 42}} + ); + expect(result).toBe('windowHandle2'); + }); + + test('returns cached handle for the same popup tab on repeated calls', async () => { + const first = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup'}, + {tab: {id: 99}} + ); + const second = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup'}, + {tab: {id: 99}} + ); + expect(first).toBe('windowHandle2'); + expect(second).toBe('windowHandle2'); + }); + + test('assigns different handles for different popup tabs', async () => { + const first = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup1'}, + {tab: {id: 10}} + ); + const second = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup2'}, + {tab: {id: 20}} + ); + expect(first).toBe('windowHandle2'); + expect(second).toBe('windowHandle3'); + }); + + test('returns default handle when sender has no tab id', async () => { + const result = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'https://example.com/popup'}, + {} + ); + expect(result).toBe(DEFAULT_WINDOW_HANDLE); + }); + + test('handles invalid popup URL gracefully', async () => { + const result = await onMessageHandler( + {type: ZAP_REGISTER_POPUP, url: 'not-a-valid-url'}, + {tab: {id: 55}} + ); + expect(result).toBe('windowHandle2'); + }); +}); diff --git a/test/ContentScript/unitTests.test.ts b/test/ContentScript/unitTests.test.ts index f384ba12..dc6f8018 100644 --- a/test/ContentScript/unitTests.test.ts +++ b/test/ContentScript/unitTests.test.ts @@ -29,6 +29,8 @@ import { ZestStatementElementClick, ZestStatementElementSendKeys, ZestStatementSwitchToFrame, + ZestStatementWindowHandle, + ZestStatementActionSleep, } from '../../source/types/zestScript/ZestStatement'; jest.mock('webextension-polyfill'); @@ -486,3 +488,48 @@ test('should generate valid frame switch statement', () => { '{"windowHandle":"windowHandle1","frameIndex":0,"frameName":"testvalue","parent":false,"index":-1,"enabled":true,"elementType":"ZestClientSwitchToFrame"}' ); }); + +test('should generate valid window handle statement', () => { + const stmt = new ZestStatementWindowHandle( + 'windowHandle2', + 'https://example\\.com.*', + true + ); + + expect(stmt.toJSON()).toBe( + '{"windowHandle":"windowHandle2","url":"https://example\\\\.com.*","regex":true,"index":-1,"enabled":true,"elementType":"ZestClientWindowHandle"}' + ); +}); + +test('should generate valid action sleep statement', () => { + const stmt = new ZestStatementActionSleep(10000); + + expect(stmt.toJSON()).toBe( + '{"milliseconds":10000,"index":-1,"enabled":true,"elementType":"ZestActionSleep"}' + ); +}); + +test('popup flow emits sleep before window handle in script', () => { + // This verifies the fix: ZestActionSleep must come before ZestClientWindowHandle + // so that ZAP waits for the popup to load before trying to locate it. + const script = new ZestScript('recordedScript'); + const sleepStmt = new ZestStatementActionSleep(10000); + const windowHandleStmt = new ZestStatementWindowHandle( + 'windowHandle2', + 'https://login\\.microsoftonline\\.com.*', + true + ); + + script.addStatement(sleepStmt.toJSON()); + script.addStatement(windowHandleStmt.toJSON()); + + const parsed = JSON.parse(script.toJSON()); + const {statements} = parsed; + + expect(statements).toHaveLength(2); + expect(statements[0].elementType).toBe('ZestActionSleep'); + expect(statements[0].milliseconds).toBe(10000); + expect(statements[1].elementType).toBe('ZestClientWindowHandle'); + expect(statements[1].windowHandle).toBe('windowHandle2'); + expect(statements[1].regex).toBe(true); +});