fix(browser): align anti-detection identity signals

This commit is contained in:
Brennan Benson
2026-08-14 18:46:59 -07:00
parent 1d8eaa81c7
commit 95fe4af6ec
2 changed files with 91 additions and 38 deletions
+55
View File
@@ -2,6 +2,7 @@ import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { googleAuthUserAgent } from './browser-google-auth-ua'
type PermissionQueryResult = {
state: string
@@ -14,15 +15,24 @@ type AntiDetectionContext = {
requestPermission: (callback?: (permission: string) => void) => Promise<string>
}
navigator: {
userAgent: string
permissions: {
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
}
}
window: {
chrome?: {
runtime?: unknown
csi?: () => unknown
loadTimes?: () => unknown
}
}
}
function createContext(args: {
nativeNotificationPermission: string
requestedNotificationPermission: string
userAgent?: string
}): AntiDetectionContext & Record<string, unknown> {
class Permissions {
query(): Promise<PermissionQueryResult> {
@@ -50,6 +60,9 @@ function createContext(args: {
performance: { now: () => 0 },
window: {},
navigator: {
userAgent:
args.userAgent ??
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
plugins: [],
languages: [],
permissions: new Permissions()
@@ -60,6 +73,48 @@ function createContext(args: {
}
describe('ANTI_DETECTION_SCRIPT', () => {
it('does not expose Chrome globals under a Firefox identity', () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied',
userAgent: googleAuthUserAgent()
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.window.chrome).toBeUndefined()
})
it('keeps Chrome API stubs aligned with an ordinary Chrome page', () => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
expect(context.window.chrome?.runtime).toBeUndefined()
expect(context.window.chrome?.csi).toBeTypeOf('function')
expect(context.window.chrome?.loadTimes).toBeTypeOf('function')
})
it.each(['geolocation', 'idle-detection', 'midi', 'storage-access'])(
'preserves the native denied state for %s',
async (name) => {
const context = createContext({
nativeNotificationPermission: 'denied',
requestedNotificationPermission: 'denied'
})
runInNewContext(ANTI_DETECTION_SCRIPT, context)
await expect(context.navigator.permissions.query({ name })).resolves.toEqual({
state: 'denied',
onchange: null
})
}
)
it('reports notification permission as granted after a site permission request succeeds', async () => {
const context = createContext({
nativeNotificationPermission: 'denied',
+36 -38
View File
@@ -16,44 +16,43 @@ export const ANTI_DETECTION_SCRIPT = `(function() {
]
});
}
// Why: Electron webviews may not have the window.chrome object that real
// Chrome exposes. Turnstile checks for its presence. The csi() and
// loadTimes() stubs satisfy deeper probes that check for these Chrome-
// specific APIs beyond just chrome.runtime.
if (!window.chrome) {
window.chrome = {};
}
if (!window.chrome.runtime) {
window.chrome.runtime = {};
}
if (!window.chrome.csi) {
window.chrome.csi = function() {
return {
startE: Date.now(),
onloadT: Date.now(),
pageT: performance.now(),
tran: 15
// Why: auth hosts present Firefox, where exposing Chrome-only globals is a direct identity mismatch.
if (!navigator.userAgent.includes('Firefox/')) {
// Why: Electron webviews may not have the window.chrome object that real
// Chrome exposes. Turnstile checks for its presence. The csi() and
// loadTimes() stubs satisfy deeper probes of Chrome-specific APIs.
if (!window.chrome) {
window.chrome = {};
}
if (!window.chrome.csi) {
window.chrome.csi = function() {
return {
startE: Date.now(),
onloadT: Date.now(),
pageT: performance.now(),
tran: 15
};
};
};
}
if (!window.chrome.loadTimes) {
window.chrome.loadTimes = function() {
return {
commitLoadTime: Date.now() / 1000,
connectionInfo: 'h2',
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
firstPaintTime: Date.now() / 1000,
navigationType: 'Other',
npnNegotiatedProtocol: 'h2',
requestTime: Date.now() / 1000 - 0.16,
startLoadTime: Date.now() / 1000 - 0.3,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true,
wasNpnNegotiated: true
}
if (!window.chrome.loadTimes) {
window.chrome.loadTimes = function() {
return {
commitLoadTime: Date.now() / 1000,
connectionInfo: 'h2',
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
firstPaintTime: Date.now() / 1000,
navigationType: 'Other',
npnNegotiatedProtocol: 'h2',
requestTime: Date.now() / 1000 - 0.16,
startLoadTime: Date.now() / 1000 - 0.3,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true,
wasNpnNegotiated: true
};
};
};
}
}
// Why: Electron's Permission API defaults to 'denied' for most permissions,
// but real Chrome returns 'prompt' for ungranted permissions. Returning
@@ -77,8 +76,7 @@ export const ANTI_DETECTION_SCRIPT = `(function() {
}
} catch {}
const promptPerms = new Set([
'geolocation', 'camera', 'microphone',
'midi', 'idle-detection', 'storage-access'
'camera', 'microphone'
]);
const origQuery = Permissions.prototype.query;
Permissions.prototype.query = function(desc) {