diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index a41fa4642..7e52f8591 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -23,9 +23,30 @@ jobs: os: [macos-13, windows-latest, ubuntu-latest] node-version: [18, 20] include: + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-123-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-122-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-121-0 - node-version: 20.x os: ubuntu-latest browser: chrome-120-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-119-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-118-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-117-0 + # - node-version: 20.x + # os: ubuntu-latest + # browser: chrome-116-0 - node-version: 20.x os: ubuntu-latest browser: chrome-115-0 diff --git a/agent/main/lib/Agent.ts b/agent/main/lib/Agent.ts index 53fbba0c3..3218e5a7c 100644 --- a/agent/main/lib/Agent.ts +++ b/agent/main/lib/Agent.ts @@ -10,7 +10,7 @@ import { IHooksProvider } from '@ulixee/unblocked-specification/agent/hooks/IHoo import IEmulationProfile, { IEmulationOptions, } from '@ulixee/unblocked-specification/plugin/IEmulationProfile'; -import { IUnblockedPluginClass, UnblockedPluginConfig } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin'; +import { IUnblockedPluginClass, PluginConfigs } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin'; import { nanoid } from 'nanoid'; import env from '../env'; import ICommandMarker from '../interfaces/ICommandMarker'; @@ -26,7 +26,7 @@ const { log } = Log(module); export interface IAgentCreateOptions extends Omit { id?: string; plugins?: IUnblockedPluginClass[]; - pluginConfigs?: UnblockedPluginConfig; + pluginConfigs?: PluginConfigs; commandMarker?: ICommandMarker; } diff --git a/agent/main/lib/Browser.ts b/agent/main/lib/Browser.ts index e99321434..74ac8a5e7 100755 --- a/agent/main/lib/Browser.ts +++ b/agent/main/lib/Browser.ts @@ -355,11 +355,14 @@ export default class Browser extends TypedEventEmitter implement private applyDefaultLaunchArgs(options: IBrowserUserConfig): void { const launchArgs = [ ...this.engine.launchArguments, - '--ignore-certificate-errors', '--no-startup-window', '--use-mock-keychain', // Use mock keychain on Mac to prevent blocking permissions dialogs ]; + if (!options.disableMitm) { + launchArgs.push('--ignore-certificate-errors'); + } + if (options.proxyPort !== undefined && !launchArgs.some(x => x.startsWith('--proxy-server'))) { launchArgs.push( // Use proxy for localhost URLs diff --git a/agent/main/lib/Page.ts b/agent/main/lib/Page.ts index b15605bd0..196d3034c 100755 --- a/agent/main/lib/Page.ts +++ b/agent/main/lib/Page.ts @@ -279,7 +279,7 @@ export default class Page extends TypedEventEmitter implements evaluate( expression: string, - options?: { timeoutMs?: number, isolatedFromWebPageEnvironment?: boolean }, + options?: { timeoutMs?: number; isolatedFromWebPageEnvironment?: boolean }, ): Promise { return this.mainFrame.evaluate(expression, options); } diff --git a/agent/main/lib/Pool.ts b/agent/main/lib/Pool.ts index 85fa1bc61..badbc8cfb 100644 --- a/agent/main/lib/Pool.ts +++ b/agent/main/lib/Pool.ts @@ -15,7 +15,7 @@ import IBrowserUserConfig from '@ulixee/unblocked-specification/agent/browser/IB import { IHooksProvider } from '@ulixee/unblocked-specification/agent/hooks/IHooks'; import { IUnblockedPluginClass, - UnblockedPluginConfig, + PluginConfigs, } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin'; import IEmulationProfile from '@ulixee/unblocked-specification/plugin/IEmulationProfile'; import IRegisteredEventListener from '@ulixee/commons/interfaces/IRegisteredEventListener'; @@ -31,7 +31,7 @@ interface ICreatePoolOptions { certificateStore?: ICertificateStore; defaultBrowserEngine?: IBrowserEngine; plugins?: IUnblockedPluginClass[]; - pluginConfigs?: UnblockedPluginConfig; + pluginConfigs?: PluginConfigs; dataDir?: string; logger?: IBoundLog; } @@ -56,7 +56,7 @@ export default class Pool extends TypedEventEmitter<{ public readonly agentsById = new Map(); public sharedMitmProxy: MitmProxy; public plugins: IUnblockedPluginClass[] = []; - public pluginConfigs: UnblockedPluginConfig = {}; + public pluginConfigs: PluginConfigs = {}; #activeAgentsCount = 0; #waitingForAvailability: { diff --git a/agent/main/lib/Worker.ts b/agent/main/lib/Worker.ts index 4578247d4..ac2ff5e26 100644 --- a/agent/main/lib/Worker.ts +++ b/agent/main/lib/Worker.ts @@ -132,14 +132,20 @@ export class Worker extends TypedEventEmitter implements IWorker return this.devtoolsSession.send('Runtime.runIfWaitingForDebugger'); } - return Promise.all([ - hooks.onNewWorker(this), - this.devtoolsSession.send('Debugger.enable'), - this.devtoolsSession.send('Debugger.setBreakpointByUrl', { - lineNumber: 0, - url: this.targetInfo.url, - }), - ]) + const isBlobWorker = this.targetInfo.url.startsWith('blob:'); + const promises = [hooks.onNewWorker(this)]; + + if (!isBlobWorker) { + promises.push( + this.devtoolsSession.send('Debugger.enable'), + this.devtoolsSession.send('Debugger.setBreakpointByUrl', { + lineNumber: 0, + url: this.targetInfo.url, + }), + ); + } + + return Promise.all(promises) .then(this.resumeAfterEmulation.bind(this)) .catch(async error => { if (error instanceof CanceledPromiseError) return; @@ -154,8 +160,8 @@ export class Worker extends TypedEventEmitter implements IWorker private resumeAfterEmulation(): Promise { return Promise.all([ - this.devtoolsSession.send('Runtime.runIfWaitingForDebugger'), this.devtoolsSession.send('Debugger.disable'), + this.devtoolsSession.send('Runtime.runIfWaitingForDebugger'), ]); } diff --git a/package.json b/package.json index 6cd9521ba..2c2d4091a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "watch": "yarn tsc && tsc -b -w tsconfig.json", "test": "yarn copy:build && yarn test:build", "test:build": "cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test jest", - "test:debug": "yarn build && yarn copy:build && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand", + "test:debug": "yarn build && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand", + "test:debug:fast": "yarn tsc && cd ./build && cross-env ULX_DATA_DIR=.data-test NODE_ENV=test node --inspect node_modules/.bin/jest --runInBand", "lint": "eslint --cache ./", "version:check": "ulx-repo-version-check fix", "version:bump": "ulx-repo-version-bump" diff --git a/plugins/default-browser-emulator/injected-scripts/.eslintrc.js b/plugins/default-browser-emulator/injected-scripts/.eslintrc.js index fed6aee58..7ebbac678 100644 --- a/plugins/default-browser-emulator/injected-scripts/.eslintrc.js +++ b/plugins/default-browser-emulator/injected-scripts/.eslintrc.js @@ -10,6 +10,15 @@ module.exports = { files: ['**/*.ts'], rules: { 'no-restricted-globals': 'off', + 'no-restricted-properties': [ + 'error', + ...Object.getOwnPropertyNames(Object).map(key => { + return { object: 'Object', property: key }; + }), + ...Object.getOwnPropertyNames(Reflect).map(key => { + return { object: 'Reflect', property: key }; + }), + ], 'no-proto': 'off', 'no-extend-native': 'off', 'no-inner-declarations': 'off', @@ -21,6 +30,7 @@ module.exports = { 'prefer-rest-params': 'off', 'func-names': 'off', 'no-console': 'off', + 'lines-around-directive': 'off', }, }, ], diff --git a/plugins/default-browser-emulator/injected-scripts/Document.prototype.cookie.ts b/plugins/default-browser-emulator/injected-scripts/Document.prototype.cookie.ts index d475c3f6a..34c665759 100644 --- a/plugins/default-browser-emulator/injected-scripts/Document.prototype.cookie.ts +++ b/plugins/default-browser-emulator/injected-scripts/Document.prototype.cookie.ts @@ -1,11 +1,18 @@ -const triggerName = args.callbackName; +export type Args = { + callbackName: string; +}; +const typedArgs = args as Args; +const triggerName = typedArgs.callbackName; if (!self[triggerName]) throw new Error('No cookie trigger'); -const cookieTrigger = ((self[triggerName] as unknown) as Function).bind(self); +const cookieTrigger = (self[triggerName] as unknown as Function).bind(self); delete self[triggerName]; -proxySetter(Document.prototype, 'cookie', (target, thisArg, cookie) => { - cookieTrigger(JSON.stringify({ cookie, origin: self.location.origin })); - return ProxyOverride.callOriginal; +replaceSetter(Document.prototype, 'cookie', (target, thisArg, argArray) => { + const cookie = argArray.at(0); + if (cookie) { + cookieTrigger(JSON.stringify({ cookie, origin: self.location.origin })); + } + return ReflectCached.apply(target, thisArg, argArray!); }); diff --git a/plugins/default-browser-emulator/injected-scripts/JSON.stringify.ts b/plugins/default-browser-emulator/injected-scripts/JSON.stringify.ts index 7bebdf9da..be3822045 100644 --- a/plugins/default-browser-emulator/injected-scripts/JSON.stringify.ts +++ b/plugins/default-browser-emulator/injected-scripts/JSON.stringify.ts @@ -1,9 +1,7 @@ -proxyFunction(JSON, 'stringify', (target, thisArg, argArray) => { - argArray[1] = null; - argArray[2] = 2; +export type Args = never; - const result = target.apply(thisArg, argArray); +replaceFunction(JSON, 'stringify', (target, thisArg, argArray) => { + const result = ReflectCached.apply(target, thisArg, [argArray.at(0), null, 2]); console.log(result); - return result; }); diff --git a/plugins/default-browser-emulator/injected-scripts/MediaDevices.prototype.enumerateDevices.ts b/plugins/default-browser-emulator/injected-scripts/MediaDevices.prototype.enumerateDevices.ts index 98d5a03f3..d55fb25f0 100644 --- a/plugins/default-browser-emulator/injected-scripts/MediaDevices.prototype.enumerateDevices.ts +++ b/plugins/default-browser-emulator/injected-scripts/MediaDevices.prototype.enumerateDevices.ts @@ -1,16 +1,22 @@ +export type Args = { + deviceId?: string; + groupId?: string +}; +const typedArgs = args as Args; + if ( navigator.mediaDevices && navigator.mediaDevices.enumerateDevices && navigator.mediaDevices.enumerateDevices.name !== 'bound reportBlock' ) { const videoDevice = { - deviceId: args.deviceId, - groupId: args.groupId, + deviceId: typedArgs.deviceId, + groupId: typedArgs.groupId, kind: 'videoinput', label: '', }; - proxyFunction(MediaDevices.prototype, 'enumerateDevices', (func, thisObj, ...args) => { - return func.apply(thisObj, args).then(list => { + replaceFunction(MediaDevices.prototype, 'enumerateDevices', (target, thisArg, argArray) => { + return (ReflectCached.apply(target, thisArg, argArray) as Promise).then(list => { if (list.find(x => x.kind === 'videoinput')) return list; list.push(videoDevice); return list; diff --git a/plugins/default-browser-emulator/injected-scripts/RTCRtpSender.getCapabilities.ts b/plugins/default-browser-emulator/injected-scripts/RTCRtpSender.getCapabilities.ts index 58e10216c..f27b8e70e 100644 --- a/plugins/default-browser-emulator/injected-scripts/RTCRtpSender.getCapabilities.ts +++ b/plugins/default-browser-emulator/injected-scripts/RTCRtpSender.getCapabilities.ts @@ -1,8 +1,13 @@ -// @ts-ignore -const { audioCodecs, videoCodecs } = args; +export type Args = { + audioCodecs: any; + videoCodecs: any; +}; +const typedArgs = args as Args; + +const { audioCodecs, videoCodecs } = typedArgs; if ('RTCRtpSender' in self && RTCRtpSender.prototype) { - proxyFunction(RTCRtpSender, 'getCapabilities', function (target, thisArg, argArray) { + replaceFunction(RTCRtpSender, 'getCapabilities', function (target, thisArg, argArray) { const kind = argArray && argArray.length ? argArray[0] : null; const args = kind ? [kind] : undefined; const capabilities = target.apply(thisArg, args); diff --git a/plugins/default-browser-emulator/injected-scripts/SharedWorker.prototype.ts b/plugins/default-browser-emulator/injected-scripts/SharedWorker.prototype.ts index fe034351f..aee30c4f0 100644 --- a/plugins/default-browser-emulator/injected-scripts/SharedWorker.prototype.ts +++ b/plugins/default-browser-emulator/injected-scripts/SharedWorker.prototype.ts @@ -1,26 +1,52 @@ +export type Args = never; + if (typeof SharedWorker === 'undefined') { // @ts-ignore return; } +const OriginalSharedWorker = SharedWorker; +const originalSharedWorkerProperties = ObjectCached.getOwnPropertyDescriptors(SharedWorker); + // shared workers created from blobs don't automatically pause in devtools, so we have to manipulate -proxyConstructor(self, 'SharedWorker', (target, argArray) => { - if (!argArray?.length) return ReflectCached.construct(target, argArray); +ObjectCached.defineProperty(self, 'SharedWorker', { + // eslint-disable-next-line object-shorthand + value: function SharedWorker(this, scriptURL, options) { + // eslint-disable-next-line strict + 'use strict'; + if (!new.target) { + return ReflectCached.apply(OriginalSharedWorker, this, [scriptURL, options]); + } - const [url] = argArray; - if (!url?.toString().startsWith('blob:')) { - return ReflectCached.construct(target, argArray); - } + let isBlob = false; + try { + isBlob = scriptURL?.toString().startsWith('blob:'); + } catch {} + if (!isBlob) { + return ReflectCached.construct(OriginalSharedWorker, [scriptURL, options], new.target); + } - // read blob contents synchronously - const xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); - xhr.send(); - const text = xhr.response; + // read blob contents synchronously + const xhr = new XMLHttpRequest(); + xhr.open('GET', scriptURL, false); + xhr.send(); + const text = xhr.response; + + const script = createScript(text); + + const newBlob = new Blob([script]); + return ReflectCached.construct(OriginalSharedWorker, [URL.createObjectURL(newBlob), options], new.target); + }, +}); +ObjectCached.defineProperties(SharedWorker, originalSharedWorkerProperties); +SharedWorker.prototype.constructor = SharedWorker; +toOriginalFn.set(SharedWorker, OriginalSharedWorker); + +function createScript(originalScript: string) { const script = ` function original() { - ${text}; + ${originalScript}; } (async function runWhenReady() { @@ -34,37 +60,33 @@ proxyConstructor(self, 'SharedWorker', (target, argArray) => { setTimeout(()=> { removeEventListener('connect', storeEvent); original(); - events.forEach(ev => onconnect(ev)); + events.forEach(ev => dispatchEvent(ev)); delete events; }, 0); } - function isInjectedDone() { + // See proxyUtils + function getSharedStorage() { try { - // We can use this to check if injected logic is loaded - return Error['${sourceUrl}']; + return Function.prototype.toString('${sourceUrl}'); } catch { - return false; + return undefined; } } - - if (isInjectedDone()) { + if (getSharedStorage()?.ready) { originalAsSync(); return; } // Keep checking until we are ready const interval = setInterval(() => { - if (!isInjectedDone()) { - return + if (getSharedStorage()?.ready) { + clearInterval(interval); + originalAsSync(); } - clearInterval(interval); - originalAsSync(); }, 20); })() `; - - const newBlob = new Blob([script]); - return ReflectCached.construct(target, [URL.createObjectURL(newBlob)]); -}); + return script; +} diff --git a/plugins/default-browser-emulator/injected-scripts/UnhandledErrorsAndRejections.ts b/plugins/default-browser-emulator/injected-scripts/UnhandledErrorsAndRejections.ts index 37deef425..e8d42d16c 100644 --- a/plugins/default-browser-emulator/injected-scripts/UnhandledErrorsAndRejections.ts +++ b/plugins/default-browser-emulator/injected-scripts/UnhandledErrorsAndRejections.ts @@ -1,29 +1,40 @@ -self.addEventListener('error', preventDefault); -self.addEventListener('unhandledrejection', preventDefault); +export type Args = { + preventDefaultUncaughtError: boolean; + preventDefaultUnhandledRejection: boolean; +}; + +const typedArgs = args as Args; + +if (typedArgs.preventDefaultUncaughtError) { + self.addEventListener('error', preventDefault); +} +if (typedArgs.preventDefaultUnhandledRejection) { + self.addEventListener('unhandledrejection', preventDefault); +} function preventDefault(event: ErrorEvent | PromiseRejectionEvent) { + let prevented = event.defaultPrevented; event.preventDefault(); // Hide this, but make sure if they hide it we mimic normal behaviour - let prevented = event.defaultPrevented; - proxyFunction( + replaceFunction( event, 'preventDefault', - (originalFunction, thisArg, argArray) => { + (target, thisArg, argArray) => { // Will raise correct error if 'thisArg' is wrong - ReflectCached.apply(originalFunction, thisArg, argArray); + ReflectCached.apply(target, thisArg, argArray); prevented = true; }, - true, + { onlyForInstance: true }, ); - proxyGetter( + replaceGetter( event, 'defaultPrevented', - (target, thisArg) => { - ReflectCached.get(target, thisArg); + (target, thisArg, argArray) => { + ReflectCached.apply(target, thisArg, argArray); return prevented; }, - true, + { onlyForInstance: true }, ); if (!('console' in self)) { diff --git a/plugins/default-browser-emulator/injected-scripts/WebGLRenderingContext.prototype.getParameter.ts b/plugins/default-browser-emulator/injected-scripts/WebGLRenderingContext.prototype.getParameter.ts index d09108460..1171ca690 100644 --- a/plugins/default-browser-emulator/injected-scripts/WebGLRenderingContext.prototype.getParameter.ts +++ b/plugins/default-browser-emulator/injected-scripts/WebGLRenderingContext.prototype.getParameter.ts @@ -1,12 +1,16 @@ +export type Args = Record; +const typedArgs = args as Args; + const activatedDebugInfo = new WeakSet(); +const ReflectCachedHere = ReflectCached; for (const context of [ self.WebGLRenderingContext.prototype, self.WebGL2RenderingContext.prototype, ]) { - proxyFunction(context, 'getExtension', function (originalFunction, thisArg, argArray) { - const result = Reflect.apply(originalFunction, thisArg, argArray); - if (argArray?.[0] === 'WEBGL_debug_renderer_info') { + replaceFunction(context, 'getExtension', function (target, thisArg, argArray) { + const result = ReflectCachedHere.apply(target, thisArg, argArray) as any; + if (argArray.at(0) === 'WEBGL_debug_renderer_info') { activatedDebugInfo.add(thisArg); } @@ -14,16 +18,16 @@ for (const context of [ }); // eslint-disable-next-line @typescript-eslint/no-loop-func - proxyFunction(context, 'getParameter', function (originalFunction, thisArg, argArray) { + replaceFunction(context, 'getParameter', function (target, thisArg, argArray) { const parameter = argArray && argArray.length ? argArray[0] : null; // call api to make sure signature goes through - const result = ReflectCached.apply(originalFunction, thisArg, argArray); - if (args[parameter]) { + const result = ReflectCached.apply(target, thisArg, argArray); + if (typedArgs[parameter]) { if (!result && !activatedDebugInfo.has(context)) { return result; } - return args[parameter]; + return typedArgs[parameter]; } return result; }); diff --git a/plugins/default-browser-emulator/injected-scripts/_descriptorBuilder.ts b/plugins/default-browser-emulator/injected-scripts/_descriptorBuilder.ts index 5b7c46872..8253303b1 100644 --- a/plugins/default-browser-emulator/injected-scripts/_descriptorBuilder.ts +++ b/plugins/default-browser-emulator/injected-scripts/_descriptorBuilder.ts @@ -10,7 +10,7 @@ for (const symbol of ReflectCached.ownKeys(Symbol)) { function createError(message: string, type?: { new (msg: string): any }) { if (!type) { const match = nativeErrorRegex.exec(message); - if (match.length) { + if (match?.length) { message = message.replace(`${match[1]}: `, ''); try { type = self[match[1]]; @@ -45,9 +45,10 @@ function newObjectConstructor( if (typeof invocation === 'function') return invocation(...arguments); return invocationReturnOrThrow(invocation, isAsync); } - const props = Object.entries(newProps); + const props = ObjectCached.entries(newProps); const obj = {}; - Object.setPrototypeOf( + if (!newProps._$protos) throw new Error('newProps._$protos undefined'); + ObjectCached.setPrototypeOf( obj, prototypesByPath[newProps._$protos[0]] ?? getObjectAtPath(newProps._$protos[0]), ); @@ -55,9 +56,9 @@ function newObjectConstructor( if (prop.startsWith('_$')) continue; let propName: string | symbol = prop; if (propName.startsWith('Symbol(')) { - propName = Symbol.for(propName.match(/Symbol\((.+)\)/)[1]); + propName = Symbol.for(propName.match(/Symbol\((.+)\)/)![1]); } - Object.defineProperty(obj, propName, buildDescriptor(value, `${path}.${prop}`)); + ObjectCached.defineProperty(obj, propName, buildDescriptor(value, `${path}.${prop}`)); } return obj; }; @@ -80,7 +81,8 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { if (entry['_$$value()']) return entry['_$$value()'](); }, }); - overriddenFns.set(attrs.get, entry._$get); + overriddenFns.set(attrs.get!, entry._$get); + toOriginalFn.set(attrs.get!, entry._$get); } else if (entry['_$$value()']) { attrs.value = entry['_$$value()'](); } else if (entry._$value !== undefined) { @@ -91,10 +93,11 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { attrs.set = new Proxy(Function.prototype.call.bind({}), { apply() {}, }); - overriddenFns.set(attrs.set, entry._$set); + overriddenFns.set(attrs.set!, entry._$set); + toOriginalFn.set(attrs.set!, entry._$set); } - let prototypeDescriptor: PropertyDescriptor; + let prototypeDescriptor: PropertyDescriptor | undefined; if (entry.prototype) { prototypeDescriptor = buildDescriptor(entry.prototype, `${path}.prototype`); @@ -103,6 +106,7 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { } if (entry._$function) { overriddenFns.set(prototypeDescriptor.value.constructor, entry._$function); + toOriginalFn.set(attrs.set!, entry._$set); } prototypesByPath[`${path}.prototype`] = prototypeDescriptor.value; } @@ -113,10 +117,10 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { if (newProps) { attrs.value = newObjectConstructor(newProps, path, entry._$invocation, entry._$isAsync); } else { - Object.keys(entry) + ObjectCached.keys(entry) .filter((key): key is OtherInvocationKey => key.startsWith('_$otherInvocation')) // Not supported currently - .filter((key)=> !key.includes('new()')) + .filter(key => !key.includes('new()')) .forEach(key => OtherInvocationsTracker.addOtherInvocation(path, key, entry[key])); // use function call just to get a function that doesn't create prototypes on new @@ -131,13 +135,13 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { }); } if (entry._$invocation !== undefined) { - Object.setPrototypeOf(attrs.value, Function.prototype); + ObjectCached.setPrototypeOf(attrs.value, Function.prototype); delete attrs.value.prototype; delete attrs.value.constructor; } if (prototypeDescriptor && newProps) { - Object.defineProperty(prototypeDescriptor.value, 'constructor', { + ObjectCached.defineProperty(prototypeDescriptor.value, 'constructor', { value: attrs.value, writable: true, enumerable: false, @@ -145,16 +149,17 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { }); } overriddenFns.set(attrs.value, entry._$function); + toOriginalFn.set(attrs.value, entry._$function); } if (typeof entry === 'object') { - const props = Object.entries(entry).filter(([prop]) => !prop.startsWith('_$')); + const props = ObjectCached.entries(entry).filter(([prop]) => !prop.startsWith('_$')); if (!attrs.value && (props.length || entry._$protos)) { attrs.value = {}; } if (entry._$protos) { const proto = prototypesByPath[entry._$protos[0]] ?? getObjectAtPath(entry._$protos[0]); - attrs.value = Object.setPrototypeOf(attrs.value, proto); + attrs.value = ObjectCached.setPrototypeOf(attrs.value, proto); } for (const [prop, value] of props) { @@ -164,17 +169,17 @@ function buildDescriptor(entry: IDescriptor, path: string): PropertyDescriptor { if (propName.startsWith('Symbol(')) { propName = globalSymbols[propName]; if (!propName) { - const symbolName = (propName as string).match(/Symbol\((.+)\)/)[1]; + const symbolName = (propName as string).match(/Symbol\((.+)\)/)![1]; propName = Symbol.for(symbolName); } } let descriptor: PropertyDescriptor; if (propName === 'prototype') { - descriptor = prototypeDescriptor; + descriptor = prototypeDescriptor!; } else { descriptor = buildDescriptor(value, `${path}.${prop}`); } - Object.defineProperty(attrs.value, propName, descriptor); + ObjectCached.defineProperty(attrs.value, propName, descriptor); } } @@ -197,9 +202,10 @@ function breakdownPath(path: string, propsToLeave) { const parts = path.split(/\.Symbol\(([\w.]+)\)|\.(\w+)/).filter(Boolean); let obj: any = self; while (parts.length > propsToLeave) { - let next: string | symbol = parts.shift(); + let next: string | symbol | undefined = parts.shift(); + if (next === undefined) throw new Error('Reached end of parts without finding obj'); if (next === 'window') continue; - if (next.startsWith('Symbol.')) next = Symbol.for(next); + if (next?.startsWith('Symbol.')) next = Symbol.for(next); obj = obj[next]; if (!obj) { throw new Error(`Property not found -> ${path} at ${String(next)}`); @@ -264,7 +270,9 @@ class PathToInstanceTracker { } private static getInstanceForPath(path: string) { - const { parent, property } = getParentAndProperty(path); + const result = getParentAndProperty(path); + if (!result) throw new Error('no parent and property found'); + const { parent, property } = result; return parent[property]; } } @@ -283,11 +291,7 @@ class OtherInvocationsTracker { { invocation: any; isAsync: boolean } >(); - static addOtherInvocation( - basePath: string, - otherKey: OtherInvocationKey, - otherInvocation: any, - ) { + static addOtherInvocation(basePath: string, otherKey: OtherInvocationKey, otherInvocation: any) { const [invocationKey, ...otherParts] = otherKey.split('.'); // Remove key/property from path const otherPath = otherParts.slice(0, -1).join('.'); @@ -303,7 +307,7 @@ class OtherInvocationsTracker { static getOtherInvocation( basePath: string, otherThis: any, - ): { invocation: any; path: string; isAsync: boolean } { + ): { invocation: any; path: string; isAsync?: boolean } | undefined { const otherPath = PathToInstanceTracker.getPath(otherThis); if (!otherPath) { return; diff --git a/plugins/default-browser-emulator/injected-scripts/_proxyUtils.ts b/plugins/default-browser-emulator/injected-scripts/_proxyUtils.ts index d188c51aa..55443a28f 100644 --- a/plugins/default-browser-emulator/injected-scripts/_proxyUtils.ts +++ b/plugins/default-browser-emulator/injected-scripts/_proxyUtils.ts @@ -1,14 +1,14 @@ -/////// MASK TO STRING //////////////////////////////////////////////////////////////////////////////////////////////// +// Injected by DomOverridesBuilder +declare let sourceUrl: string; +declare let targetType: string | undefined; +declare let args: any; -// eslint-disable-next-line prefer-const -- must be let: could change for different browser (ie, Safari) -let nativeToStringFunctionString = `${Function.toString}`; -// when functions are re-bound to work around the loss of scope issue in chromium, they blow up their native toString -// Store undefined value to register overrides, but still keep native toString logic -const overriddenFns = new Map(); -const proxyToTarget = new WeakMap(); +/* eslint-disable no-restricted-properties */ -// From puppeteer-stealth: this is to prevent someone snooping at Reflect calls -const ReflectCached = { +const ReflectCached: Pick< + typeof Reflect, + 'construct' | 'get' | 'set' | 'apply' | 'setPrototypeOf' | 'ownKeys' | 'getOwnPropertyDescriptor' +> = { construct: Reflect.construct.bind(Reflect), get: Reflect.get.bind(Reflect), set: Reflect.set.bind(Reflect), @@ -19,19 +19,176 @@ const ReflectCached = { }; const ErrorCached = Error; - -const ObjectCached = { +const ObjectCached: Pick< + ObjectConstructor, + | 'setPrototypeOf' + | 'getPrototypeOf' + | 'getOwnPropertyNames' + | 'defineProperty' + | 'defineProperties' + | 'create' + | 'entries' + | 'values' + | 'keys' + | 'getOwnPropertyDescriptors' + | 'getOwnPropertyDescriptor' + | 'hasOwn' + | 'seal' + | 'freeze' +> = { setPrototypeOf: Object.setPrototypeOf.bind(Object), getPrototypeOf: Object.getPrototypeOf.bind(Object), defineProperty: Object.defineProperty.bind(Object), + defineProperties: Object.defineProperties.bind(Object), create: Object.create.bind(Object), entries: Object.entries.bind(Object), values: Object.values.bind(Object), keys: Object.keys.bind(Object), getOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), getOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object), + getOwnPropertyNames: Object.getOwnPropertyNames.bind(Object), + hasOwn: Object.hasOwn.bind(Object), + seal: Object.seal.bind(Object), + freeze: Object.freeze.bind(Object), +}; + +/* eslint-enable no-restricted-properties */ + +// We don't always have the original function (eg when creating a polyfil). In those +// cases we store toString instead. +const toOriginalFn = new Map(); + +type NewFunction = (target: any, thisArg: any, argArray: any[]) => any; + +type ModifyDescriptorOpts = { + descriptorKey: 'value' | 'get' | 'set'; + onlyForInstance?: Boolean; }; +function internalModifyDescriptor( + obj: T, + key: K, + newFunction: NewFunction, + opts: ModifyDescriptorOpts, +) { + const descriptorInHierarch = getDescriptorInHierarchy(obj, key); + if (!descriptorInHierarch) throw new Error('prototype descriptor not found'); + + const { descriptor, descriptorOwner } = descriptorInHierarch; + const target = descriptor[opts.descriptorKey]; + + const newDescriptor = { + ...descriptor, + // Keep function signature like this or we will mess up property descriptors (fn(){} != fn: function(){} != fn: ()=>{}) + [opts.descriptorKey](this: any, ...argArray) { + // Make sure our prototypes match, if they dont either stuff has been modified, or we are doing + // logic accross frames. In both cases forward logic to more specific targetFn. + if (opts.descriptorKey === 'value') { + const expectedFnProto = ObjectCached.getPrototypeOf(target); + let receivedFnProto = expectedFnProto; + try { + receivedFnProto = ObjectCached.getPrototypeOf(this[key]); + } catch {} + if (expectedFnProto !== receivedFnProto) { + return ReflectCached.apply(this[key], this, argArray); + } + } + + // onlyForInstance is needed so we can edit prototypes but only change behaviour for a single instance. + // Editing prototypes is needed, because otherwise they can detect we changed this by checking instance === prototype. + if (opts?.onlyForInstance && this !== obj) { + return ReflectCached.apply(target, this, argArray); + } + return newFunction(target, this, argArray); + }, + }; + + // Get all descriptors of original function (get set is also a function internally) + const originalValueDescriptors = ObjectCached.getOwnPropertyDescriptors( + ObjectCached.getOwnPropertyDescriptor(descriptor, opts.descriptorKey)!.value, + ); + // By defining these again we make sure we have all the correct properties set (eg name) + ObjectCached.defineProperties(newDescriptor[opts.descriptorKey], originalValueDescriptors); + ObjectCached.defineProperty(descriptorOwner, key, newDescriptor); + + toOriginalFn.set(newDescriptor[opts.descriptorKey], target); + + return newDescriptor; +} + +function replaceFunction( + obj: T, + key: K, + newFunction: NewFunction, + opts: Omit = {}, +) { + return internalModifyDescriptor(obj, key, newFunction, { ...opts, descriptorKey: 'value' }); +} + +function replaceGetter( + obj: T, + key: K, + newFunction: NewFunction, + opts: Omit = {}, +) { + return internalModifyDescriptor(obj, key, newFunction, { ...opts, descriptorKey: 'get' }); +} + +function replaceSetter( + obj: T, + key: K, + newFunction: NewFunction, + opts: Omit = {}, +) { + return internalModifyDescriptor(obj, key, newFunction, { ...opts, descriptorKey: 'set' }); +} + +// Can be empty in some tests +const hiddenKey = typeof sourceUrl === 'string' ? sourceUrl : 'testing'; +// Storage shared between multiple runs within the same frame/page/worker... +// Needed to sync in some cases where our scripts run multiple times. +type SharedStorage = { ready: boolean }; +const sharedStorage: SharedStorage = { ready: false }; + +replaceFunction(Function.prototype, 'toString', (target, thisArg, argArray) => { + if (argArray.at(0) === hiddenKey) return sharedStorage; + const originalFn = toOriginalFn.get(thisArg); + if (typeof originalFn === 'string') return originalFn; + return ReflectCached.apply(target, originalFn ?? thisArg, argArray); +}); + +function getSharedStorage(): SharedStorage | undefined { + try { + return (Function.prototype.toString as any)(sourceUrl) as SharedStorage; + } catch { + return undefined; + } +} + +// Make sure we run our logic only once, see toString proxy for how this storage works. +if (getSharedStorage()?.ready) { + // @ts-expect-error + return; +} + +if (typeof module === 'object' && typeof module.exports === 'object') { + module.exports = { + proxyFunction, + replaceFunction, + }; +} + +/////// MASK TO STRING //////////////////////////////////////////////////////////////////////////////////////////////// + +// eslint-disable-next-line prefer-const -- must be let: could change for different browser (ie, Safari) +let nativeToStringFunctionString = `${Function.toString}`; +// when functions are re-bound to work around the loss of scope issue in chromium, they blow up their native toString +// Store undefined value to register overrides, but still keep native toString logic +const overriddenFns = new Map(); +const proxyToTarget = new WeakMap(); + +// From puppeteer-stealth: this is to prevent someone snooping at Reflect calls + // Store External proxies as undefined so we can treat it as just missing const proxyThisTracker = new Map | any>([['External', undefined]]); @@ -71,52 +228,37 @@ function runAndInjectProxyInStack(target: any, thisArg: any, argArray: any, prox (function trackProxyInstances() { if (typeof self === 'undefined') return; const descriptor = ObjectCached.getOwnPropertyDescriptor(self, 'Proxy'); - const toString = descriptor.value.toString(); - descriptor.value = new Proxy(descriptor.value, { - construct(target: any, argArray: any[], newTarget: Function): object { - const result = ReflectCached.construct(target, argArray, newTarget); - if (argArray?.length) proxyToTarget.set(result, argArray[0]); + if (!descriptor) return; + + const OriginalProxy = Proxy; + const originalProxyProperties = ObjectCached.getOwnPropertyDescriptors(Proxy); + + ObjectCached.defineProperty(self, 'Proxy', { + // eslint-disable-next-line object-shorthand + value: function Proxy(this, target, handler) { + // eslint-disable-next-line strict + 'use strict'; + if (!new.target) { + return ReflectCached.apply(OriginalProxy, this, [target, handler]); + } + + const result = ReflectCached.construct(OriginalProxy, [target, handler], new.target); + if (target && typeof target === 'object') proxyToTarget.set(result, target); return result; }, }); - overriddenFns.set(descriptor.value, toString); - ObjectCached.defineProperty(self, 'Proxy', descriptor); -})(); -const fnToStringDescriptor = ObjectCached.getOwnPropertyDescriptor(Function.prototype, 'toString'); -const fnToStringProxy = internalCreateFnProxy({ - target: Function.prototype.toString, - descriptor: fnToStringDescriptor, - inner: { - apply: (target, thisArg, args) => { - const storedToString = overriddenFns.get(thisArg); - if (storedToString) { - return storedToString; - } - if (thisArg !== null && thisArg !== undefined) { - // from puppeteer-stealth: Check if the toString prototype of the context is the same as the global prototype, - // if not indicates that we are doing a check across different windows - const hasSameProto = ObjectCached.getPrototypeOf(Function.prototype.toString).isPrototypeOf( - thisArg.toString, - ); - if (hasSameProto === false) { - // Pass the call on to the local Function.prototype.toString instead - return thisArg.toString(...(args ?? [])); - } - } + ObjectCached.defineProperties(Proxy, originalProxyProperties); + Proxy.prototype.constructor = Proxy; + toOriginalFn.set(Proxy, OriginalProxy); +})(); - return runAndInjectProxyInStack(target, thisArg, args, thisArg); - }, - }, -}); -ObjectCached.defineProperty(Function.prototype, 'toString', { - ...fnToStringDescriptor, - value: fnToStringProxy, -}); /////// END TOSTRING ////////////////////////////////////////////////////////////////////////////////////////////////// +// TODO remove this, but make sure we clean this up in: +// hero/core/injected-scripts/domOverride_openShadowRoots.ts enum ProxyOverride { callOriginal = '_____invoke_original_____', } @@ -124,35 +266,28 @@ enum ProxyOverride { function proxyConstructor( owner: T, key: K, - overrideFn: ( - target?: T[K], - argArray?: T[K] extends new (...args: infer P) => any ? P : never[], - newTarget?: T[K], - ) => (T[K] extends new () => infer Z ? Z : never) | ProxyOverride, + overrideFn: typeof ReflectCached.construct, ) { const descriptor = ObjectCached.getOwnPropertyDescriptor(owner, key); + if (!descriptor) throw new Error(`Descriptor with key ${String(key)} not found`); + const toString = descriptor.value.toString(); descriptor.value = new Proxy(descriptor.value, { - construct() { - const result = overrideFn(...arguments); - if (result !== ProxyOverride.callOriginal) { - return result as any; - } - - return ReflectCached.construct(...arguments); + construct(target, argArray, newTarget) { + return overrideFn(target, argArray, newTarget); }, }); overriddenFns.set(descriptor.value, toString); ObjectCached.defineProperty(owner, key, descriptor); } -const setProtoTracker = new WeakSet(); +const setProtoTracker = new WeakSet(); -function internalCreateFnProxy(opts: { +function internalCreateFnProxy(opts: { target: T; descriptor?: any; custom?: ProxyHandler; - inner?: ProxyHandler & { disableGetProxyOnFunction?: boolean }; + inner?: ProxyHandler & { fixThisArg?: boolean }; disableStoreToString?: boolean; }) { function apply(target: any, thisArg: any, argArray: any[]) { @@ -166,7 +301,7 @@ function internalCreateFnProxy(opts: { let protoTarget = newPrototype; let newPrototypeProto; try { - newPrototypeProto = Object.getPrototypeOf(newPrototype); + newPrototypeProto = ObjectCached.getPrototypeOf(newPrototype); } catch {} if (newPrototype === proxy || newPrototypeProto === proxy) { protoTarget = target; @@ -176,7 +311,7 @@ function internalCreateFnProxy(opts: { ErrorCached.captureStackTrace(temp); const stack = temp.stack.split('\n'); - const isFromReflect = stack.at(1).includes('Reflect.setPrototypeOf'); + const isFromReflect = stack.at(1)?.includes('Reflect.setPrototypeOf'); try { const caller = isFromReflect ? ReflectCached : ObjectCached; return caller.setPrototypeOf(target, protoTarget); @@ -199,11 +334,20 @@ function internalCreateFnProxy(opts: { ? opts.inner.get(target, p, receiver) : ReflectCached.get(target, p, receiver); - if (typeof value === 'function' && !opts.inner.disableGetProxyOnFunction) { + if (typeof value === 'function') { return internalCreateFnProxy({ target: value, inner: { + fixThisArg: opts.inner?.fixThisArg, apply: (fnTarget, fnThisArg, fnArgArray) => { + // Make sure we use the correct thisArg, but only for things that we didn't mean to replace + // overriddenFns.has(fnTarget); + // const proto = getPrototypeSafe(fnThisArg); + // const shouldHide = + // overriddenFns.has(fnThisArg) || proto ? overriddenFns.has(proto) : false; + if (opts.inner?.fixThisArg && fnThisArg === receiver) { + return runAndInjectProxyInStack(fnTarget, target, fnArgArray, proxy); + } return runAndInjectProxyInStack(fnTarget, fnThisArg, fnArgArray, proxy); }, }, @@ -223,7 +367,7 @@ function internalCreateFnProxy(opts: { const result = opts.inner?.set ? opts.inner.set(target, p, value, receiver) - : ReflectCached.set(...arguments); + : ReflectCached.set(target, p, value, receiver); return result; } @@ -237,20 +381,22 @@ function internalCreateFnProxy(opts: { if (proxy instanceof Function) { const toString = overriddenFns.get(opts.target as Function) ?? opts.target.toString(); overriddenFns.set(proxy, toString); + toOriginalFn.set(proxy, toString); } return proxy as any; } +type OverrideFn = (target: Function, thisArg: any, argArray: any[]) => any; + function proxyFunction( thisObject: T, functionName: K, - overrideFn: ( - target?: T[K], - thisArg?: T, - argArray?: T[K] extends (...args: infer P) => any ? P : never[], - ) => (T[K] extends (...args: any[]) => infer Z ? Z : never) | ProxyOverride, - overrideOnlyForInstance = false, + overrideFn: OverrideFn, + opts?: { + overrideOnlyForInstance?: boolean; + fixThisArg?: boolean; + }, ) { const descriptorInHierarchy = getDescriptorInHierarchy(thisObject, functionName); if (!descriptorInHierarchy) { @@ -263,10 +409,13 @@ function proxyFunction( descriptor, inner: { apply: (target, thisArg, argArray) => { - const shouldOverride = overrideOnlyForInstance === false || thisArg === thisObject; - const overrideFnToUse = shouldOverride ? overrideFn : null; - return defaultProxyApply([target, thisArg, argArray], overrideFnToUse); + const shouldOverride = !opts?.overrideOnlyForInstance || thisArg === thisObject; + if (shouldOverride) { + return overrideFn(target, thisArg, argArray); + } + return ReflectCached.apply(target, thisArg, argArray); }, + fixThisArg: opts?.fixThisArg, }, }); return thisObject[functionName]; @@ -275,8 +424,8 @@ function proxyFunction( function proxyGetter( thisObject: T, propertyName: K, - overrideFn: (target?: T[K], thisArg?: T) => T[K] | ProxyOverride, - overrideOnlyForInstance = false, + overrideFn: OverrideFn, + opts?: { overrideOnlyForInstance?: boolean }, ) { const descriptorInHierarchy = getDescriptorInHierarchy(thisObject, propertyName); if (!descriptorInHierarchy) { @@ -284,15 +433,22 @@ function proxyGetter( } const { descriptorOwner, descriptor } = descriptorInHierarchy; + if (!descriptor.get) { + throw new Error('Trying to apply a proxy getter on something that doesnt have a getter'); + } descriptor.get = internalCreateFnProxy({ target: descriptor.get, descriptor, inner: { apply: (target, thisArg, argArray) => { - const shouldOverride = overrideOnlyForInstance === false || thisArg === thisObject; - const overrideFnToUse = shouldOverride ? overrideFn : null; - return defaultProxyApply([target, thisArg, argArray], overrideFnToUse); + const shouldOverride = !opts?.overrideOnlyForInstance || thisArg === thisObject; + if (shouldOverride) { + const result = overrideFn(target, thisArg, argArray); + // TODO remove + if (result !== ProxyOverride.callOriginal) return result; + } + return ReflectCached.apply(target, thisArg, argArray); }, }, }); @@ -303,26 +459,26 @@ function proxyGetter( function proxySetter( thisObject: T, propertyName: K, - overrideFn: ( - target?: T[K], - thisArg?: T, - value?: T[K] extends (value: infer P) => any ? P : never, - ) => void | ProxyOverride, - overrideOnlyForInstance = false, + overrideFn: OverrideFn, + opts?: { overrideOnlyForInstance?: boolean }, ) { const descriptorInHierarchy = getDescriptorInHierarchy(thisObject, propertyName); if (!descriptorInHierarchy) { throw new Error(`Could not find descriptor for setter: ${String(propertyName)}`); } const { descriptorOwner, descriptor } = descriptorInHierarchy; + if (!descriptor.set) { + throw new Error('Trying to apply a proxy setter on something that doesnt have a setter'); + } + descriptor.set = internalCreateFnProxy({ target: descriptor.set, descriptor, inner: { apply: (target, thisArg, argArray) => { - if (!overrideOnlyForInstance || thisArg === thisObject) { - const result = overrideFn(target, thisArg, ...argArray); - if (result !== ProxyOverride.callOriginal) return result; + const shouldOverride = !opts?.overrideOnlyForInstance || thisArg === thisObject; + if (shouldOverride) { + return overrideFn(target, thisArg, argArray); } return ReflectCached.apply(target, thisArg, argArray); }, @@ -332,30 +488,15 @@ function proxySetter( return descriptor.set; } -function defaultProxyApply( - args: [target: any, thisArg: T, argArray: any[]], - overrideFn?: (target?: T[K], thisArg?: T, argArray?: any[]) => T[K] | ProxyOverride, -): any { - let result: T[K] | ProxyOverride = ProxyOverride.callOriginal; - if (overrideFn) { - result = overrideFn(...args); - } - - if (result === ProxyOverride.callOriginal) { - result = ReflectCached.apply(...args); - } - - return result; -} - function getDescriptorInHierarchy(obj: T, prop: K) { let proto = obj; do { if (!proto) return null; - if (proto.hasOwnProperty(prop)) { + const descriptor = ObjectCached.getOwnPropertyDescriptor(proto, prop); + if (descriptor) { return { descriptorOwner: proto, - descriptor: ObjectCached.getOwnPropertyDescriptor(proto, prop), + descriptor, }; } proto = ObjectCached.getPrototypeOf(proto); @@ -385,7 +526,7 @@ function addDescriptorAfterProperty( const inHierarchy = getDescriptorInHierarchy(owner, propertyName); if (inHierarchy && descriptor.value) { if (inHierarchy.descriptor.get) { - proxyGetter(owner, propertyName, () => descriptor.value, true); + replaceGetter(owner, propertyName, () => descriptor.value, { onlyForInstance: true }); } else { throw new Error("Can't override descriptor that doesnt have a getter"); } @@ -416,11 +557,13 @@ const reordersByObject = new WeakMap< { propertyName: string; prevProperty: string; throughProperty: string }[] >(); -proxyFunction(Object, 'getOwnPropertyDescriptors', (target, thisArg, argArray) => { - const descriptors = ReflectCached.apply(target, thisArg, argArray); - const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray?.[0]); +replaceFunction(Object, 'getOwnPropertyDescriptors', (target, thisArg, argArray) => { + const descriptors = ReflectCached.apply(target, thisArg, argArray) as ReturnType< + ObjectConstructor['getOwnPropertyDescriptors'] + >; + const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray.at(0)); if (reorders) { - const keys = Object.keys(descriptors); + const keys = ObjectCached.keys(descriptors); for (const { propertyName, prevProperty, throughProperty } of reorders) { adjustKeyOrder(keys, propertyName, prevProperty, throughProperty); } @@ -433,10 +576,10 @@ proxyFunction(Object, 'getOwnPropertyDescriptors', (target, thisArg, argArray) = return descriptors; }); -proxyFunction(Object, 'getOwnPropertyNames', (target, thisArg, argArray) => { - const keys = ReflectCached.apply(target, thisArg, argArray); +replaceFunction(Object, 'getOwnPropertyNames', (target, thisArg, argArray) => { + const keys = ReflectCached.apply(target, thisArg!, argArray!); - const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray?.[0]); + const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray.at(0)); if (reorders) { for (const { propertyName, prevProperty, throughProperty } of reorders) { adjustKeyOrder(keys, propertyName, prevProperty, throughProperty); @@ -445,10 +588,10 @@ proxyFunction(Object, 'getOwnPropertyNames', (target, thisArg, argArray) => { return keys; }); -proxyFunction(Object, 'keys', (target, thisArg, argArray) => { - const keys = ReflectCached.apply(target, thisArg, argArray); +replaceFunction(Object, 'keys', (target, thisArg, argArray) => { + const keys = ReflectCached.apply(target, thisArg!, argArray!); - const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray?.[0]); + const reorders = reordersByObject.get(thisArg) ?? reordersByObject.get(argArray.at(0)); if (reorders) { for (const { propertyName, prevProperty, throughProperty } of reorders) { adjustKeyOrder(keys, propertyName, prevProperty, throughProperty); @@ -457,27 +600,6 @@ proxyFunction(Object, 'keys', (target, thisArg, argArray) => { return keys; }); -(['call', 'apply'] as const).forEach(key => { - proxyFunction(Function.prototype, key, (target, thisArg, argArray) => { - const originalThis = argArray.at(0); - return runAndInjectProxyInStack(target, thisArg, argArray, originalThis); - }); -}); - -proxyFunction(Function.prototype, 'bind', (target, thisArg, argArray) => { - const result = ReflectCached.apply(target, thisArg, argArray); - const proxy = internalCreateFnProxy({ - target: result, - inner: { - apply(innerTarget, innerThisArg, innerArgArray) { - const originalThis = argArray.at(0); - return runAndInjectProxyInStack(innerTarget, innerThisArg, innerArgArray, originalThis); - }, - }, - }); - return proxy; -}); - function reorderNonConfigurableDescriptors( objectPath, propertyName, @@ -486,19 +608,19 @@ function reorderNonConfigurableDescriptors( ) { const objectAtPath = getObjectAtPath(objectPath); if (!reordersByObject.has(objectAtPath)) reordersByObject.set(objectAtPath, []); - const reorders = reordersByObject.get(objectAtPath); + const reorders = reordersByObject.get(objectAtPath)!; reorders.push({ prevProperty, propertyName, throughProperty }); } function reorderDescriptor(path, propertyName, prevProperty, throughProperty) { const owner = getObjectAtPath(path); - const descriptor = Object.getOwnPropertyDescriptor(owner, propertyName); + const descriptor = ObjectCached.getOwnPropertyDescriptor(owner, propertyName); if (!descriptor) { console.log(`Can't redefine a non-existent property descriptor: ${path} -> ${propertyName}`); return; } - const prevDescriptor = Object.getOwnPropertyDescriptor(owner, prevProperty); + const prevDescriptor = ObjectCached.getOwnPropertyDescriptor(owner, prevProperty); if (!prevDescriptor) { console.log( `Can't redefine a non-existent prev property descriptor: ${path} -> ${propertyName}, prev =${prevProperty}`, @@ -506,14 +628,14 @@ function reorderDescriptor(path, propertyName, prevProperty, throughProperty) { return; } - const descriptors = Object.getOwnPropertyDescriptors(owner); - const keys = Object.keys(owner); + const descriptors = ObjectCached.getOwnPropertyDescriptors(owner); + const keys = ObjectCached.keys(owner); adjustKeyOrder(keys, propertyName, prevProperty, throughProperty); for (const key of keys) { const keyDescriptor = descriptors[key]; delete owner[key]; - Object.defineProperty(owner, key, keyDescriptor); + ObjectCached.defineProperty(owner, key, keyDescriptor); } } @@ -523,14 +645,3 @@ function adjustKeyOrder(keys, propertyName, prevProperty, throughProperty) { const props = keys.splice(currentIndex, throughPropIndex); keys.splice(keys.indexOf(prevProperty) + 1, 0, ...props); } - -if (typeof module === 'object' && typeof module.exports === 'object') { - module.exports = { - proxyFunction, - }; -} - -// Injected by DomOverridesBuilder -declare let sourceUrl: string; -declare let targetType: string | undefined; -declare let args: any; diff --git a/plugins/default-browser-emulator/injected-scripts/console.ts b/plugins/default-browser-emulator/injected-scripts/console.ts index ec82a59a4..ac67b4f38 100644 --- a/plugins/default-browser-emulator/injected-scripts/console.ts +++ b/plugins/default-browser-emulator/injected-scripts/console.ts @@ -1,48 +1,227 @@ -export type Args = { - mode: 'disableConsole' | 'patchLeaks'; +export type Args = never; + +// Ported from chromium/src/v8/src/builtins/builtins-console.cc +type ConsoleKeys = Array; +const CONSOLE_METHODS_WITH_FORMATTING: ConsoleKeys = [ + 'debug', + 'error', + 'info', + 'log', + 'warn', + 'trace', + 'group', + 'groupCollapsed', + 'assert', +] as const; + +const CONSOLE_METHODS: ConsoleKeys = [ + 'dir', + 'dirxml', + 'table', + 'groupEnd', + 'clear', + 'count', + 'countReset', + 'profile', + 'profileEnd', + 'timeLog', +] as const; + +const ALL_CONSOLE_METHODS: ConsoleKeys = [ + ...CONSOLE_METHODS_WITH_FORMATTING, + ...CONSOLE_METHODS, +] as const; + +// TODO move to utils in PR to cache everything +const CACHED = { + Object, + Date, + Function, + Error, + RegExp, + String, + ArrayIsArray: Array.isArray, + NumberParseFloat: Number.parseFloat, + NumberParseInt: Number.parseInt, + NumberNaN: Number.NaN, + // eslint-disable-next-line no-restricted-properties + ObjectPrototypeToString: Object.prototype.toString }; -const typedArgs = args as Args; +ALL_CONSOLE_METHODS.forEach(key => { + if (typeof console[key] !== 'function') return; + replaceFunction(console, key, (target, thisArg, argArray) => { + if (CONSOLE_METHODS_WITH_FORMATTING.includes(key)) { + formatter(argArray); + } -ObjectCached.keys(console).forEach(key => { - proxyFunction(console, key, (target, thisArg, args) => { - if (typedArgs.mode === 'disableConsole') return undefined; - args = replaceErrorStackWithOriginal(args); - return ReflectCached.apply(target, thisArg, args); + // Don't use map here, or it will leak in stacktraces + for (let idx = 0; idx < argArray.length; idx++) { + argArray[idx] = V8ValueStringBuilder.toString(argArray[idx]); + } + return ReflectCached.apply(target, thisArg, argArray); }); }); -const defaultErrorStackGetter = Object.getOwnPropertyDescriptor(new Error(''), 'stack').get; +// Ported from chromium/src/v8/src/builtins/builtins-console.cc +function formatter(args: any[]) { + let idx = 0; + if (args.length < 2 || typeof args[0] !== 'string') return true; + + const states: { str: string; offset: number }[] = []; + states.push({ str: args[idx++], offset: 0 }); + while (states.length && idx < args.length) { + const state = states.at(-1)!; + state.offset = state.str.indexOf('%', state.offset); + if (state.offset < 0 || state.offset === state.str.length - 1) { + states.pop(); + continue; + } + let current = args.at(idx); + const specifier = state.str[state.offset + 1]; + if (['d', 'f', 'i'].includes(specifier)) { + if (typeof current === 'symbol') { + current = CACHED.NumberNaN; + } else { + const fn = specifier === 'f' ? CACHED.NumberParseFloat : CACHED.NumberParseInt; + const fnArgs = [current, 10] as const; + try { + current = fn(...fnArgs); + } catch { + return false; + } + } + } else if (specifier === 's') { + try { + current = CACHED.String(current); + } catch { + return false; + } + states.push({ str: current, offset: 0 }); + } else if (['c', 'o', 'O', '_'].includes(specifier)) { + idx++; + state.offset += 2; + continue; + } else if (specifier === '%') { + state.offset += 2; + continue; + } else { + state.offset++; + continue; + } -function replaceErrorStackWithOriginal(object: unknown) { - if (!object || typeof object !== 'object') { - return object; + args[idx++] = current; + state.offset += 2; } + return true; +} + +// Ported from chromium/src/v8/src/inspector/v8-console-message.cc +const maxArrayItemsLimit = 10000; +const maxStackDepthLimit = 32; - if (object instanceof Error) { - const nameDesc = - Object.getOwnPropertyDescriptor(object, 'name') ?? - Object.getOwnPropertyDescriptor(Object.getPrototypeOf(object), 'name'); - const { message: msgDesc, stack: stackDesc } = Object.getOwnPropertyDescriptors(object); +class V8ValueStringBuilder { + m_arrayLimit = maxArrayItemsLimit; + m_tryCatch = false; + m_builder = ''; + m_visitedArrays = new Set>(); - const isSafeName = nameDesc.hasOwnProperty('value'); - const isSafeMsg = msgDesc.hasOwnProperty('value'); - const isSafeStack = stackDesc?.get === defaultErrorStackGetter; + appendValue(value: any): boolean { + if (value === null) return true; + if (value === undefined) return true; + // skip type casting - if (isSafeName && isSafeMsg && isSafeStack) { - return object; + if (typeof value === 'string') return this.appendString(value); + if (typeof value === 'bigint') return this.appendBigInt(value); + if (typeof value === 'symbol') return this.appendSymbol(value); + if (CACHED.ArrayIsArray(value)) return this.appendArray(value); + if (proxyToTarget.has(value)) { + this.m_builder += '[object Proxy]'; + return true; } - const name = isSafeName ? object.name : 'NameNotSafe'; - const msg = isSafeMsg ? object.message : 'message removed because its not safe'; - const error = new Error(`Unblocked stealth created new error from ${name}: ${msg}`); - error.stack = `${msg}\n Stack removed to prevent leaking debugger active`; - return error; + if ( + value instanceof CACHED.Object && + !(value instanceof CACHED.Date) && + !(value instanceof CACHED.Function) && + !(value instanceof CACHED.Error) && + !(value instanceof CACHED.RegExp) + ) { + try { + // eslint-disable-next-line no-restricted-properties + const string = CACHED.ObjectPrototypeToString.call(value); + return this.appendString(string); + } catch { + this.m_tryCatch = true; + } + } + + try { + const string = value.toString(); + return this.appendString(string); + } catch { + this.m_tryCatch = true; + return false; + } } - if (object instanceof Array) { - return object.map(item => replaceErrorStackWithOriginal(item)); + appendArray(array: Array): boolean { + if (this.m_visitedArrays.has(array)) return true; + const length = array.length; + if (length > this.m_arrayLimit) return false; + if (this.m_visitedArrays.size > maxStackDepthLimit) return false; + + let result = true; + this.m_arrayLimit -= length; + this.m_visitedArrays.add(array); + for (let i = 0; i < length; i++) { + if (i) this.m_builder += ','; + const value = array[i]; + if (!this.appendValue(value)) { + result = false; + break; + } + } + + this.m_visitedArrays.delete(array); + return result; } - return ObjectCached.values(object).map(item => replaceErrorStackWithOriginal(item)); + appendSymbol(symbol: symbol): boolean { + this.m_builder += 'Symbol('; + const result = this.appendValue(symbol.description); + this.m_builder += ')'; + return result; + } + + appendBigInt(bigint: BigInt): boolean { + let string; + try { + string = bigint.toString(); + } catch { + this.m_tryCatch = true; + return false; + } + const result = this.appendString(string); + if (this.m_tryCatch) return false; + this.m_builder += 'n'; + return result; + } + + appendString(string: string): boolean { + if (this.m_tryCatch) return false; + this.m_builder += string; + return true; + } + + toString() { + if (this.m_tryCatch) return ''; + return this.m_builder; + } + + static toString(value: any): string { + const builder = new this(); + if (!builder.appendValue(value)) return ''; + return builder.toString(); + } } diff --git a/plugins/default-browser-emulator/injected-scripts/error.ts b/plugins/default-browser-emulator/injected-scripts/error.ts index ab9a7b889..c05dc8cb1 100644 --- a/plugins/default-browser-emulator/injected-scripts/error.ts +++ b/plugins/default-browser-emulator/injected-scripts/error.ts @@ -1,7 +1,6 @@ export type Args = { + fixConsoleStack: boolean; removeInjectedLines: boolean; - modifyWrongProxyAndObjectString: boolean; - skipDuplicateSetPrototypeLines: boolean; applyStackTraceLimit: boolean; }; @@ -13,25 +12,51 @@ if (typeof self === 'undefined') { return; } -let stackTracelimit = Error.stackTraceLimit; -Error.stackTraceLimit = 200; +const OriginalError = Error; +const originalErrorProperties = ObjectCached.getOwnPropertyDescriptors(Error); -let customPrepareStackTrace: any | undefined; +Error.stackTraceLimit = 10000; +Error.prepareStackTrace = prepareStackTrace; -const proxyThisTrackerHere = proxyThisTracker; -const proxyToTargetHere = proxyToTarget; -const getPrototypeSafeHere = getPrototypeSafe; -function prepareStackAndStackTraces(error: Error, stackTraces?: NodeJS.CallSite[]) { +ObjectCached.defineProperty(self, 'Error', { + // eslint-disable-next-line object-shorthand + value: function Error(this, msg, opts) { + // eslint-disable-next-line strict + 'use strict'; + const argArray = [msg, opts]; + if (!new.target) { + return ReflectCached.apply(OriginalError, this, argArray); + } + return ReflectCached.construct(OriginalError, argArray, new.target); + }, +}); + +ObjectCached.defineProperties(Error, originalErrorProperties); +Error.prototype.constructor = Error; +toOriginalFn.set(Error, OriginalError); + +ObjectCached.getOwnPropertyNames(self).forEach(key => { + if (!key.includes('Error')) return; + const item = self[key]; + if (OriginalError.isPrototypeOf(item) && ObjectCached.getPrototypeOf(item) === OriginalError) { + ObjectCached.setPrototypeOf(item, Error); + } +}); + +function prepareStackAndStackTraces( + error: Error, + stackTraces: NodeJS.CallSite[] = [], +): { stack?: string; stackTraces: NodeJS.CallSite[] } { let stack = error.stack; - stackTraces ??= []; + const safeStackTraces: NodeJS.CallSite[] = []; + if (!stack) return { stack, stackTraces: safeStackTraces }; + const lines = stack.split('\n'); - const safeLines = []; - const safeStackTraces = []; + const safeLines: string[] = []; // Chrome debugger generates these things one the fly for every letter you type in // devtools so it can dynamically generates previews, but this is super annoying when // working with debug points. Also we don't need to modify them because they only contain // a single first line, which never leaks any information - if (lines.length <= 1) return { stack, stackTraces }; // First line never leak @@ -40,60 +65,78 @@ function prepareStackAndStackTraces(error: Error, stackTraces?: NodeJS.CallSite[ // stack lines - first line = stackTraces array for (let i = 1; i < lines.length; i++) { let line = lines[i]; - const stackTrace = stackTraces.at(i - 1); + let stackTrace = stackTraces.at(i - 1); // First line doesnt count for limit - if (safeLines.length > stackTracelimit && typedArgs.applyStackTraceLimit) break; + if (safeLines.length > Error.stackTraceLimit && typedArgs.applyStackTraceLimit) break; - if (setProtoTracker.has(error) && i === 1 && typedArgs.skipDuplicateSetPrototypeLines) continue; - - if (line.includes(sourceUrl) && typedArgs.removeInjectedLines) { - continue; + if (typedArgs.fixConsoleStack && line.includes(sourceUrl) && line.includes('console.')) { + ({ line, stackTrace } = fixConsoleStack(line, stackTrace)); } - const hideProxyLogic = () => { - if (!typedArgs.modifyWrongProxyAndObjectString) return; - if (!line.includes('Proxy') && !line.includes('Object')) return; - - const nextLine = lines.at(i + 1); - if (!nextLine) return; - - const name = nextLine.trim().split(' ').at(1); - if (!name?.includes('Internal-')) return; - - let originalThis = proxyThisTrackerHere.get(name); - if (!originalThis) return; - if (originalThis instanceof WeakRef) originalThis = originalThis.deref(); - - let proxyTarget = originalThis; - while (proxyTarget) { - originalThis = proxyTarget; - proxyTarget = - proxyToTargetHere.get(originalThis) ?? - proxyToTargetHere.get(getPrototypeSafeHere(originalThis)); - } - - const replacement = typeof originalThis === 'function' ? 'Function' : 'Object'; - // Make sure to replace Object first, so we don't accidentally replace it. - line = line.replace('Object', replacement); - line = line.replace('Proxy', replacement); - }; - - hideProxyLogic(); + if (line.includes(sourceUrl) && typedArgs.removeInjectedLines) continue; safeLines.push(line); - if (stackTrace) { - safeStackTraces.push(stackTrace); - } + if (stackTrace) safeStackTraces.push(stackTrace); } stack = safeLines.join('\n'); return { stack, stackTraces: safeStackTraces }; } -Error.prepareStackTrace = (error, stackTraces) => { +function fixConsoleStack(line: string, stackTrace?: NodeJS.CallSite) { + line = `${line.substring(0, 20)}()`; + if (stackTrace) { + const originalProperties = ObjectCached.getOwnPropertyDescriptors( + ObjectCached.getPrototypeOf(stackTrace), + ); + const writeableProperties = ObjectCached.getOwnPropertyDescriptors( + ObjectCached.getPrototypeOf(stackTrace), + ); + + ObjectCached.keys(writeableProperties).forEach(key => { + writeableProperties[key].writable = true; + writeableProperties[key].configurable = true; + }); + const newProto = {}; + ObjectCached.defineProperties(newProto, writeableProperties); + ObjectCached.setPrototypeOf(stackTrace, newProto); + + [ + 'getScriptNameOrSourceURL', + 'getLineNumber', + 'getEnclosingLineNumber', + 'getEnclosingColumnNumber', + 'getColumnNumber', + ].forEach(key => + replaceFunction(stackTrace as any, key, (target, thisArg, argArray) => { + const _result = ReflectCached.apply(target, thisArg, argArray); + return null; + }), + ); + + replaceFunction(stackTrace as any, 'getPosition', (target, thisArg, argArray) => { + const _result = ReflectCached.apply(target, thisArg, argArray); + return 0; + }); + + const ObjectCachedHere = ObjectCached; + ObjectCached.keys(originalProperties).forEach(key => { + ObjectCachedHere.defineProperty(newProto, key, { + ...ObjectCachedHere.getOwnPropertyDescriptor(newProto, key), + writable: originalProperties[key].writable, + configurable: originalProperties[key].configurable, + }); + }); + } + + return { line, stackTrace }; +} + +function prepareStackTrace(error, stackTraces) { const { stack, stackTraces: safeStackTraces } = prepareStackAndStackTraces(error, stackTraces); + const customPrepareStackTrace = Error.prepareStackTrace; if (!customPrepareStackTrace) { return stack; } @@ -110,43 +153,4 @@ Error.prepareStackTrace = (error, stackTraces) => { // Default behaviour when prepareStackTrace crashes return error.toString(); } -}; - -const ErrorDescriptor = ObjectCached.getOwnPropertyDescriptor(self, 'Error'); -const ErrorProxy = internalCreateFnProxy({ - target: Error, - inner: { - get: (target, p, receiver) => { - // Special property that other plugins can use to see if injected scripts are loaded - if (p === sourceUrl) return true; - if (p === 'prepareStackTrace') return customPrepareStackTrace; - if (p === 'stackTraceLimit') return stackTracelimit; - return ReflectCached.get(target, p, receiver); - }, - set: (target, p, newValue, receiver) => { - if (p === 'prepareStackTrace') { - console.info('prepareStackTrace used by external user'); - customPrepareStackTrace = newValue; - return true; - } - if (p === 'stackTraceLimit') { - stackTracelimit = newValue; - return true; - } - return ReflectCached.set(target, p, newValue, receiver); - }, - }, -}); - -ObjectCached.defineProperty(self, 'Error', { - ...ErrorDescriptor, - value: ErrorProxy, -}); - -proxyFunction(Error, 'captureStackTrace', (targetObj, thisArg, argArray) => { - const [obj, ...rest] = argArray as any; - const out = ReflectCached.apply(targetObj, thisArg, [obj, ...rest]); - const { stack } = prepareStackAndStackTraces(obj); - obj.stack = stack; - return out; -}); +} diff --git a/plugins/default-browser-emulator/injected-scripts/navigator.deviceMemory.ts b/plugins/default-browser-emulator/injected-scripts/navigator.deviceMemory.ts index b4df87adf..d150df263 100644 --- a/plugins/default-browser-emulator/injected-scripts/navigator.deviceMemory.ts +++ b/plugins/default-browser-emulator/injected-scripts/navigator.deviceMemory.ts @@ -1,32 +1,44 @@ +export type Args = { + memory: number; + maxHeapSize: number; + storageTib: number; +}; +const typedArgs = args as Args; + if ( 'WorkerGlobalScope' in self || self.location.protocol === 'https:' || 'deviceMemory' in navigator ) { - // @ts-ignore - proxyGetter(self.navigator, 'deviceMemory', () => args.memory, true); + replaceGetter( + self.navigator, + // @ts-expect-error + 'deviceMemory', + () => typedArgs.memory, + { overrideOnlyForInstance: true }, + ); } if ('WorkerGlobalScope' in self || self.location.protocol === 'https:') { - if ('storage' in navigator && navigator.storage && args.storageTib) { - proxyFunction( + if ('storage' in navigator && navigator.storage && typedArgs.storageTib) { + replaceFunction( self.navigator.storage, 'estimate', async (target, thisArg, argArray) => { - const result = await ReflectCached.apply(target, thisArg, argArray); - result.quota = Math.round(args.storageTib * 1024 * 1024 * 1024 * 1024 * 0.5); + const result = await ReflectCached.apply(target, thisArg, argArray) as any; + result.quota = Math.round(typedArgs.storageTib * 1024 * 1024 * 1024 * 1024 * 0.5); return result; }, - true, + { onlyForInstance: true }, ); } if ( 'webkitTemporaryStorage' in navigator && 'queryUsageAndQuota' in (navigator as any).webkitTemporaryStorage && - args.storageTib + typedArgs.storageTib ) { - proxyFunction( + replaceFunction( (self.navigator as any).webkitTemporaryStorage, 'queryUsageAndQuota', (target, thisArg, argArray) => { @@ -34,36 +46,36 @@ if ('WorkerGlobalScope' in self || self.location.protocol === 'https:') { usage => { (argArray[0] as any)( usage, - Math.round(args.storageTib * 1024 * 1024 * 1024 * 1024 * 0.5), + Math.round(typedArgs.storageTib * 1024 * 1024 * 1024 * 1024 * 0.5), ); }, ]); }, - true, + { onlyForInstance: true }, ); } if ('memory' in performance && (performance as any).memory) { - proxyGetter( + replaceGetter( self.performance, 'memory' as any, - function () { - const result = ReflectCached.apply(...arguments); - proxyGetter(result, 'jsHeapSizeLimit', () => args.maxHeapSize); + (target, thisArg, argArray) => { + const result = ReflectCached.apply(target, thisArg, argArray) as any; + replaceGetter(result, 'jsHeapSizeLimit', () => typedArgs.maxHeapSize); return result; }, - true, + { onlyForInstance: true }, ); } if ('memory' in console && (console as any).memory) { - proxyGetter( + replaceGetter( self.console, 'memory' as any, - function () { - const result = ReflectCached.apply(...arguments); - proxyGetter(result, 'jsHeapSizeLimit', () => args.maxHeapSize); + (target, thisArg, argArray) => { + const result = ReflectCached.apply(target, thisArg, argArray) as any; + replaceGetter(result, 'jsHeapSizeLimit', () => typedArgs.maxHeapSize); return result; }, - true, + { onlyForInstance: true }, ); } } diff --git a/plugins/default-browser-emulator/injected-scripts/navigator.hardwareConcurrency.ts b/plugins/default-browser-emulator/injected-scripts/navigator.hardwareConcurrency.ts index c15da7125..466d1dd5d 100644 --- a/plugins/default-browser-emulator/injected-scripts/navigator.hardwareConcurrency.ts +++ b/plugins/default-browser-emulator/injected-scripts/navigator.hardwareConcurrency.ts @@ -1 +1,8 @@ -proxyGetter(self.navigator, 'hardwareConcurrency', () => args.concurrency, true); +export type Args = { + concurrency: number; +}; +const typedArgs = args as Args; + +replaceGetter(self.navigator, 'hardwareConcurrency', () => typedArgs.concurrency, { + onlyForInstance: true, +}); diff --git a/plugins/default-browser-emulator/injected-scripts/navigator.ts b/plugins/default-browser-emulator/injected-scripts/navigator.ts index b0accdd1d..a224382b1 100644 --- a/plugins/default-browser-emulator/injected-scripts/navigator.ts +++ b/plugins/default-browser-emulator/injected-scripts/navigator.ts @@ -1,50 +1,65 @@ -if (args.userAgentString) { - proxyGetter(self.navigator, 'userAgent', () => args.userAgentString, true); - proxyGetter( +export type Args = { + userAgentString: string; + platform: string; + headless: boolean; + pdfViewerEnabled: boolean; + userAgentData: any; + rtt: number; +}; +const typedArgs = args as Args; + +if (typedArgs.userAgentString) { + replaceGetter(self.navigator, 'userAgent', () => typedArgs.userAgentString, { + onlyForInstance: true, + }); + replaceGetter( self.navigator, 'appVersion', - () => args.userAgentString.replace('Mozilla/', ''), - true, + () => typedArgs.userAgentString.replace('Mozilla/', ''), + { onlyForInstance: true }, ); } if ('NetworkInformation' in self) { - proxyGetter((self.NetworkInformation as any).prototype as any, 'rtt', () => args.rtt, false); + replaceGetter((self.NetworkInformation as any).prototype as any, 'rtt', () => typedArgs.rtt); } -if (args.userAgentData && 'userAgentData' in self.navigator) { +if (typedArgs.userAgentData && 'userAgentData' in self.navigator) { const userAgentData = self.navigator.userAgentData as any; function checkThisArg(thisArg, customMessage = '') { - // @ts-expect-error - if (Object.getPrototypeOf(thisArg) !== self.NavigatorUAData.prototype) { + if ( + ObjectCached.getPrototypeOf(thisArg) !== + // @ts-expect-error + self.NavigatorUAData.prototype + ) { throw new TypeError(`${customMessage}Illegal invocation`); } } - proxyGetter(userAgentData, 'brands', (_, thisArg) => { + replaceGetter(userAgentData, 'brands', (_, thisArg) => { checkThisArg(thisArg); - const clonedValues = args.userAgentData.brands.map(x => ({ ...x })); + const clonedValues = typedArgs.userAgentData.brands.map(x => ({ ...x })); - return Object.seal(Object.freeze(clonedValues)); + return ObjectCached.seal(ObjectCached.freeze(clonedValues)); }); - proxyGetter(userAgentData, 'platform', (_, thisArg) => { + replaceGetter(userAgentData, 'platform', (_, thisArg) => { checkThisArg(thisArg); - return args.userAgentData.platform; + return typedArgs.userAgentData.platform; }); - proxyFunction(userAgentData, 'getHighEntropyValues', async (target, thisArg, argArray) => { + replaceFunction(userAgentData, 'getHighEntropyValues', async (target, thisArg, argArray) => { // TODO: pull Error messages directly from dom extraction files checkThisArg(thisArg, "Failed to execute 'getHighEntropyValues' on 'NavigatorUAData': "); // check if these work await ReflectCached.apply(target, thisArg, argArray); const props: any = { - brands: Object.seal(Object.freeze(args.userAgentData.brands)), + brands: ObjectCached.seal(ObjectCached.freeze(typedArgs.userAgentData.brands)), mobile: false, }; if (argArray.length && Array.isArray(argArray[0])) { for (const key of argArray[0]) { - if (key in args.userAgentData) { - props[key] = args.userAgentData[key]; + if (key in typedArgs.userAgentData) { + props[key] = typedArgs.userAgentData[key]; } } } @@ -53,62 +68,13 @@ if (args.userAgentData && 'userAgentData' in self.navigator) { }); } -if (args.pdfViewerEnabled && 'pdfViewerEnabled' in self.navigator) { - proxyGetter(self.navigator, 'pdfViewerEnabled', () => args.pdfViewerEnabled, true); -} - -// always override -proxyGetter(self.navigator, 'platform', () => args.platform, true); - -if ('setAppBadge' in self.navigator) { - // @ts-ignore - proxyFunction(self.navigator, 'setAppBadge', async (target, thisArg, argArray) => { - if (Object.getPrototypeOf(thisArg) !== Navigator.prototype) { - throw new TypeError("Failed to execute 'setAppBadge' on 'Navigator': Illegal invocation"); - } else if (argArray.length) { - const arg = argArray[0]; - if (typeof arg === 'number') { - if (arg < 0 || arg > Number.MAX_SAFE_INTEGER) { - throw new TypeError( - `Failed to execute 'setAppBadge' on 'Navigator': Value is outside the 'unsigned long long' value range.`, - ); - } - } else { - throw new TypeError( - `Failed to execute 'setAppBadge' on 'Navigator': Value is not of type 'unsigned long long'.`, - ); - } - } - return undefined; - }); -} - -if ('clearAppBadge' in self.navigator) { - // @ts-ignore - proxyFunction(self.navigator, 'clearAppBadge', async (target, thisArg, argArray) => { - if (Object.getPrototypeOf(thisArg) !== Navigator.prototype) { - throw new TypeError("Failed to execute 'clearAppBadge' on 'Navigator': Illegal invocation"); - } - return undefined; +if (typedArgs.pdfViewerEnabled && 'pdfViewerEnabled' in self.navigator) { + replaceGetter(self.navigator, 'pdfViewerEnabled', () => typedArgs.pdfViewerEnabled, { + onlyForInstance: true, }); } -if (args.headless === true && 'requestMediaKeySystemAccess' in self.navigator) { - proxyFunction( - self.navigator, - 'requestMediaKeySystemAccess', - async (target, thisArg, argArray) => { - if (argArray.length < 2) { - return ProxyOverride.callOriginal; - } - const [keySystem, configs] = argArray; - if (keySystem !== 'com.widevine.alpha' || [...configs].length < 1) { - return ProxyOverride.callOriginal; - } - - const result = await ReflectCached.apply(target, thisArg, ['org.w3.clearkey', configs]); - proxyGetter(result, 'keySystem', () => keySystem); - return result; - }, - ); -} +// always override +replaceGetter(self.navigator, 'platform', () => typedArgs.platform, { + onlyForInstance: true, +}); diff --git a/plugins/default-browser-emulator/injected-scripts/performance.ts b/plugins/default-browser-emulator/injected-scripts/performance.ts index d902b79a4..3779e0c36 100644 --- a/plugins/default-browser-emulator/injected-scripts/performance.ts +++ b/plugins/default-browser-emulator/injected-scripts/performance.ts @@ -1,15 +1,16 @@ // This is here, because on Linux using Devtools, the lack of activationStart and renderBlockingStatus leads to blocking on some protections +export type Args = never; -proxyFunction( +replaceFunction( performance, 'getEntriesByType', (target, thisArg, argArray): PerformanceEntryList => { - const entries = ReflectCached.apply(target, thisArg, argArray); + const entries = ReflectCached.apply(target, thisArg, argArray) as any; if (argArray[0] === 'navigation') { entries.forEach(entry => { - proxyGetter(entry, 'activationStart', () => 0); - proxyGetter(entry, 'renderBlockingStatus', () => 'non-blocking'); + replaceGetter(entry, 'activationStart', () => 0); + replaceGetter(entry, 'renderBlockingStatus', () => 'non-blocking'); }); } @@ -17,13 +18,13 @@ proxyFunction( }, ); -proxyFunction(performance, 'getEntries', (target, thisArg, argArray): PerformanceEntryList => { - const entries = ReflectCached.apply(target, thisArg, argArray); +replaceFunction(performance, 'getEntries', (target, thisArg, argArray): PerformanceEntryList => { + const entries = ReflectCached.apply(target, thisArg, argArray) as any; entries.forEach(entry => { if (entry.entryType === 'navigation') { - proxyGetter(entry, 'activationStart', () => 0); - proxyGetter(entry, 'renderBlockingStatus', () => 'non-blocking'); + replaceGetter(entry, 'activationStart', () => 0); + replaceGetter(entry, 'renderBlockingStatus', () => 'non-blocking'); } }); diff --git a/plugins/default-browser-emulator/injected-scripts/polyfill.add.ts b/plugins/default-browser-emulator/injected-scripts/polyfill.add.ts index 366920d0d..4cdf587f2 100644 --- a/plugins/default-browser-emulator/injected-scripts/polyfill.add.ts +++ b/plugins/default-browser-emulator/injected-scripts/polyfill.add.ts @@ -1,4 +1,9 @@ -for (const itemToAdd of args.itemsToAdd || []) { +export type Args = { + itemsToAdd: any[]; +}; +const typedArgs = args as Args; + +for (const itemToAdd of typedArgs.itemsToAdd) { try { if (itemToAdd.propertyName === 'getVideoPlaybackQuality') { itemToAdd.property['_$$value()'] = function () { @@ -16,7 +21,11 @@ for (const itemToAdd of args.itemsToAdd || []) { ), ); } catch (err) { - console.log(`ERROR adding polyfill ${itemToAdd.path}.${itemToAdd.propertyName}\n${err.stack}`); + let log = `ERROR adding polyfill ${itemToAdd.path}.${itemToAdd.propertyName}`; + if (err instanceof Error) { + log += `\n${err.stack}`; + } + console.error(log); } } diff --git a/plugins/default-browser-emulator/injected-scripts/polyfill.modify.ts b/plugins/default-browser-emulator/injected-scripts/polyfill.modify.ts index b36fa982d..0250dbc94 100644 --- a/plugins/default-browser-emulator/injected-scripts/polyfill.modify.ts +++ b/plugins/default-browser-emulator/injected-scripts/polyfill.modify.ts @@ -1,8 +1,14 @@ -for (const itemToModify of args.itemsToModify || []) { +export type Args = { + itemsToModify: any[]; +}; +const typedArgs = args as Args; + +for (const itemToModify of typedArgs.itemsToModify) { try { if (itemToModify.propertyName === '_$function') { const func = getObjectAtPath(itemToModify.path); overriddenFns.set(func, itemToModify.property); + toOriginalFn.set(func, itemToModify.property); } if ( itemToModify.propertyName === '_$setToStringToString' || @@ -18,6 +24,7 @@ for (const itemToModify of args.itemsToModify || []) { } const parts = getParentAndProperty(itemToModify.path); + if (!parts) throw new Error('failed to find parent and property'); const property = parts.property; const parent = parts.parent; const descriptorInHierarchy = getDescriptorInHierarchy(parent, property); @@ -29,23 +36,37 @@ for (const itemToModify of args.itemsToModify || []) { if (itemToModify.propertyName === '_$value') { if (descriptor.get) { - descriptor.get = proxyGetter(parent, property, () => itemToModify.property); + descriptor.get = replaceGetter(parent, property, () => itemToModify.property).get; } else { descriptor.value = itemToModify.property; - Object.defineProperty(parent, property, descriptor); + ObjectCached.defineProperty(parent, property, descriptor); } } else if (itemToModify.propertyName === '_$get') { - overriddenFns.set(descriptor.get, itemToModify.property); + overriddenFns.set(descriptor.get!, itemToModify.property); + toOriginalFn.set(descriptor.get!, itemToModify.property); } else if (itemToModify.propertyName === '_$set') { - overriddenFns.set(descriptor.set, itemToModify.property); + overriddenFns.set(descriptor.set!, itemToModify.property); + toOriginalFn.set(descriptor.get!, itemToModify.property); } else if (itemToModify.propertyName.startsWith('_$otherInvocation')) { + replaceFunction(parent, property, (target, thisArg, argArray) => { + const otherInvocation = OtherInvocationsTrackerHere.getOtherInvocation( + itemToModify.path, + thisArg, + ); + + return otherInvocation !== undefined + ? invocationReturnOrThrowHere(otherInvocation.invocation, otherInvocation.isAsync) + : ReflectCachedHere.apply(target, thisArg, argArray); + }); + + // TODO why is this needed, Im guessing since this is one big dump? const ReflectCachedHere = ReflectCached; const invocationReturnOrThrowHere = invocationReturnOrThrow; const OtherInvocationsTrackerHere = OtherInvocationsTracker; // Create single proxy on original prototype so 'this' rebinding is possible. if (!OtherInvocationsTracker.basePaths.has(itemToModify.path)) { - proxyFunction(parent, property, (target, thisArg, argArray) => { + replaceFunction(parent, property, (target, thisArg, argArray) => { const otherInvocation = OtherInvocationsTrackerHere.getOtherInvocation( itemToModify.path, thisArg, @@ -65,9 +86,11 @@ for (const itemToModify of args.itemsToModify || []) { ); } } catch (err) { - console.log( - `WARN: error changing prop ${itemToModify.path}.${itemToModify.propertyName}\n${err.stack}`, - ); + let log = `ERROR changing prop ${itemToModify.path}.${itemToModify.propertyName}`; + if (err instanceof Error) { + log += `\n${err.stack}`; + } + console.error(log); } } diff --git a/plugins/default-browser-emulator/injected-scripts/polyfill.remove.ts b/plugins/default-browser-emulator/injected-scripts/polyfill.remove.ts index 1c7412a9f..e44d9c60a 100644 --- a/plugins/default-browser-emulator/injected-scripts/polyfill.remove.ts +++ b/plugins/default-browser-emulator/injected-scripts/polyfill.remove.ts @@ -1,8 +1,17 @@ -for (const itemToRemove of args.itemsToRemove || []) { +export type Args = { + itemsToRemove: any[]; +}; +const typedArgs = args as Args; + +for (const itemToRemove of typedArgs.itemsToRemove) { try { const parent = getObjectAtPath(itemToRemove.path); delete parent[itemToRemove.propertyName]; } catch (err) { - console.log(`ERROR deleting path ${itemToRemove.path}.${itemToRemove.propertyName}\n${err.toString()}`); + let log = `ERROR deleting prop ${itemToRemove.path}.${itemToRemove.propertyName}`; + if (err instanceof Error) { + log += `\n${err.stack}`; + } + console.error(log); } } diff --git a/plugins/default-browser-emulator/injected-scripts/polyfill.reorder.ts b/plugins/default-browser-emulator/injected-scripts/polyfill.reorder.ts index 96979bca2..9d070e142 100644 --- a/plugins/default-browser-emulator/injected-scripts/polyfill.reorder.ts +++ b/plugins/default-browser-emulator/injected-scripts/polyfill.reorder.ts @@ -1,4 +1,9 @@ -for (const { propertyName, prevProperty, throughProperty, path } of args.itemsToReorder || []) { +export type Args = { + itemsToReorder: any[]; +}; +const typedArgs = args as Args; + +for (const { propertyName, prevProperty, throughProperty, path } of typedArgs.itemsToReorder) { try { if (!path.includes('.prototype')) { reorderNonConfigurableDescriptors(path, propertyName, prevProperty, throughProperty); @@ -6,6 +11,10 @@ for (const { propertyName, prevProperty, throughProperty, path } of args.itemsTo } reorderDescriptor(path, propertyName, prevProperty, throughProperty); } catch (err) { - console.log(`ERROR adding order polyfill ${path}->${propertyName}\n${err.toString()}`); + let log = `ERROR adding order polyfill ${path}->${propertyName}`; + if (err instanceof Error) { + log += `\n${err.stack}`; + } + console.error(log); } } diff --git a/plugins/default-browser-emulator/injected-scripts/speechSynthesis.getVoices.ts b/plugins/default-browser-emulator/injected-scripts/speechSynthesis.getVoices.ts index e2844640a..9a07c8bf0 100644 --- a/plugins/default-browser-emulator/injected-scripts/speechSynthesis.getVoices.ts +++ b/plugins/default-browser-emulator/injected-scripts/speechSynthesis.getVoices.ts @@ -1,17 +1,22 @@ +export type Args = { + voices: any; +}; +const typedArgs = args as Args; + if ('speechSynthesis' in self) { // @ts-ignore - const { voices } = args; - proxyFunction(speechSynthesis, 'getVoices', (func, thisObj, ...args) => { - const original = ReflectCached.apply(func, thisObj, args); + const { voices } = typedArgs; + replaceFunction(speechSynthesis, 'getVoices', (func, thisObj, ...args) => { + const original = ReflectCached.apply(func, thisObj, args) as any; if (!original.length) return original; const speechProto = ObjectCached.getPrototypeOf(original[0]); return voices.map(x => { - const voice: SpeechSynthesisVoice = Object.create(speechProto); - proxyGetter(voice, 'name', () => x.name, true); - proxyGetter(voice, 'lang', () => x.lang, true); - proxyGetter(voice, 'default', () => x.default, true); - proxyGetter(voice, 'voiceURI', () => x.voiceURI, true); - proxyGetter(voice, 'localService', () => x.localService, true); + const voice: SpeechSynthesisVoice = ObjectCached.create(speechProto); + replaceGetter(voice, 'name', () => x.name, { onlyForInstance: true }); + replaceGetter(voice, 'lang', () => x.lang, { onlyForInstance: true }); + replaceGetter(voice, 'default', () => x.default, { onlyForInstance: true }); + replaceGetter(voice, 'voiceURI', () => x.voiceURI, { onlyForInstance: true }); + replaceGetter(voice, 'localService', () => x.localService, { onlyForInstance: true }); return voice; }); }); diff --git a/plugins/default-browser-emulator/injected-scripts/tsconfig.json b/plugins/default-browser-emulator/injected-scripts/tsconfig.json index 2e0508cb5..fdf74d283 100644 --- a/plugins/default-browser-emulator/injected-scripts/tsconfig.json +++ b/plugins/default-browser-emulator/injected-scripts/tsconfig.json @@ -2,12 +2,20 @@ "extends": "../../../tsconfig.json", "compileOnSave": true, "compilerOptions": { + "strict": true, "composite": true, "sourceMap": false, "inlineSourceMap": false, "removeComments": true, - "strictBindCallApply": false + "strictBindCallApply": false, + "lib": ["dom", "es2022"], }, "include": ["*.ts", "*.js", ".eslintrc.js"], - "exclude": ["**/tsconfig*.json", "**/node_modules", "**/dist", "**/build*", "**/injected-scripts"] + "exclude": [ + "**/tsconfig*.json", + "**/node_modules", + "**/dist", + "**/build*", + "**/injected-scripts", + ], } diff --git a/plugins/default-browser-emulator/injected-scripts/webrtc.ts b/plugins/default-browser-emulator/injected-scripts/webrtc.ts index 067db4a90..1895b94a7 100644 --- a/plugins/default-browser-emulator/injected-scripts/webrtc.ts +++ b/plugins/default-browser-emulator/injected-scripts/webrtc.ts @@ -1,20 +1,28 @@ -const maskLocalIp = args.localIp; -const replacementIp = args.proxyIp; +export type Args = { + localIp: string; + proxyIp: string; +}; + +const typedArgs = args as Args; + +const maskLocalIp = typedArgs.localIp; +const replacementIp = typedArgs.proxyIp; if ('RTCIceCandidate' in self && RTCIceCandidate.prototype) { - proxyGetter(RTCIceCandidate.prototype, 'candidate', function () { - const result = ReflectCached.apply(...arguments); + // TODO argArray + replaceGetter(RTCIceCandidate.prototype, 'candidate', (target, thisArg) => { + const result = ReflectCached.apply(target, thisArg, []) as any; return result.replace(maskLocalIp, replacementIp); }); if ('address' in RTCIceCandidate.prototype) { // @ts-ignore - proxyGetter(RTCIceCandidate.prototype, 'address', function () { - const result: string = ReflectCached.apply(...arguments); + replaceGetter(RTCIceCandidate.prototype, 'address', (target, thisArg, argArray) => { + const result: string = ReflectCached.apply(target, thisArg, argArray); return result.replace(maskLocalIp, replacementIp); }); } - proxyFunction(RTCIceCandidate.prototype, 'toJSON', function () { - const json = ReflectCached.apply(...arguments); + replaceFunction(RTCIceCandidate.prototype, 'toJSON', (target, thisArg, argArray) => { + const json = ReflectCached.apply(target, thisArg, argArray) as any; if ('address' in json) json.address = json.address.replace(maskLocalIp, replacementIp); if ('candidate' in json) json.candidate = json.candidate.replace(maskLocalIp, replacementIp); return json; @@ -22,15 +30,15 @@ if ('RTCIceCandidate' in self && RTCIceCandidate.prototype) { } if ('RTCSessionDescription' in self && RTCSessionDescription.prototype) { - proxyGetter(RTCSessionDescription.prototype, 'sdp', function () { - let result = ReflectCached.apply(...arguments); + replaceGetter(RTCSessionDescription.prototype, 'sdp', (target, thisArg, argArray) => { + let result = ReflectCached.apply(target, thisArg, argArray) as any; while (result.indexOf(maskLocalIp) !== -1) { result = result.replace(maskLocalIp, replacementIp); } return result; }); - proxyFunction(RTCSessionDescription.prototype, 'toJSON', function () { - const json = ReflectCached.apply(...arguments); + replaceGetter(RTCSessionDescription.prototype, 'toJSON', (target, thisArg, argArray) => { + const json = ReflectCached.apply(target, thisArg, argArray) as any; if ('sdp' in json) json.sdp = json.sdp.replace(maskLocalIp, replacementIp); return json; }); diff --git a/plugins/default-browser-emulator/injected-scripts/window.screen.ts b/plugins/default-browser-emulator/injected-scripts/window.screen.ts index 07a49b1bf..ba5e415d7 100644 --- a/plugins/default-browser-emulator/injected-scripts/window.screen.ts +++ b/plugins/default-browser-emulator/injected-scripts/window.screen.ts @@ -1,17 +1,26 @@ -proxyGetter( +export type Args = { + unAvailHeight?: number; + unAvailWidth?: number; + colorDepth?: number; +}; + +const typedArgs = args as Args; + +replaceGetter( window.screen, 'availHeight', - () => window.screen.height - (args.unAvailHeight || 0), - true, + () => window.screen.height - (typedArgs.unAvailHeight ?? 0), + { onlyForInstance: true }, ); -proxyGetter( +replaceGetter( window.screen, 'availWidth', - () => window.screen.width - (args.unAvailWidth || 0), - true, + () => window.screen.width - (typedArgs.unAvailWidth ?? 0), + { onlyForInstance: true }, ); -if (args.colorDepth) { - proxyGetter(window.screen, 'colorDepth', () => args.colorDepth, true); - proxyGetter(window.screen, 'pixelDepth', () => args.colorDepth, true); +const colorDepth = typedArgs.colorDepth; +if (colorDepth) { + replaceGetter(window.screen, 'colorDepth', () => colorDepth, { onlyForInstance: true }); + replaceGetter(window.screen, 'pixelDepth', () => colorDepth, { onlyForInstance: true }); } diff --git a/plugins/default-browser-emulator/interfaces/IBrowserData.ts b/plugins/default-browser-emulator/interfaces/IBrowserData.ts index d204fbec8..996e8ad19 100644 --- a/plugins/default-browser-emulator/interfaces/IBrowserData.ts +++ b/plugins/default-browser-emulator/interfaces/IBrowserData.ts @@ -25,10 +25,10 @@ export interface IDataWindowNavigator { } export interface IDataDomPolyfill { - add: any[]; - remove: any[]; - modify: any[]; - reorder: any[]; + add?: any[]; + remove?: any[]; + modify?: any[]; + reorder?: any[]; } export interface IDataWindowChrome { diff --git a/plugins/default-browser-emulator/interfaces/IBrowserEmulatorConfig.ts b/plugins/default-browser-emulator/interfaces/IBrowserEmulatorConfig.ts index 28c6bef77..6b9858df3 100644 --- a/plugins/default-browser-emulator/interfaces/IBrowserEmulatorConfig.ts +++ b/plugins/default-browser-emulator/interfaces/IBrowserEmulatorConfig.ts @@ -1,27 +1,50 @@ import type { Args as ConsoleArgs } from '../injected-scripts/console'; +import type { Args as CookieArgs } from '../injected-scripts/Document.prototype.cookie'; import type { Args as ErrorArgs } from '../injected-scripts/error'; +import type { Args as JSONArgs } from '../injected-scripts/JSON.stringify'; +import type { Args as MediaDevicesArgs } from '../injected-scripts/MediaDevices.prototype.enumerateDevices'; +import type { Args as DeviceMemoryArgs } from '../injected-scripts/navigator.deviceMemory'; +import type { Args as HardwareConcurrencyArgs } from '../injected-scripts/navigator.hardwareConcurrency'; +import type { Args as NavigatorArgs } from '../injected-scripts/navigator'; +import type { Args as PerformanceArgs } from '../injected-scripts/performance'; +import type { Args as PolyfillAddArgs } from '../injected-scripts/polyfill.add'; +import type { Args as PolyfillModifyArgs } from '../injected-scripts/polyfill.modify'; +import type { Args as PolyfillRemoveArgs } from '../injected-scripts/polyfill.remove'; +import type { Args as PolyfillReorderArgs } from '../injected-scripts/polyfill.reorder'; +import type { Args as RTCRtpSenderArgs } from '../injected-scripts/RTCRtpSender.getCapabilities'; +import type { Args as SharedWorkerArgs } from '../injected-scripts/SharedWorker.prototype'; +import type { Args as GetVoicesArgs } from '../injected-scripts/speechSynthesis.getVoices'; +import type { Args as UnhandledArgs } from '../injected-scripts/UnhandledErrorsAndRejections'; +import type { Args as WebGlRenderingArgs } from '../injected-scripts/WebGLRenderingContext.prototype.getParameter'; +import type { Args as WebRtcArgs } from '../injected-scripts/webrtc'; +import type { Args as WindowScreenArgs } from '../injected-scripts/window.screen'; + +// T = config used if provided +// true = plugin enabled and loadDomOverrides will provide default config +// false = plugin disabled +export type InjectedScriptConfig = T | boolean; export default interface IBrowserEmulatorConfig { [InjectedScript.CONSOLE]: InjectedScriptConfig; - [InjectedScript.DOCUMENT_PROTOTYPE_COOKIE]: InjectedScriptConfig; + [InjectedScript.DOCUMENT_PROTOTYPE_COOKIE]: InjectedScriptConfig; [InjectedScript.ERROR]: InjectedScriptConfig; - [InjectedScript.JSON_STRINGIFY]: InjectedScriptConfig; - [InjectedScript.MEDIA_DEVICES_PROTOTYPE_ENUMERATE_DEVICES]: InjectedScriptConfig; - [InjectedScript.NAVIGATOR_DEVICE_MEMORY]: InjectedScriptConfig; - [InjectedScript.NAVIGATOR_HARDWARE_CONCURRENCY]: InjectedScriptConfig; - [InjectedScript.NAVIGATOR]: InjectedScriptConfig; - [InjectedScript.PERFORMANCE]: InjectedScriptConfig; - [InjectedScript.POLYFILL_ADD]: InjectedScriptConfig; - [InjectedScript.POLYFILL_MODIFY]: InjectedScriptConfig; - [InjectedScript.POLYFILL_REMOVE]: InjectedScriptConfig; - [InjectedScript.POLYFILL_REORDER]: InjectedScriptConfig; - [InjectedScript.RTC_RTP_SENDER_GETCAPABILITIES]: InjectedScriptConfig; - [InjectedScript.SHAREDWORKER_PROTOTYPE]: InjectedScriptConfig; - [InjectedScript.SPEECH_SYNTHESIS_GETVOICES]: InjectedScriptConfig; - [InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS]: InjectedScriptConfig; - [InjectedScript.WEBGL_RENDERING_CONTEXT_PROTOTYPE_GETPARAMETERS]: InjectedScriptConfig; - [InjectedScript.WEBRTC]: InjectedScriptConfig; - [InjectedScript.WINDOW_SCREEN]: InjectedScriptConfig; + [InjectedScript.JSON_STRINGIFY]: InjectedScriptConfig; + [InjectedScript.MEDIA_DEVICES_PROTOTYPE_ENUMERATE_DEVICES]: InjectedScriptConfig; + [InjectedScript.NAVIGATOR_DEVICE_MEMORY]: InjectedScriptConfig; + [InjectedScript.NAVIGATOR_HARDWARE_CONCURRENCY]: InjectedScriptConfig; + [InjectedScript.NAVIGATOR]: InjectedScriptConfig; + [InjectedScript.PERFORMANCE]: InjectedScriptConfig; + [InjectedScript.POLYFILL_ADD]: InjectedScriptConfig; + [InjectedScript.POLYFILL_MODIFY]: InjectedScriptConfig; + [InjectedScript.POLYFILL_REMOVE]: InjectedScriptConfig; + [InjectedScript.POLYFILL_REORDER]: InjectedScriptConfig; + [InjectedScript.RTC_RTP_SENDER_GETCAPABILITIES]: InjectedScriptConfig; + [InjectedScript.SHAREDWORKER_PROTOTYPE]: InjectedScriptConfig; + [InjectedScript.SPEECH_SYNTHESIS_GETVOICES]: InjectedScriptConfig; + [InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS]: InjectedScriptConfig; + [InjectedScript.WEBGL_RENDERING_CONTEXT_PROTOTYPE_GETPARAMETERS]: InjectedScriptConfig; + [InjectedScript.WEBRTC]: InjectedScriptConfig; + [InjectedScript.WINDOW_SCREEN]: InjectedScriptConfig; } export enum InjectedScript { @@ -46,5 +69,3 @@ export enum InjectedScript { WEBRTC = 'webrtc', WINDOW_SCREEN = 'window.screen', } - -export type InjectedScriptConfig = T | boolean; diff --git a/plugins/default-browser-emulator/lib/DomOverridesBuilder.ts b/plugins/default-browser-emulator/lib/DomOverridesBuilder.ts index 9efae70d0..ae275d9bb 100644 --- a/plugins/default-browser-emulator/lib/DomOverridesBuilder.ts +++ b/plugins/default-browser-emulator/lib/DomOverridesBuilder.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import { IFrame } from '@ulixee/unblocked-specification/agent/browser/IFrame'; import INewDocumentInjectedScript from '../interfaces/INewDocumentInjectedScript'; -import { InjectedScript } from '../interfaces/IBrowserEmulatorConfig'; +import IBrowserEmulatorConfig, { InjectedScript } from '../interfaces/IBrowserEmulatorConfig'; const injectedSourceUrl = ``; const cache: { [name: string]: string } = {}; @@ -20,6 +20,8 @@ export default class DomOverridesBuilder { private workerOverrides = new Set(); + constructor(private readonly config?: IBrowserEmulatorConfig) {} + public getWorkerOverrides(): string[] { return [...this.workerOverrides]; } @@ -81,8 +83,8 @@ export default class DomOverridesBuilder { if (runMap.has(self)) return; const sourceUrl = '${injectedSourceUrl}'; - ${utilsScript} - + ${utilsScript}; + (function newDocumentScript(selfOverride) { const originalSelf = self; if (selfOverride) self = selfOverride; @@ -105,9 +107,9 @@ export default class DomOverridesBuilder { PathToInstanceTracker.updateAllReferences(); } finally { self = originalSelf; + getSharedStorage().ready = true; } })(); - })(); //# sourceURL=${injectedSourceUrl}`.replace(/\/\/# sourceMap.+/g, ''), }; @@ -169,6 +171,26 @@ export default class DomOverridesBuilder { }); } + public addOverrideAndUseConfig( + injectedScript: T, + defaultConfig: IBrowserEmulatorConfig[T], + opts?: { registerWorkerOverride?: boolean }, + ): void { + if (!this.config) + throw new Error( + 'This method can only be used when creating domOverriderBuilder with a config', + ); + + const scriptConfig = this.config[injectedScript]; + if (!scriptConfig) return; + + this.add( + injectedScript, + scriptConfig === true ? defaultConfig : scriptConfig, + opts?.registerWorkerOverride ?? false, + ); + } + public cleanup(): void { this.alwaysPageScripts.clear(); this.alwaysWorkerScripts.clear(); diff --git a/plugins/default-browser-emulator/lib/loadDomOverrides.ts b/plugins/default-browser-emulator/lib/loadDomOverrides.ts index 2e26d0458..832fd63dc 100644 --- a/plugins/default-browser-emulator/lib/loadDomOverrides.ts +++ b/plugins/default-browser-emulator/lib/loadDomOverrides.ts @@ -10,22 +10,7 @@ export default function loadDomOverrides( data: IBrowserData, userAgentData: IUserAgentData, ): DomOverridesBuilder { - const domOverrides = new DomOverridesBuilder(); - - const addOverrideWithConfigOrDefault = ( - injectedScript: T, - defaultConfig: IBrowserEmulatorConfig[T], - registerWorkerOverride = false, - ): void => { - const scriptConfig = config[injectedScript]; - if (!scriptConfig) return; - - domOverrides.add( - injectedScript, - scriptConfig === true ? defaultConfig : scriptConfig, - registerWorkerOverride, - ); - }; + const domOverrides = new DomOverridesBuilder(config); const deviceProfile = emulationProfile.deviceProfile; const isHeadless = @@ -40,131 +25,108 @@ export default function loadDomOverrides( const domPolyfill = data.domPolyfill; - addOverrideWithConfigOrDefault( + domOverrides.addOverrideAndUseConfig( InjectedScript.ERROR, { removeInjectedLines: true, - modifyWrongProxyAndObjectString: true, - skipDuplicateSetPrototypeLines: true, applyStackTraceLimit: true, + fixConsoleStack: true, }, - true, + { registerWorkerOverride: true }, ); - addOverrideWithConfigOrDefault(InjectedScript.CONSOLE, { mode: 'patchLeaks' }, true); - // TODO migrate others to new logic. This first requires proper types for all Plugin Args. - // This will also allow us to configure everything in special ways. In most occasions - // you would never want to do this, but this is very helpful for specific use-cases, e.g. testing. + domOverrides.addOverrideAndUseConfig( + InjectedScript.CONSOLE, + undefined, + { registerWorkerOverride: true }, + ); - if (config[InjectedScript.JSON_STRINGIFY]) { - domOverrides.add(InjectedScript.JSON_STRINGIFY, undefined, true); - } + domOverrides.addOverrideAndUseConfig( + InjectedScript.JSON_STRINGIFY, + undefined, + { registerWorkerOverride: true }, + ); - if (config[InjectedScript.MEDIA_DEVICES_PROTOTYPE_ENUMERATE_DEVICES]) { - domOverrides.add(InjectedScript.MEDIA_DEVICES_PROTOTYPE_ENUMERATE_DEVICES, { - videoDevice: deviceProfile.videoDevice, - }); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.MEDIA_DEVICES_PROTOTYPE_ENUMERATE_DEVICES, { + groupId: deviceProfile.videoDevice?.groupId, + deviceId: deviceProfile.videoDevice?.deviceId, + }); - if (config[InjectedScript.NAVIGATOR]) { - domOverrides.add( - InjectedScript.NAVIGATOR, - { - userAgentString: emulationProfile.userAgentOption.string, - platform: emulationProfile.windowNavigatorPlatform, - headless: isHeadless, - pdfViewerEnabled: data.windowNavigator.navigator.pdfViewerEnabled?._$value, - userAgentData, - rtt: emulationProfile.deviceProfile.rtt, - }, - true, - ); - } + domOverrides.addOverrideAndUseConfig( + InjectedScript.NAVIGATOR, + { + userAgentString: emulationProfile.userAgentOption.string, + platform: emulationProfile.windowNavigatorPlatform, + headless: isHeadless, + pdfViewerEnabled: data.windowNavigator.navigator.pdfViewerEnabled?._$value, + userAgentData, + rtt: emulationProfile.deviceProfile.rtt, + }, + { registerWorkerOverride: true }, + ); - if (config[InjectedScript.NAVIGATOR_DEVICE_MEMORY]) { - domOverrides.add( - InjectedScript.NAVIGATOR_DEVICE_MEMORY, - { - memory: deviceProfile.deviceMemory, - storageTib: deviceProfile.deviceStorageTib, - maxHeapSize: deviceProfile.maxHeapSize, - }, - true, - ); - } + domOverrides.addOverrideAndUseConfig( + InjectedScript.NAVIGATOR_DEVICE_MEMORY, + { + memory: deviceProfile.deviceMemory, + storageTib: deviceProfile.deviceStorageTib, + maxHeapSize: deviceProfile.maxHeapSize, + }, + { registerWorkerOverride: true }, + ); - if (config[InjectedScript.NAVIGATOR_HARDWARE_CONCURRENCY]) { - domOverrides.add( - InjectedScript.NAVIGATOR_HARDWARE_CONCURRENCY, - { - concurrency: deviceProfile.hardwareConcurrency, - }, - true, - ); - } + domOverrides.addOverrideAndUseConfig( + InjectedScript.NAVIGATOR_HARDWARE_CONCURRENCY, + { + concurrency: deviceProfile.hardwareConcurrency, + }, + { registerWorkerOverride: true }, + ); - if ( - config[InjectedScript.PERFORMANCE] && - Number(emulationProfile.browserEngine.fullVersion.split('.')[0]) >= 109 - ) { - domOverrides.add(InjectedScript.PERFORMANCE); + if (Number(emulationProfile.browserEngine.fullVersion.split('.')[0]) >= 109) { + domOverrides.addOverrideAndUseConfig(InjectedScript.PERFORMANCE, undefined); } - if (domPolyfill) { - if (config[InjectedScript.POLYFILL_ADD] && domPolyfill?.add?.length) { - domOverrides.add(InjectedScript.POLYFILL_ADD, { - itemsToAdd: domPolyfill.add, - }); - } - - if (config[InjectedScript.POLYFILL_MODIFY] && domPolyfill?.modify?.length) { - domOverrides.add(InjectedScript.POLYFILL_MODIFY, { - itemsToAdd: domPolyfill.modify, - }); - } - - if (config[InjectedScript.POLYFILL_REMOVE] && domPolyfill?.remove?.length) { - domOverrides.add(InjectedScript.POLYFILL_REMOVE, { - itemsToRemove: domPolyfill.remove, - }); - } - - if (config[InjectedScript.POLYFILL_REORDER] && domPolyfill?.reorder?.length) { - domOverrides.add(InjectedScript.POLYFILL_REORDER, { - itemsToReorder: domPolyfill.add, - }); - } - } + domOverrides.addOverrideAndUseConfig(InjectedScript.POLYFILL_ADD, { + itemsToAdd: domPolyfill?.add ?? [], + }); - if (config[InjectedScript.SHAREDWORKER_PROTOTYPE]) { - domOverrides.add(InjectedScript.SHAREDWORKER_PROTOTYPE, undefined, true); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.POLYFILL_MODIFY, { + itemsToModify: domPolyfill?.modify ?? [], + }); - if (config[InjectedScript.SPEECH_SYNTHESIS_GETVOICES] && voices?.length) { - domOverrides.add(InjectedScript.SPEECH_SYNTHESIS_GETVOICES, { voices }); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.POLYFILL_REMOVE, { + itemsToRemove: domPolyfill?.remove ?? [], + }); - if (config[InjectedScript.WINDOW_SCREEN]) { - const frame = data.windowFraming; - domOverrides.add(InjectedScript.WINDOW_SCREEN, { - unAvailHeight: frame.screenGapTop + frame.screenGapBottom, - unAvailWidth: frame.screenGapLeft + frame.screenGapRight, - colorDepth: emulationProfile.viewport.colorDepth ?? frame.colorDepth, - }); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.POLYFILL_REORDER, { + itemsToReorder: domPolyfill?.reorder ?? [], + }); - if (config[InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS]) { - domOverrides.add(InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS); - domOverrides.registerWorkerOverrides(InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.SHAREDWORKER_PROTOTYPE, undefined, { + registerWorkerOverride: true, + }); - if (config[InjectedScript.WEBGL_RENDERING_CONTEXT_PROTOTYPE_GETPARAMETERS]) { - domOverrides.add( - InjectedScript.WEBGL_RENDERING_CONTEXT_PROTOTYPE_GETPARAMETERS, - deviceProfile.webGlParameters, - true, - ); - } + domOverrides.addOverrideAndUseConfig(InjectedScript.SPEECH_SYNTHESIS_GETVOICES, { voices }); + + const frame = data.windowFraming; + domOverrides.addOverrideAndUseConfig(InjectedScript.WINDOW_SCREEN, { + unAvailHeight: frame.screenGapTop + frame.screenGapBottom, + unAvailWidth: frame.screenGapLeft + frame.screenGapRight, + colorDepth: emulationProfile.viewport.colorDepth ?? frame.colorDepth, + }); + + domOverrides.addOverrideAndUseConfig( + InjectedScript.UNHANDLED_ERRORS_AND_REJECTIONS, + { preventDefaultUncaughtError: true, preventDefaultUnhandledRejection: true }, + { registerWorkerOverride: true }, + ); + + domOverrides.addOverrideAndUseConfig( + InjectedScript.WEBGL_RENDERING_CONTEXT_PROTOTYPE_GETPARAMETERS, + deviceProfile.webGlParameters, + { registerWorkerOverride: true }, + ); return domOverrides; } diff --git a/plugins/default-browser-emulator/test/media.test.ts b/plugins/default-browser-emulator/test/media.test.ts index 314f1b6de..487df51ea 100644 --- a/plugins/default-browser-emulator/test/media.test.ts +++ b/plugins/default-browser-emulator/test/media.test.ts @@ -45,7 +45,7 @@ test('can use widevine', async () => { return x.createMediaKeys(); }).then(x => { return x.constructor.name - })`, + }).catch((error) => 'error: ' + error.toString());`, ) .catch((err) => err); expect(accessKey).toBe('MediaKeys'); diff --git a/plugins/default-browser-emulator/test/proxyLeak.test.ts b/plugins/default-browser-emulator/test/proxyLeak.test.ts index aabe13474..e48a0e342 100644 --- a/plugins/default-browser-emulator/test/proxyLeak.test.ts +++ b/plugins/default-browser-emulator/test/proxyLeak.test.ts @@ -28,10 +28,9 @@ afterEach(Helpers.afterEach, 30e3); // Modify these values for easy testing const config = { - modifyWrongProxyAndObjectString: true, removeInjectedLines: true, - skipDuplicateSetPrototypeLines: true, applyStackTraceLimit: true, + fixConsoleStack: true, } satisfies ErrorArgs; const debug = false; // True will increase timeouts and open chrome with debugger attached @@ -256,67 +255,62 @@ test('Errors should not leak proxy objects, second simple edition: looking at Er expect(output).toEqual(referenceOutput); }); -// TODO hide this test somewhere as it seems they don't know this yet??? But we did fix so doesn't really hurt? -test.failing( - 'Errors should not leak proxy objects, advanced edition: looking at Error.prepareStackTrace', - async () => { - function script() { - let errorSeen; - let stackTracesSeen; +test('Errors should not leak proxy objects, advanced edition: looking at Error.prepareStackTrace', async () => { + function script() { + let errorSeen; + let stackTracesSeen; - function leak() { - try { - // @ts-expect-error - document.boddfsqy.innerHTML = 'dfjksqlm'; - } catch (e) { - return e.stack; - } + function leak() { + try { + // @ts-expect-error + document.boddfsqy.innerHTML = 'dfjksqlm'; + } catch (e) { + return e.stack; } + } - Error.prepareStackTrace = (error, stackTraces) => { - // This should already be ok if simply tests succeed but also check - // everything here is as expected in case we missed something. - errorSeen = { - name: error.name, - message: error.message, - stack: error.stack, - }; - // Fixing error.stack is one thing, but holly hell this leaks even more, good thing we can hide this - stackTracesSeen = stackTraces.map(callsite => { - const callsiteInfo = {}; - for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(callsite))) { - // This doesn't work with JSON.toString, but also doesn't contain anything interesting so just drop it. - if (key === 'getThis') continue; - try { - callsiteInfo[key] = callsite[key](); - } catch { - // dont care - } + Error.prepareStackTrace = (error, stackTraces) => { + // This should already be ok if simply tests succeed but also check + // everything here is as expected in case we missed something. + errorSeen = { + name: error.name, + message: error.message, + stack: error.stack, + }; + // Fixing error.stack is one thing, but holly hell this leaks even more, good thing we can hide this + stackTracesSeen = stackTraces.map(callsite => { + const callsiteInfo = {}; + for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(callsite))) { + // This doesn't work with JSON.toString, but also doesn't contain anything interesting so just drop it. + if (key === 'getThis') continue; + try { + callsiteInfo[key] = callsite[key](); + } catch { + // dont care } - return callsiteInfo; - }); + } + return callsiteInfo; + }); - return error.stack; - }; + return error.stack; + }; + // @ts-expect-error + console.info.leak = leak; + try { // @ts-expect-error - console.info.leak = leak; - try { - // @ts-expect-error - console.info.leak(); - } catch (e) { - // trigger stack - const _stack = e.stack; - } - - return { errorSeen, stackTracesSeen }; + console.info.leak(); + } catch (e) { + // trigger stack + const _stack = e.stack; } - const { output, referenceOutput } = await runScriptWithReference(script); - expect(output).toEqual(referenceOutput); - }, - 9999999, -); + return { errorSeen, stackTracesSeen }; + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); test('Error should also not leak when using call with a proxy', async () => { function script() { @@ -385,7 +379,22 @@ test('Error should also not leak when using bind with a proxy', async () => { } } - return leak.bind(console.info)(); + const bound = leak.bind(console.info); + return bound(); + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + +test('Bind should work as expected', async () => { + function script() { + function bla() { + return this.data; + } + const t = { data: 'test' }; + + return bla.bind(t).bind(undefined)(); } const { output, referenceOutput } = await runScriptWithReference(script); @@ -485,6 +494,119 @@ test('stacktrace length should be the same', async () => { expect(output).toEqual(referenceOutput); }); +test('string expansion should trigger', async () => { + function script() { + let toStringTriggered = false; + const t = { + toString() { + toStringTriggered = true; + return 'test'; + }, + }; + console.info('%s', t); + return { toStringTriggered }; + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + +test('string expansion trigger should not reveal different .toString location', async () => { + function script() { + const error = new Error(); + let wrongStack = false; + let seenStackInName = ''; + let nameStack = ''; + + Object.defineProperty(error, 'stack', { + get() { + return 'proxied stack'; + }, + }); + const expectedStack = Object.getOwnPropertyDescriptor(error, 'stack'); + + Object.defineProperty(error, 'name', { + get() { + if (Object.getOwnPropertyDescriptor(this, 'stack').get !== expectedStack.get) { + wrongStack = true; + } + seenStackInName = this.stack; + nameStack = new Error().stack; + return 'name'; + }, + }); + + console.info(error); + return { wrongStack, seenStackInName, nameStack }; + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + +test('Should not leak we are modifying functions because this changes for primitives', async () => { + function script() { + const proxy = new Proxy(Function.prototype.toString, { + apply(target, thisArg, argArray) { + return typeof thisArg; + }, + }); + + return proxy.call('stringThisArg'); + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + +test('should not leak we modified error constructor', async () => { + function script() { + const descriptor = Object.getOwnPropertyDescriptor(window, 'Error'); + const propertyDescriptors = Object.getOwnPropertyDescriptors(Error); + + const fnStack = Error('stack 1').stack; + const classStack = new Error('stack 2').stack; + + class Test extends Error {} + const testStack = new Test('test').stack; + + return { descriptor, propertyDescriptors, fnStack, classStack, testStack }; + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + +test('should trigger user unhandledrejection', async () => { + async function script() { + const result = { + triggered: false, + defaultPrevented: null, + defaultPreventedAfterCall: null, + }; + window.addEventListener('unhandledrejection', event => { + result.triggered = true; + result.defaultPrevented = event.defaultPrevented; + + event.preventDefault(); + result.defaultPreventedAfterCall = event.defaultPrevented; + }); + + async function thrower() { + throw new Error('async error'); + } + void thrower(); + + await new Promise(resolve => { + setTimeout(resolve, 200); + }); + return result; + } + + const { output, referenceOutput } = await runScriptWithReference(script); + expect(output).toEqual(referenceOutput); +}); + // ** TEMPLATE for test **/ test('template test', async () => { @@ -518,7 +640,7 @@ async function runScript(poolToUse: Pool, fn: T) const agent = poolToUse.createAgent({ logger, options: { showChrome: debug && poolToUse === pool, showDevtools: debug }, - customEmulatorConfig: { + pluginConfigs: { [BrowserEmulator.id]: { ...defaultConfig, [InjectedScript.ERROR]: config, @@ -555,3 +677,57 @@ async function runScript(poolToUse: Pool, fn: T) return output; } + +// Play with this test +// eslint-disable-next-line jest/no-disabled-tests, jest/expect-expect +test.skip('headful chrome for debugging', async () => { + const agent = pool.createAgent({ + logger, + options: { + // useRemoteDebuggingPort: true, + showChrome: true, + // disableMitm: true, + // useRemoteDebuggingPort: true + }, + + pluginConfigs: { + [BrowserEmulator.id]: { + ...allDisabled, + 'Document.prototype.cookie': true, + 'JSON.stringify': false, + 'MediaDevices.prototype.enumerateDevices': true, + 'navigator.deviceMemory': true, + 'navigator.hardwareConcurrency': true, + 'polyfill.add': false, + 'polyfill.modify': false, + 'polyfill.remove': false, + 'polyfill.reorder': false, + 'RTCRtpSender.getCapabilities': true, + 'SharedWorker.prototype': false, + 'speechSynthesis.getVoices': true, + 'WebGLRenderingContext.prototype.getParameter': false, + 'window.screen': true, + console: true, + error: true, + navigator: true, + performance: true, + UnhandledErrorsAndRejections: true, + webrtc: true, + + // ...defaultConfig, + // [InjectedScript.ERROR]: config, + } satisfies IBrowserEmulatorConfig, + }, + }); + Helpers.needsClosing.push(agent); + const page = await agent.newPage(); + page.on('console', console.log); + const url = 'about:blank'; + await page.goto(url).catch(() => undefined); + // eslint-disable-next-line promise/param-names + await new Promise(r => undefined); +}, 999999999); + +const allDisabled = Object.values(InjectedScript).reduce((acc, value) => { + return { ...acc, [value]: false }; +}, {} as IBrowserEmulatorConfig); diff --git a/plugins/default-browser-emulator/test/utils.test.ts b/plugins/default-browser-emulator/test/utils.test.ts index 1b4ffa15a..309fa7373 100644 --- a/plugins/default-browser-emulator/test/utils.test.ts +++ b/plugins/default-browser-emulator/test/utils.test.ts @@ -7,7 +7,7 @@ import { defaultHooks } from '@ulixee/unblocked-agent-testing/browserUtils'; import { getOverrideScript, injectedSourceUrl } from '../lib/DomOverridesBuilder'; // @ts-ignore // eslint-disable-next-line import/extensions -import { proxyFunction } from '../injected-scripts/_proxyUtils'; +import { replaceFunction } from '../injected-scripts/_proxyUtils'; import BrowserEmulator from '../index'; import DomExtractor = require('./DomExtractor'); import { InjectedScript } from '../interfaces/IBrowserEmulatorConfig'; @@ -48,7 +48,7 @@ test('should be able to override a function', async () => { if (debug) console.log(inspect(hierarchy, false, null, true)); expect(win.holder.tester.doSomeWork('we')).toBe('we nope'); - proxyFunction(win.TestClass.prototype, 'doSomeWork', (target, thisArg, args) => { + replaceFunction(win.TestClass.prototype, 'doSomeWork', (target, thisArg, args) => { return `${target.apply(thisArg, args)} yep`; }); @@ -98,3 +98,24 @@ test('should override a function and clean error stacks', async () => { })();`); expect(perms).not.toContain(injectedSourceUrl); }); + +test('should be able to combine multiple single instance overrides', async () => { + class TestClass { + public doSomeWork(param: string) { + return `${param} nope`; + } + } + + const a = new TestClass(); + const b = new TestClass(); + const c = new TestClass(); + + replaceFunction(TestClass.prototype, 'doSomeWork', () => 'base'); + replaceFunction(a, 'doSomeWork', t => 'a', { onlyForInstance: true }); + replaceFunction(b, 'doSomeWork', () => 'b', { onlyForInstance: true }); + + expect(a.doSomeWork('')).toBe('a'); + expect(b.doSomeWork('')).toBe('b'); + expect(c.doSomeWork('')).toBe('base'); + expect(a.doSomeWork === b.doSomeWork && b.doSomeWork === c.doSomeWork).toBeTruthy(); +}); diff --git a/yarn.lock b/yarn.lock index 353cd1047..18276593a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1762,7 +1762,7 @@ dependencies: "@babel/types" "^7.20.7" -"@types/better-sqlite3@^7.6.11": +"@types/better-sqlite3@^7.6.9": version "7.6.11" resolved "https://registry.yarnpkg.com/@types/better-sqlite3/-/better-sqlite3-7.6.11.tgz#95acf22fcf5577624eea202058e26ba239760b9f" integrity sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg== @@ -2229,6 +2229,17 @@ progress "^2.0.3" tar "^6.1.11" +"@ulixee/commons@2.0.0-alpha.29": + version "2.0.0-alpha.29" + resolved "https://registry.yarnpkg.com/@ulixee/commons/-/commons-2.0.0-alpha.29.tgz#522321a6bbb1d9b2a24b249e6703615b44eae5f4" + integrity sha512-8sPJlVhcP/YY6FE/iMsxP6sMTEdZOjmk575HAo0V76/Y04Kzq3XoKjAq6OggTrba2Xfr//aHn3Q3yubsZXlKcw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.18" + bech32 "^2.0.0" + devtools-protocol "^0.0.1137505" + https-proxy-agent "^5.0.0" + semver "^7.3.7" + "@ulixee/repo-tools@^1.0.31": version "1.0.31" resolved "https://registry.yarnpkg.com/@ulixee/repo-tools/-/repo-tools-1.0.31.tgz#53e261239af01d9ecc6443fa7be393f08b0f9419" @@ -2738,6 +2749,11 @@ big.js@^5.2.2: resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@^9.0.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" + integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== + binary@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz" @@ -8864,6 +8880,11 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== +typescript@^5.3.3: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" + integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== + typescript@^5.4.5: version "5.4.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" @@ -9272,6 +9293,11 @@ ws@^7.2.0: resolved "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@^7.5.9: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + ws@^8.18.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" @@ -9370,3 +9396,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.20.2: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==