mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix(browser): improve Cloudflare Turnstile compatibility in embedded browser
Cloudflare Turnstile challenges fail with error 600010 ("bot behavior
detected") in Orca's embedded browser. Multiple detection vectors
contribute to this:
1. Navigation guard blocks blob: URLs that Turnstile uses to load
challenge resources inside iframes.
2. Anti-detection script injection via executeJavaScript on
did-start-navigation runs on the OLD page context — overrides are
never present when the new page loads.
3. CDP proxy endpoints leak "Orca/CdpWsProxy" and "Orca/Electron"
identifiers.
4. User-Agent contains "Electron/X.X.X" and the app name even without
an imported browser session.
5. Permission check handler returns false for all non-clipboard
permissions, making the fingerprint look bot-like.
Fixes:
- Allow blob: URLs through the navigation guard
- Switch to CDP Page.addScriptToEvaluateOnNewDocument for reliable
pre-page-JS injection (re-attaches after debugger detach)
- Extract shared anti-detection script with expanded signals:
navigator.webdriver, plugins, chrome.csi/loadTimes, Notification
permission, navigator.languages
- Mask CDP proxy identifiers as Chrome/Headless
- Strip Electron/app tokens from default UA and non-default partitions
- Widen passive permission checks to reduce bot-detection signal
- Extract UA/Client-Hints helpers to browser-session-ua.ts
This commit is contained in:
parent
7a9bc4ef6d
commit
3547d9c270
9 changed files with 272 additions and 56 deletions
83
src/main/browser/anti-detection.ts
Normal file
83
src/main/browser/anti-detection.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Why: Cloudflare Turnstile and similar bot detectors probe multiple browser
|
||||
// APIs beyond navigator.webdriver. This script runs via
|
||||
// Page.addScriptToEvaluateOnNewDocument before any page JS to mask automation
|
||||
// signals that CDP debugger attachment and Electron's webview expose.
|
||||
export const ANTI_DETECTION_SCRIPT = `(function() {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
// Why: Electron webviews expose an empty plugins array. Real Chrome always
|
||||
// has at least a few default plugins (PDF Viewer, etc.). An empty array is
|
||||
// a strong automation signal.
|
||||
if (navigator.plugins.length === 0) {
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
|
||||
]
|
||||
});
|
||||
}
|
||||
// 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
|
||||
};
|
||||
};
|
||||
}
|
||||
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 notifications,
|
||||
// which differs from real Chrome where the default is 'prompt'. Bot
|
||||
// detectors compare this against expected defaults.
|
||||
const origQuery = Permissions.prototype.query;
|
||||
Permissions.prototype.query = function(desc) {
|
||||
if (desc.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||
}
|
||||
return origQuery.call(this, desc);
|
||||
};
|
||||
// Why: Electron may report Notification.permission as 'denied' by default
|
||||
// whereas real Chrome reports 'default' for sites that haven't been granted
|
||||
// or blocked. Turnstile cross-references this with the Permissions API.
|
||||
try {
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
get: () => 'default'
|
||||
});
|
||||
} catch {}
|
||||
// Why: Electron webviews may have an empty languages array. Real Chrome
|
||||
// always has at least one entry. An empty array is an automation signal.
|
||||
if (!navigator.languages || navigator.languages.length === 0) {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['en-US', 'en']
|
||||
});
|
||||
}
|
||||
})()`
|
||||
|
|
@ -46,6 +46,7 @@ import type {
|
|||
BrowserSessionProfileSource
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser detection
|
||||
|
|
@ -1578,7 +1579,7 @@ export async function importCookiesFromBrowser(
|
|||
const ua = getUserAgentForBrowser(browser.family)
|
||||
if (ua) {
|
||||
targetSession.setUserAgent(ua)
|
||||
browserSessionRegistry.setupClientHintsOverride(targetSession, ua)
|
||||
setupClientHintsOverride(targetSession, ua)
|
||||
browserSessionRegistry.persistUserAgent(ua)
|
||||
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,50 @@ export class BrowserManager {
|
|||
private readonly downloadsById = new Map<string, ActiveDownload>()
|
||||
private readonly grabSessionController = new BrowserGrabSessionController()
|
||||
|
||||
// Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the
|
||||
// only reliable way to run JS before page scripts on every navigation.
|
||||
// The previous approach — executeJavaScript on did-start-navigation — ran
|
||||
// on the OLD page context during navigation, so overrides were never
|
||||
// present when the new page's Turnstile script executed.
|
||||
private injectAntiDetection(guest: Electron.WebContents): void {
|
||||
const attach = (): void => {
|
||||
if (guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!guest.debugger.isAttached()) {
|
||||
guest.debugger.attach('1.3')
|
||||
}
|
||||
const { ANTI_DETECTION_SCRIPT } = require('./anti-detection')
|
||||
void guest.debugger
|
||||
.sendCommand('Page.enable', {})
|
||||
.then(() =>
|
||||
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
)
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
/* best-effort — debugger may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
attach()
|
||||
// Why: the CDP proxy and bridge detach the debugger when they stop,
|
||||
// which removes addScriptToEvaluateOnNewDocument injections. Re-attach
|
||||
// so manual browsing retains anti-detection overrides after agent
|
||||
// sessions end.
|
||||
guest.debugger.on('detach', () => {
|
||||
if (!guest.isDestroyed()) {
|
||||
setTimeout(attach, 100)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
|
||||
return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null
|
||||
}
|
||||
|
|
@ -333,6 +377,13 @@ export class BrowserManager {
|
|||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
|
||||
// Why: Cloudflare Turnstile and similar bot detectors probe browser APIs
|
||||
// (navigator.webdriver, plugins, window.chrome) that differ in Electron
|
||||
// webviews vs real Chrome. Inject overrides on every page load so manual
|
||||
// browsing passes challenges even without the CDP debugger attached.
|
||||
this.injectAntiDetection(guest)
|
||||
|
||||
// Why: background throttling must be disabled so agent-driven screenshots
|
||||
// (Page.captureScreenshot via CDP proxy) can capture frames even when the
|
||||
// Orca window is not the focused foreground app. With throttling enabled,
|
||||
|
|
@ -373,6 +424,12 @@ export class BrowserManager {
|
|||
})
|
||||
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
// Why: blob: URLs are same-origin (inherit the creator's origin) and are
|
||||
// used by Cloudflare Turnstile to load challenge resources inside iframes.
|
||||
// Blocking them triggers error 600010 ("bot behavior detected").
|
||||
if (url.startsWith('blob:')) {
|
||||
return
|
||||
}
|
||||
if (!normalizeBrowserNavigationUrl(url)) {
|
||||
// Why: `will-attach-webview` only validates the initial src. Main must
|
||||
// keep enforcing the same allowlist for later guest navigations too.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ vi.mock('./browser-manager', () => ({
|
|||
}))
|
||||
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
|
||||
describe('BrowserSessionRegistry', () => {
|
||||
|
|
@ -153,7 +154,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const edgeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(mockSess, edgeUa)
|
||||
setupClientHintsOverride(mockSess, edgeUa)
|
||||
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
|
|
@ -178,7 +179,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(mockSess, chromeUa)
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
|
|
@ -192,10 +193,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
|
||||
browserSessionRegistry.setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (compatible; MSIE 10.0)'
|
||||
)
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
|
||||
|
||||
expect(onBeforeSendHeaders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -203,10 +201,7 @@ describe('BrowserSessionRegistry', () => {
|
|||
it('leaves non-Client-Hints headers unchanged', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
browserSessionRegistry.setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36'
|
||||
)
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { app, type Session, session } from 'electron'
|
||||
/* eslint-disable max-lines -- Why: the registry is the single source of truth for
|
||||
browser session profiles, partition allowlisting, cookie import staging, and
|
||||
per-partition permission/download policies. Splitting further would scatter the
|
||||
security boundary across modules. */
|
||||
import { app, session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
|
|
@ -12,6 +16,7 @@ import { join } from 'node:path'
|
|||
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
|
||||
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
|
||||
import { browserManager } from './browser-manager'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
|
||||
type BrowserSessionMeta = {
|
||||
defaultSource: BrowserSessionProfile['source']
|
||||
|
|
@ -107,7 +112,18 @@ class BrowserSessionRegistry {
|
|||
if (meta.userAgent) {
|
||||
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
|
||||
sess.setUserAgent(meta.userAgent)
|
||||
this.setupClientHintsOverride(sess, meta.userAgent)
|
||||
setupClientHintsOverride(sess, meta.userAgent)
|
||||
} else {
|
||||
// Why: even without an imported session, the default Electron UA contains
|
||||
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
|
||||
try {
|
||||
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
} catch {
|
||||
/* session not available yet (e.g. unit tests or pre-ready) */
|
||||
}
|
||||
}
|
||||
if (meta.defaultSource) {
|
||||
const current = this.profiles.get('default')
|
||||
|
|
@ -120,46 +136,6 @@ class BrowserSessionRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
|
||||
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
|
||||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
|
||||
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = secChUaFull
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the import writes cookies to a staging DB because CookieMonster holds
|
||||
// the live DB's data in memory and would overwrite our changes on its next
|
||||
// flush. This method MUST run before any session.fromPartition() call so
|
||||
|
|
@ -373,10 +349,30 @@ class BrowserSessionRegistry {
|
|||
this.configuredPartitions.add(partition)
|
||||
|
||||
const sess = session.fromPartition(partition)
|
||||
if (typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
}
|
||||
// Why: clipboard-read and clipboard-sanitized-write are required for agent-browser's
|
||||
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
|
||||
// throws NotAllowedError even when invoked via CDP with userGesture:true.
|
||||
const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write'])
|
||||
// Why: Turnstile and other bot detectors probe permissions via
|
||||
// navigator.permissions.query(). In real Chrome most permissions return
|
||||
// 'prompt', but Electron's default is 'denied' for everything not in
|
||||
// autoGranted. Returning true for passive CHECK queries (read-only probes)
|
||||
// makes the fingerprint look like a normal browser. REQUEST handlers
|
||||
// remain gated so actual permission grants still require approval.
|
||||
const passiveAllowed = new Set([
|
||||
...autoGranted,
|
||||
'notifications',
|
||||
'geolocation',
|
||||
'media',
|
||||
'midi',
|
||||
'idle-detection',
|
||||
'storage-access'
|
||||
])
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const allowed = autoGranted.has(permission)
|
||||
if (!allowed) {
|
||||
|
|
@ -389,7 +385,7 @@ class BrowserSessionRegistry {
|
|||
callback(allowed)
|
||||
})
|
||||
sess.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return autoGranted.has(permission)
|
||||
return passiveAllowed.has(permission)
|
||||
})
|
||||
sess.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
callback({ video: undefined, audio: undefined })
|
||||
|
|
|
|||
55
src/main/browser/browser-session-ua.ts
Normal file
55
src/main/browser/browser-session-ua.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { Session } from 'electron'
|
||||
|
||||
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
|
||||
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
|
||||
// flag as non-human traffic. Strip those tokens so the webview's UA and
|
||||
// sec-ch-ua Client Hints look like standard Chrome.
|
||||
export function cleanElectronUserAgent(ua: string): string {
|
||||
return (
|
||||
ua
|
||||
.replace(/\s+Electron\/\S+/, '')
|
||||
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
|
||||
// including pre-release semver strings that [\d.]+ would miss.
|
||||
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
|
||||
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
|
||||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
export function setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
|
||||
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = secChUaFull
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
|
@ -159,6 +159,8 @@ function createMockGuest(id: number, url: string, title: string) {
|
|||
return {}
|
||||
case 'Target.setAutoAttach':
|
||||
return {}
|
||||
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||
return { identifier: 'mock-script-id' }
|
||||
case 'Runtime.enable':
|
||||
return {}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
type SnapshotResult
|
||||
} from './snapshot-engine'
|
||||
import type { BrowserManager } from './browser-manager'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
export class BrowserError extends Error {
|
||||
constructor(
|
||||
|
|
@ -1158,6 +1159,13 @@ export class CdpBridge {
|
|||
flatten: true
|
||||
})
|
||||
|
||||
// Why: attaching the CDP debugger sets navigator.webdriver = true and
|
||||
// exposes other automation signals that Cloudflare Turnstile checks.
|
||||
// Override them on every new document load so challenges succeed.
|
||||
await sender('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
|
||||
// Why: remove any stale listeners from a previous attach cycle to prevent
|
||||
// listener accumulation. After a detach+reattach, the old handlers would
|
||||
// still fire alongside the new ones, causing duplicate log entries,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws'
|
|||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import type { WebContents } from 'electron'
|
||||
import { captureScreenshot } from './cdp-screenshot'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
|
||||
export class CdpWsProxy {
|
||||
private httpServer: Server | null = null
|
||||
|
|
@ -96,9 +97,12 @@ export class CdpWsProxy {
|
|||
const url = req.url ?? ''
|
||||
if (url === '/json/version' || url === '/json/version/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
// Why: agent-browser reads this endpoint to identify the browser. Returning
|
||||
// "Orca/CdpWsProxy" leaks that this is an embedded automation surface, which
|
||||
// could affect downstream detection heuristics.
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
Browser: 'Orca/CdpWsProxy',
|
||||
Browser: 'Chrome/Headless',
|
||||
'Protocol-Version': '1.3',
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
|
||||
})
|
||||
|
|
@ -134,6 +138,19 @@ export class CdpWsProxy {
|
|||
}
|
||||
}
|
||||
this.attached = true
|
||||
|
||||
// Why: attaching the CDP debugger sets navigator.webdriver = true and
|
||||
// exposes other automation signals that Cloudflare Turnstile checks.
|
||||
// Inject before any page loads so challenges succeed.
|
||||
try {
|
||||
await this.webContents.debugger.sendCommand('Page.enable', {})
|
||||
await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
|
||||
source: ANTI_DETECTION_SCRIPT
|
||||
})
|
||||
} catch {
|
||||
/* best-effort — page domain may not be ready yet */
|
||||
}
|
||||
|
||||
this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => {
|
||||
const [method, params, sessionId] = rest as [
|
||||
string,
|
||||
|
|
@ -209,11 +226,13 @@ export class CdpWsProxy {
|
|||
return
|
||||
}
|
||||
if (msg.method === 'Browser.getVersion') {
|
||||
// Why: returning "Orca/Electron" identifies this as an embedded automation
|
||||
// surface to agent-browser. Use a generic Chrome product string instead.
|
||||
this.sendResult(
|
||||
clientId,
|
||||
{
|
||||
protocolVersion: '1.3',
|
||||
product: 'Orca/Electron',
|
||||
product: 'Chrome/Headless',
|
||||
userAgent: '',
|
||||
jsVersion: ''
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue