From e16a22ef586dd4329a8a80076f0cc2a1f0d22d8b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:35:34 -0700 Subject: [PATCH] fix(browser): return a real PermissionStatus from the query override (#14684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): shadow PermissionStatus state instead of proxying it Rebased onto main after #14685 landed in the same file. Both changes coexist: #14685's Firefox gating and narrowed promptPerms are preserved untouched, and this change replaces only the query-override implementation. The previous Proxy bound every callable property, which broke three observable things: - onchange assignment threw "Illegal invocation" — the native setter received the proxy rather than the branded target. - Listeners were delivered with the real target and the NATIVE state, so on a real permission change event.target.state read 'granted' while the returned status still read 'prompt'. - Method reads returned a new bound function each time, so name became 'bound addEventListener', toString lost its name, and identity was unstable. Separately, the notifications state was captured once as a string, so an already-returned status went stale after requestPermission updated it. Shadow only 'state' on the genuine PermissionStatus with a lazy provider. The object the site holds IS the real one, so identity, brand checks, method fingerprints and native event delivery survive with nothing to keep in sync. Verified against real Chromium (Chrome 151) rather than only the vm stand-in, because the stand-in cannot show whether defineProperty succeeds on a branded instance: the instance is extensible, the prototype 'state' accessor is configurable, defineProperty succeeds, instanceof survives, addEventListener keeps its native name and referential stability, and onchange assignment works. New tests live in their own file rather than merged into anti-detection.test.ts, whose harness diverged on main. The fallback test targets 'camera' because #14685 narrowed the intercepted set, so a name outside it never reaches the fallback. * test(browser): cover intercepted PermissionStatus identity --- .../anti-detection-permission-status.test.ts | 210 ++++++++++++++++++ src/main/browser/anti-detection.ts | 35 ++- 2 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/main/browser/anti-detection-permission-status.test.ts diff --git a/src/main/browser/anti-detection-permission-status.test.ts b/src/main/browser/anti-detection-permission-status.test.ts new file mode 100644 index 00000000000..d686e32c975 --- /dev/null +++ b/src/main/browser/anti-detection-permission-status.test.ts @@ -0,0 +1,210 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' + +import { ANTI_DETECTION_SCRIPT } from './anti-detection' + +type PermissionQueryResult = EventTarget & { + state: string + onchange: EventListener | null + marker: string +} + +type PermissionStatusConstructor = { + new (): PermissionQueryResult + prototype: PermissionQueryResult +} + +type AntiDetectionContext = { + Notification: { + permission: string + requestPermission: (callback?: (permission: string) => void) => Promise + } + PermissionStatus: PermissionStatusConstructor + dispatchPermissionChange: (name: string) => void + navigator: { + permissions: { + query: (descriptor: { name: string }) => Promise + } + } +} + +function createContext(args: { + nativeNotificationPermission: string + requestedNotificationPermission: string + rejectedPermissions?: string[] +}): AntiDetectionContext & Record { + class PermissionStatus extends EventTarget { + #state = 'denied' + #onchange: EventListener | null = null + marker = 'real-status' + + get state(): string { + return this.#state + } + + get onchange(): EventListener | null { + return this.#onchange + } + + set onchange(listener: EventListener | null) { + if (this.#onchange) { + super.removeEventListener('change', this.#onchange) + } + this.#onchange = typeof listener === 'function' ? listener : null + if (this.#onchange) { + super.addEventListener('change', this.#onchange) + } + } + } + + const statuses = new Map() + const rejectedPermissions = new Set(args.rejectedPermissions) + + class Permissions { + query(descriptor: { name: string }): Promise { + if (rejectedPermissions.has(descriptor.name)) { + return Promise.reject(new Error('Unsupported permission')) + } + const status = new PermissionStatus() + const permissionStatuses = statuses.get(descriptor.name) ?? [] + permissionStatuses.push(status) + statuses.set(descriptor.name, permissionStatuses) + return Promise.resolve(status) + } + } + + const Notification = { + permission: args.nativeNotificationPermission, + requestPermission(callback?: (permission: string) => void): Promise { + callback?.(args.requestedNotificationPermission) + return Promise.resolve(args.requestedNotificationPermission) + } + } + Object.defineProperty(Notification, 'permission', { + configurable: true, + get: () => args.nativeNotificationPermission + }) + + return { + Date, + Event, + EventTarget, + Object, + Promise, + Set, + performance: { now: () => 0 }, + // Why: the script's Firefox gate reads navigator.userAgent, so a non-Firefox UA keeps these + // tests on the ordinary-page path where the PermissionStatus override applies. + window: { chrome: {} }, + navigator: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + plugins: [], + languages: [], + permissions: new Permissions() + }, + Permissions, + PermissionStatus, + Notification, + dispatchPermissionChange(name: string): void { + for (const status of statuses.get(name) ?? []) { + status.dispatchEvent(new Event('change')) + } + } + } as AntiDetectionContext & Record +} + +describe('ANTI_DETECTION_SCRIPT — PermissionStatus', () => { + it('keeps an existing notification status current after permission changes', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + + expect(context.Notification.permission).toBe('default') + expect(status.state).toBe('prompt') + + await expect(context.Notification.requestPermission()).resolves.toBe('granted') + + expect(context.Notification.permission).toBe('granted') + expect(status.state).toBe('granted') + }) + + it('preserves native PermissionStatus identity and methods', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + const expectedSource = Function.prototype.toString.call( + context.PermissionStatus.prototype.addEventListener + ) + + expect(status).toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(status.constructor.name).toBe('PermissionStatus') + expect(status.marker).toBe('real-status') + expect(status.addEventListener.name).toBe('addEventListener') + expect(status.addEventListener).toBe(status.addEventListener) + expect(Function.prototype.toString.call(status.addEventListener)).toBe(expectedSource) + expect(expectedSource).toContain('addEventListener') + }) + + it('delivers change events through the returned status with the overridden state', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'notifications' }) + const events: { receiver: EventTarget; target: EventTarget | null; state: string }[] = [] + const recordEvent = function (this: EventTarget, event: Event): void { + events.push({ + receiver: this, + target: event.target, + state: (event.target as PermissionQueryResult).state + }) + } + + status.addEventListener('change', recordEvent) + expect(() => { + status.onchange = function (this: EventTarget, event): void { + recordEvent.call(this, event) + } + }).not.toThrow() + + await context.Notification.requestPermission() + context.dispatchPermissionChange('notifications') + + expect(events).toHaveLength(2) + expect(events).toEqual([ + { receiver: status, target: status, state: 'granted' }, + { receiver: status, target: status, state: 'granted' } + ]) + }) + + // Why: 'camera' rather than 'storage-access' — #14685 narrowed the intercepted set to + // camera/microphone, so a name outside it falls through to the real query and never reaches + // the fallback at all. + it('uses a non-enumerable EventTarget fallback when the native query rejects', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted', + rejectedPermissions: ['camera'] + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + const status = await context.navigator.permissions.query({ name: 'camera' }) + + expect(status).toBeInstanceOf(EventTarget) + expect(status).not.toBeInstanceOf(context.PermissionStatus) + expect(status.state).toBe('prompt') + expect(Object.keys(status)).toEqual([]) + }) +}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts index b955b200229..d725fbf965f 100644 --- a/src/main/browser/anti-detection.ts +++ b/src/main/browser/anti-detection.ts @@ -86,12 +86,43 @@ export const ANTI_DETECTION_SCRIPT = `(function() { 'camera', 'microphone' ]); const origQuery = Permissions.prototype.query; + // Why: sites must receive the genuine PermissionStatus so native events, brand checks and method + // identity survive. Shadow only state, and resolve it lazily so existing statuses stay current. + function withOverriddenState(realStatus, stateProvider) { + Object.defineProperty(realStatus, 'state', { + configurable: true, + get: stateProvider + }); + return realStatus; + } + // Why: some names the real implementation rejects outright; fall back to an EventTarget so + // listener registration still works instead of throwing. + function fallbackStatus(stateProvider) { + const status = new EventTarget(); + Object.defineProperties(status, { + state: { configurable: true, get: stateProvider }, + onchange: { configurable: true, value: null, writable: true } + }); + return status; + } + function queryWithState(permissions, desc, stateProvider) { + let real; + try { + real = origQuery.call(permissions, desc); + } catch { + return Promise.resolve(fallbackStatus(stateProvider)); + } + return Promise.resolve(real).then( + (status) => withOverriddenState(status, stateProvider), + () => fallbackStatus(stateProvider) + ); + } Permissions.prototype.query = function(desc) { if (desc.name === 'notifications') { - return Promise.resolve({ state: notificationPermissionState(), onchange: null }); + return queryWithState(this, desc, notificationPermissionState); } if (promptPerms.has(desc.name)) { - return Promise.resolve({ state: 'prompt', onchange: null }); + return queryWithState(this, desc, () => 'prompt'); } return origQuery.call(this, desc); };