diff --git a/cli/src/connection.rs b/cli/src/connection.rs index cadb357..06ae424 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -118,6 +118,12 @@ fn get_pid_path(session: &str) -> PathBuf { /// Clean up stale socket and PID files for a session fn cleanup_stale_files(session: &str) { + // Never delete files for a live daemon. A missing PID file can happen in + // race scenarios, but the socket is authoritative for liveness. + if daemon_ready(session) { + return; + } + let pid_path = get_pid_path(session); let _ = fs::remove_file(&pid_path); @@ -150,42 +156,6 @@ fn get_port_for_session(session: &str) -> u16 { 49152 + ((hash.unsigned_abs() as u32 % 16383) as u16) } -#[cfg(unix)] -fn is_daemon_running(session: &str) -> bool { - let pid_path = get_pid_path(session); - if !pid_path.exists() { - return false; - } - if let Ok(pid_str) = fs::read_to_string(&pid_path) { - if let Ok(pid) = pid_str.trim().parse::() { - unsafe { - if libc::kill(pid, 0) == 0 { - return true; - } - // EPERM means the process exists but we lack permission to - // signal it (e.g. inside a macOS sandbox). Only ESRCH means - // the process is genuinely gone. - return std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH); - } - } - } - false -} - -#[cfg(windows)] -fn is_daemon_running(session: &str) -> bool { - let pid_path = get_pid_path(session); - if !pid_path.exists() { - return false; - } - let port = get_port_for_session(session); - TcpStream::connect_timeout( - &format!("127.0.0.1:{}", port).parse().unwrap(), - Duration::from_millis(100), - ) - .is_ok() -} - fn daemon_ready(session: &str) -> bool { #[cfg(unix)] { @@ -230,8 +200,9 @@ pub fn ensure_daemon( tab_group: Option<&str>, tab_group_plugin_id: Option<&str>, ) -> Result { - // Check if daemon is running AND responsive - if is_daemon_running(session) && daemon_ready(session) { + // Socket readiness is the source of truth for a usable daemon. + // PID files can be missing/stale under concurrent start/stop races. + if daemon_ready(session) { // Double-check it's actually responsive by waiting and checking again // This handles the race condition where daemon is shutting down // (daemon has a 100ms shutdown delay, so we wait longer) diff --git a/cli/src/flags.rs b/cli/src/flags.rs index 5b150fe..3b9139e 100644 --- a/cli/src/flags.rs +++ b/cli/src/flags.rs @@ -301,7 +301,8 @@ pub fn parse_flags(args: &[String]) -> Flags { color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME") .ok() .or(config.color_scheme), - download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok() + download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH") + .ok() .or(config.download_path), tab_group: env::var("AGENT_BROWSER_TAB_GROUP") .ok() diff --git a/cli/src/main.rs b/cli/src/main.rs index e1fe2ef..bca2442 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -587,7 +587,6 @@ fn main() { } exit(1); } - } Err(e) => { if flags.json { @@ -679,8 +678,7 @@ fn main() { || flags.allow_file_access || flags.debug || flags.color_scheme.is_some() - || flags.download_path.is_some() - ) + || flags.download_path.is_some()) && flags.cdp.is_none() && flags.provider.is_none() && !attached_to_existing_browser @@ -766,7 +764,6 @@ fn main() { } exit(1); } - } Err(e) => { if flags.json { diff --git a/docs/blog/agent-browser-stealth-attack-defense-playbook.zh-CN.md b/docs/blog/agent-browser-stealth-attack-defense-playbook.zh-CN.md new file mode 100644 index 0000000..769fa1f --- /dev/null +++ b/docs/blog/agent-browser-stealth-attack-defense-playbook.zh-CN.md @@ -0,0 +1,189 @@ +# 从「能跑」到「长期稳定」:agent-browser-stealth 的攻防工程实践 + +高风控站点对自动化会话的判断,通常不是单一规则命中,而是多信号打分。 +要点不在“补一个 patch”,而在“让整组信号在同一会话内自洽”。 + +项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser) + +--- + +## 检测系统如何做判断 + +大多数检测系统会同时看三类问题: + +1. **一致性**:UA、语言、时区、渲染能力是否互相匹配 +2. **稀有性**:是否出现低频但高度可疑的组合(如某些 headless 特征并存) +3. **时序性**:输入、点击、等待、重试是否呈现机械节奏 + +评估通常是累积分值而非二元判断。 +同一个会话里的轻微异常可以被容忍,但跨维度冲突叠加后,容易触发挑战页或高频二次验证。 + +```mermaid +flowchart LR + A["采集信号"] --> B["一致性检查"] + A --> C["稀有性评估"] + A --> D["时序行为评估"] + B --> E["风险分值"] + C --> E + D --> E + E --> F{"超过阈值?"} + F -->|否| G["继续放行"] + F -->|是| H["挑战/验证/限流"] +``` + +这个模型对应的治理原则很直接: + +- 优先消除跨维度冲突 +- 再处理低频高危特征 +- 最后处理行为时序的机械性 + +--- + +## 指纹治理:从“补点”改成“信号闭环” + +指纹治理按 `launch -> CDP -> init script` 三层执行。 +核心目标是把“可见信号”变成一张一致的画像,而不是局部拟真。 + +```mermaid +flowchart LR + A["Launch 层"] --> B["CDP 层"] + B --> C["Init Script 层"] + C --> D["统一画像"] + D --> E["降低冲突分值"] +``` + +### Launch 层(本地启动时的基础面) + +Launch 层处理“浏览器刚启动就暴露”的特征面: + +- `--disable-blink-features=AutomationControlled` +- `--use-gl=angle` +- `--use-angle=default` +- 默认 UA 清洗(未自定义 UA 时去掉 `HeadlessChrome`) + +原理:这层不追求“真实用户画像”,而是先消除明显自动化标识,避免会话在首屏前就进入高风险。 + +### CDP 层(运行时协议面) + +CDP 层治理的是“同一身份在不同字段里的自我矛盾”: + +- 同步覆盖 `userAgent`、`acceptLanguage`、`userAgentMetadata` +- 覆盖应持续作用于新旧 target +- 设置不透明背景,降低透明渲染特征 + +典型冲突示例: + +- `userAgent` 显示某平台版本,但 `userAgentMetadata` brand/version 不对应 +- 语言首选项与请求头不一致 + +这类冲突往往比“是否 headless”更早触发评分上升。 + +### Init Script 层(页面脚本前) + +Init 层治理页面 JS 可直接探测的运行时表面。 +重点不是数量,而是覆盖高频检查路径。 + +高频治理面: + +- Runtime 身份:`navigator.webdriver`、`chrome.runtime`、`cdc_` +- Navigator 能力:`languages/plugins/mimeTypes/permissions/userAgentData` +- 渲染能力:WebGL vendor/renderer +- 窗口屏幕:`outer*` / `screen*` / `avail*` +- 能力暴露:`share/contacts/contentIndex/mediaDevices/pdfViewerEnabled` +- 边缘特征:`connection/hardwareConcurrency/performance.memory` + +### 指纹排障优先级 + +| 优先级 | 信号面 | 常见现象 | 先做什么 | +| --- | --- | --- | --- | +| P0 | UA + metadata + language | 首屏挑战页 | 先统一三者,再看其它项 | +| P1 | webdriver/runtime 痕迹 | 关键动作前即拦截 | 验证 init 注入是否在页面脚本前生效 | +| P2 | WebGL/screen | 间歇性二次验证 | 对齐渲染与窗口参数 | +| P3 | connection/memory 等边缘面 | 长链路后段异常 | 增量修复并对照回归 | + +--- + +## 行为治理:让时序分布接近真实交互 + +行为检测通常不关心单次点击,而关注一段时间序列。 +真正会被命中的,是“低方差、强周期、强同步”的机器节奏。 + +```mermaid +flowchart TD + A["动作计划"] --> B["输入节奏分布"] + A --> C["鼠标轨迹分布"] + A --> D["等待/思考时间分布"] + A --> E["重试退避分布"] + B --> F["执行时序"] + C --> F + D --> F + E --> F +``` + +### 输入节奏 + +固定字符延迟(例如全程 100ms)很容易形成可分辨模式。 +更稳妥的做法是“基线 + 抖动 + 语义停顿”: + +- 基线延迟围绕输入场景变化 +- 每字符有扰动,不保持等间隔 +- 词边界、字段切换处出现较长停顿 + +### 鼠标轨迹 + +坐标瞬移与恒速直线是高风险模式。 +轨迹应包含: + +- 曲线路径 +- 中间采样点 +- 速度变化(起步、调整、收敛) + +### 等待与思考时间 + +固定等待常量会形成明显周期。 +建议使用区间采样,让同类操作在时间上有自然波动。 + +### 重试退避 + +命中风险后继续等间隔重试,通常会放大风险分值。 +退避策略应具备: + +- 间隔递增 +- 抖动扰动 +- 次数上限 + +### 行为反模式 + +- 全链路固定输入延迟 +- 点击前无移动直接命中目标 +- 所有等待都是同一个常量 +- 重试间隔完全一致 +- 所有站点使用同一动作模板 + +--- + +## 2026-03 实战更新:Cloudflare 验证页恢复策略 + +在真实使用中,`dash.cloudflare.com` 一类站点常见 `Just a moment... / Performing security verification` 挑战页。 +关键问题不只是“被识别”,还包括“客户端过早刷新把挑战流程重置”,导致长期卡在验证中。 + +本次修复的关键点: + +1. **风险信号增强**:从 `URL + Title` 扩展为 `URL + Title + PageText` + 覆盖 `Performing security verification`、`This website uses a security service...` 等文本证据。 +2. **恢复策略调整**:`risk-mode=warn` 先等待挑战自动放行,再进入重试 + 避免“重试即刷新”打断 Cloudflare 的挑战倒计时。 +3. **会话稳定化**:未显式传 `--session-name` 时,默认跟随 `--session` + 降低会话漂移造成的重复挑战概率。 +4. **挑战窗口友好化**:减少过早刷新导致的挑战重置 + 让验证流程有足够时间自动完成,降低反复卡住的概率。 + +--- + +## 结语 + +指纹治理的重点是跨维度一致性。 +行为治理的重点是时间分布去机械化。 +把这两部分统一治理,通常能显著降低攻防波动。 + +项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser) diff --git a/docs/blog/use-agent-browser-stealth-instead-of-agent-browser.zh-CN.md b/docs/blog/use-agent-browser-stealth-instead-of-agent-browser.zh-CN.md new file mode 100644 index 0000000..c948cf7 --- /dev/null +++ b/docs/blog/use-agent-browser-stealth-instead-of-agent-browser.zh-CN.md @@ -0,0 +1,105 @@ +# 使用 agent-browser-stealth 代替 agent-browser + +很多人已经把 AI Agent 接入了浏览器自动化,但上线后会遇到同一个问题: + +同样流程在不同网站表现不一致;流程写完了,关键站点还是过不去。 + +问题通常不在“会不会自动化”,而在“能不能在真实网站里稳定自动化”。 + +这就是为什么要从 `agent-browser` 升级到 `agent-browser-stealth`。 + +项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser) + +--- + +## 为什么要替换 + +### 1) 让 AI 真正可以使用浏览器 + +很多网站已经有反爬和自动化检测策略。 +在这些场景里,传统自动化链路会出现高频验证、中断、重试失败,最终让 AI 任务卡在关键步骤。 + +`agent-browser-stealth` 的目标很明确:让 AI 在真实网站环境中保持更高可用性。 + +### 2) 应对“限制 AI 浏览器”的站点策略 + +部分站点会对自动化浏览器做额外限制,包括: + +- 触发挑战页 +- 关键页面二次验证 +- 会话中途降权或限流 + +`agent-browser-stealth` 提供更完整的防识别能力,降低这类限制对任务成功率的影响。 + +### 3) 让 Agent 和用户共享同一个浏览器 + +很多自动化失败发生在“登录前后状态切换”环节。 +`agent-browser-stealth` 支持 Agent 复用用户正在使用的浏览器会话,直接继承已登录状态,减少重复登录和验证码干扰。 + +对业务流程的价值是直接的: + +- 缩短执行路径 +- 降低登录步骤失败率 +- 提升整体成功率与执行速度 + +--- + +## 典型站点效果(示例) + +以亚马逊这类高风控电商站点为例,很多 AI 浏览器流程过去常见的问题是: + +- 能打开首页,但关键操作前触发验证 +- 搜索、跳转、加购这类连续动作中途被打断 +- 会话偶发失效,任务难以完整执行 + +切换到 `agent-browser-stealth` 后,可显著提升这类流程的可执行性,常见可完成动作包括: + +- 商品搜索与详情浏览 +- 购物车相关操作 +- 已登录状态下的页面导航与信息读取 + +除了电商站点,下面两类场景也常见明显改善: + +- 社媒/内容平台:多步骤跳转流程更稳定 +- SaaS 后台系统:登录后连续操作中断率降低 + +说明:不同账号状态、网络环境、站点实时策略会影响最终效果。 + +--- + +## 适合哪些场景 + +- AI 客服或运营 Agent 需要在多站点执行后台操作 +- 自动化流程经常卡在登录、验证、跳转环节 +- 需要“人机协同”:用户和 Agent 共用一个会话处理复杂任务 +- 对稳定性要求高的生产任务(不是 Demo) + +--- + +## 迁移成本高吗 + +迁移成本通常很低,命令习惯可以保持一致。 +多数场景可以先做“无侵入替换”,再按业务流程逐步优化。 + +--- + +## 一张表看差异 + +| 维度 | agent-browser | agent-browser-stealth | +| --- | --- | --- | +| 目标 | 标准自动化能力 | 面向真实风控环境的稳定自动化 | +| 站点兼容性 | 普通站点可用 | 高风控站点可用性更高 | +| AI 执行稳定性 | 受验证页影响明显 | 对验证/限制策略更稳 | +| 登录链路 | 常需重复处理登录步骤 | 支持复用用户浏览器状态,减少登录干扰 | +| 生产可用性 | 适合基础自动化 | 更适合生产级 AI 浏览器任务 | + +--- + +## 结论 + +如果目标是“让 AI 在真实网站里稳定完成任务”,`agent-browser-stealth` 是更合适的选择。 +如果目标只是“脚本在理想环境跑通”,`agent-browser` 已经足够。 + +在生产环境里,真正的差异通常体现在四个字:**可用与稳定**。 + +项目地址:[leeguooooo/agent-browser](https://github.com/leeguooooo/agent-browser) diff --git a/docs/src/app/sessions/page.mdx b/docs/src/app/sessions/page.mdx index 6ba8495..9e06a13 100644 --- a/docs/src/app/sessions/page.mdx +++ b/docs/src/app/sessions/page.mdx @@ -50,6 +50,8 @@ export AGENT_BROWSER_SESSION_NAME=twitter agent-browser open twitter.com ``` +If `--session-name` is omitted, it defaults to `--session` (or `default`). + State files are stored in `~/.agent-browser/sessions/` and automatically loaded on daemon start. ### Session name rules diff --git a/scripts/check-daemon-pid-recovery.js b/scripts/check-daemon-pid-recovery.js new file mode 100755 index 0000000..c7836bd --- /dev/null +++ b/scripts/check-daemon-pid-recovery.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +/** + * Regression check for daemon liveness when .pid is missing. + * + * What it verifies: + * 1) A daemon session is reachable. + * 2) Deleting .pid does not break the next command. + * 3) The session socket is not recreated (inode unchanged on Unix), + * meaning we reused the live daemon instead of tearing it down. + * + * Usage: + * node scripts/check-daemon-pid-recovery.js + * node scripts/check-daemon-pid-recovery.js --session default + * node scripts/check-daemon-pid-recovery.js --binary ./bin/agent-browser-darwin-arm64 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path, { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +const args = process.argv.slice(2); +const getArgValue = (name, fallback) => { + const index = args.indexOf(name); + if (index === -1 || index + 1 >= args.length) return fallback; + return args[index + 1]; +}; + +function resolveSocketDir() { + if (process.env.AGENT_BROWSER_SOCKET_DIR && process.env.AGENT_BROWSER_SOCKET_DIR.length > 0) { + return process.env.AGENT_BROWSER_SOCKET_DIR; + } + if (process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0) { + return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser'); + } + return path.join(os.homedir(), '.agent-browser'); +} + +function resolveDefaultBinary() { + const osKey = os.platform() === 'win32' ? 'win32' : os.platform() === 'darwin' ? 'darwin' : 'linux'; + const archKey = os.arch() === 'arm64' ? 'arm64' : 'x64'; + const ext = os.platform() === 'win32' ? '.exe' : ''; + + const candidates = [ + join(rootDir, 'bin', `agent-browser-${osKey}-${archKey}${ext}`), + join(rootDir, 'cli', 'target', 'release', `agent-browser${ext}`), + join(rootDir, 'bin', `agent-browser-local${ext}`), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + return candidates[0]; +} + +function runCommand(binary, commandArgs, allowFailure = false) { + const result = spawnSync(binary, commandArgs, { + encoding: 'utf8', + env: process.env, + }); + if (result.status !== 0 && !allowFailure) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + throw new Error( + `Command failed: ${binary} ${commandArgs.join(' ')}\n${stderr || stdout || `exit code ${result.status}`}` + ); + } + return result; +} + +function main() { + const session = getArgValue('--session', 'default'); + const binary = getArgValue('--binary', resolveDefaultBinary()); + const socketDir = resolveSocketDir(); + const isWindows = os.platform() === 'win32'; + + const pidPath = join(socketDir, `${session}.pid`); + const socketPath = isWindows ? null : join(socketDir, `${session}.sock`); + const portPath = isWindows ? join(socketDir, `${session}.port`) : null; + + if (!fs.existsSync(binary)) { + throw new Error(`Binary not found: ${binary}`); + } + + // Ensure daemon/session is live before we simulate pid loss. + runCommand(binary, ['--session', session, 'get', 'url']); + + let socketInodeBefore = null; + if (!isWindows) { + if (!socketPath || !fs.existsSync(socketPath)) { + throw new Error(`Socket file not found: ${socketPath}`); + } + socketInodeBefore = fs.statSync(socketPath).ino; + } + + const pidExistedBefore = fs.existsSync(pidPath); + if (pidExistedBefore) { + fs.unlinkSync(pidPath); + } + + // This is the critical step: should still work even though pid file is gone. + const second = runCommand(binary, ['--session', session, 'get', 'title']); + const secondOutput = (second.stdout || '').trim(); + + let socketInodeAfter = null; + let socketUnchanged = true; + if (!isWindows) { + if (!socketPath || !fs.existsSync(socketPath)) { + throw new Error(`Socket file missing after pid removal: ${socketPath}`); + } + socketInodeAfter = fs.statSync(socketPath).ino; + socketUnchanged = socketInodeBefore === socketInodeAfter; + } else if (portPath && !fs.existsSync(portPath)) { + throw new Error(`Port file missing after pid removal: ${portPath}`); + } + + const pidExistsAfter = fs.existsSync(pidPath); + const passed = socketUnchanged; + + const report = { + passed, + session, + binary, + socketDir, + pidPath, + pidExistedBefore, + pidExistsAfter, + socketPath, + socketInodeBefore, + socketInodeAfter, + socketUnchanged, + secondCommandOutput: secondOutput, + timestamp: new Date().toISOString(), + }; + + console.log(JSON.stringify(report, null, 2)); + if (!passed) { + process.exit(1); + } +} + +main(); diff --git a/src/browser.ts b/src/browser.ts index 35111ed..b4174f4 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -38,6 +38,7 @@ import { STEALTH_CHROMIUM_ARGS, applyStealthScripts, applyBrowserLevelStealth, + wrapCDPSessionSourceUrlSanitizer, type StealthScriptOptions, } from './stealth.js'; @@ -319,7 +320,7 @@ export class BrowserManager { try { const page = this.getPage(); - const cdp = await page.context().newCDPSession(page); + const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page)); if (!envTimezone) { await cdp @@ -3089,7 +3090,7 @@ export class BrowserManager { const context = page.context(); // Create a new CDP session attached to the page - this.cdpSession = await context.newCDPSession(page); + this.cdpSession = wrapCDPSessionSourceUrlSanitizer(await context.newCDPSession(page)); return this.cdpSession; } diff --git a/src/stealth.test.ts b/src/stealth.test.ts index c1663c9..dbe7823 100644 --- a/src/stealth.test.ts +++ b/src/stealth.test.ts @@ -76,6 +76,20 @@ describe('Stealth mode', () => { } }); + it('keeps navigator.vendor aligned with Chrome', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const vendorSignals = await browser.getPage().evaluate(() => ({ + userAgent: navigator.userAgent, + vendor: navigator.vendor, + })); + + if (vendorSignals.userAgent.includes('Chrome/')) { + expect(vendorSignals.vendor).toBe('Google Inc.'); + } + }); + it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => { browser = new BrowserManager(); await browser.launch({ headless: true, stealth: true }); @@ -170,6 +184,124 @@ describe('Stealth mode', () => { expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true); }); + it('exposes legacy chrome.app/csi/loadTimes APIs', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const signals = await browser.getPage().evaluate(() => { + const chromeObj = (window as any).chrome; + const csi = chromeObj && typeof chromeObj.csi === 'function' ? chromeObj.csi() : null; + const loadTimes = + chromeObj && typeof chromeObj.loadTimes === 'function' ? chromeObj.loadTimes() : null; + return { + hasChrome: !!chromeObj, + hasApp: !!(chromeObj && chromeObj.app), + appInstalled: chromeObj?.app?.isInstalled, + appRunningState: chromeObj?.app?.runningState?.(), + hasCsi: typeof chromeObj?.csi === 'function', + hasLoadTimes: typeof chromeObj?.loadTimes === 'function', + csiHasOnloadT: csi && typeof csi.onloadT === 'number', + csiHasPageT: csi && typeof csi.pageT === 'number', + loadTimesHasRequestTime: loadTimes && typeof loadTimes.requestTime === 'number', + loadTimesHasConnectionInfo: loadTimes && typeof loadTimes.connectionInfo === 'string', + }; + }); + + expect(signals.hasChrome).toBe(true); + expect(signals.hasApp).toBe(true); + expect(signals.appInstalled).toBe(false); + expect(signals.appRunningState).toBe('cannot_run'); + expect(signals.hasCsi).toBe(true); + expect(signals.hasLoadTimes).toBe(true); + expect(signals.csiHasOnloadT).toBe(true); + expect(signals.csiHasPageT).toBe(true); + expect(signals.loadTimesHasRequestTime).toBe(true); + expect(signals.loadTimesHasConnectionInfo).toBe(true); + }); + + it('spoofs high-signal media codec probes', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const codecs = await browser.getPage().evaluate(() => { + const video = document.createElement('video'); + const audio = document.createElement('audio'); + return { + mp4Avc: video.canPlayType('video/mp4; codecs="avc1.42E01E"'), + xM4a: audio.canPlayType('audio/x-m4a;'), + aac: audio.canPlayType('audio/aac'), + }; + }); + + expect(codecs.mp4Avc).toBe('probably'); + expect(codecs.xM4a).toBe('maybe'); + expect(codecs.aac).toBe('probably'); + }); + + it('patches srcdoc iframe.contentWindow probes', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const iframeSignals = await browser.getPage().evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.srcdoc = 'ok'; + const win = iframe.contentWindow; + return { + hasContentWindow: !!win, + selfEqualsWindow: win ? win.self === win : false, + selfEqualsTop: win ? win.self === window.top : null, + frameElementMatches: win ? win.frameElement === iframe : false, + zeroSlotType: typeof (win as any)?.[0], + }; + }); + + expect(iframeSignals.hasContentWindow).toBe(true); + expect(iframeSignals.selfEqualsWindow).toBe(true); + expect(iframeSignals.selfEqualsTop).toBe(false); + expect(iframeSignals.frameElementMatches).toBe(true); + expect(iframeSignals.zeroSlotType).toBe('undefined'); + }); + + it('sanitizes Playwright sourceURL markers in error stacks', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const stacks = await browser.getPage().evaluate(() => { + const explicitEvalStack = eval( + `(() => { try { throw new Error('explicit'); } catch (error) { return String(error.stack || ''); } })()\n//# sourceURL=__playwright_evaluation_script__` + ); + let directStack = ''; + try { + throw new Error('direct'); + } catch (error) { + directStack = String((error as Error).stack || ''); + } + return { explicitEvalStack, directStack }; + }); + + expect(stacks.explicitEvalStack).not.toContain('__playwright_evaluation_script__'); + expect(stacks.explicitEvalStack).not.toContain('__puppeteer_evaluation_script__'); + expect(stacks.explicitEvalStack).not.toContain('sourceURL='); + expect(stacks.directStack).not.toContain('__playwright_evaluation_script__'); + expect(stacks.directStack).not.toContain('__puppeteer_evaluation_script__'); + }); + + it('sanitizes sourceURL markers in direct CDP Runtime.evaluate payloads', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const cdp = await browser.getCDPSession(); + const response = await cdp.send('Runtime.evaluate', { + expression: + "(() => { throw new Error('cdp'); })()\\n//# sourceURL=__playwright_evaluation_script__", + returnByValue: true, + }); + const raw = JSON.stringify(response); + expect(raw).not.toContain('__playwright_evaluation_script__'); + expect(raw).not.toContain('__puppeteer_evaluation_script__'); + expect(raw).not.toContain('sourceURL='); + }); + it('exposes contacts manager and content index APIs', async () => { browser = new BrowserManager(); await browser.launch({ headless: true, stealth: true }); diff --git a/src/stealth.ts b/src/stealth.ts index 11993bf..d9349d9 100644 --- a/src/stealth.ts +++ b/src/stealth.ts @@ -12,6 +12,7 @@ export interface StealthScriptOptions { locale?: string; userAgent?: string; acceptLanguage?: string; + allowWebGLContextFallback?: boolean; } /** @@ -24,6 +25,90 @@ export const STEALTH_CHROMIUM_ARGS: string[] = [ '--use-angle=default', ]; +const CDP_SOURCE_URL_SANITIZED = Symbol('ab.cdpSourceUrlSanitized'); + +interface CDPSessionLike { + send(method: string, params?: Record): Promise; + [CDP_SOURCE_URL_SANITIZED]?: boolean; +} + +function stripSourceUrlLabels(input: string): string { + let output = input; + output = output.replace(/\n?\s*\/\/[@#]\s*sourceURL=[^\n\r]*/gi, ''); + output = output.replace(/\n?\s*\/\*[@#]\s*sourceURL=[\s\S]*?\*\//gi, ''); + return output; +} + +function sanitizeCdpPayload( + method: string, + params?: Record +): Record | undefined { + if (!params || typeof params !== 'object') return params; + const sanitizeField = ( + payload: Record, + field: 'expression' | 'functionDeclaration' | 'source' + ): Record => { + const value = payload[field]; + if (typeof value !== 'string') return payload; + const cleaned = stripSourceUrlLabels(value); + if (cleaned === value) return payload; + return { ...payload, [field]: cleaned }; + }; + + switch (method) { + case 'Runtime.evaluate': + case 'Runtime.compileScript': + return sanitizeField(params, 'expression'); + case 'Runtime.callFunctionOn': + return sanitizeField(params, 'functionDeclaration'); + case 'Page.addScriptToEvaluateOnNewDocument': + return sanitizeField(params, 'source'); + default: + return params; + } +} + +/** + * Patch CDPSession.send so Runtime/Page script payloads no longer carry + * sourceURL labels that reveal automation internals. + */ +export function wrapCDPSessionSourceUrlSanitizer(session: T): T { + if (!session || typeof session.send !== 'function') return session; + if (session[CDP_SOURCE_URL_SANITIZED]) return session; + + const nativeSend = session.send.bind(session); + const wrappedSend = (method: string, params?: Record) => { + return nativeSend(method, sanitizeCdpPayload(method, params)); + }; + + try { + Object.defineProperty(session, 'send', { + value: wrappedSend, + configurable: true, + writable: true, + }); + } catch { + try { + (session as any).send = wrappedSend; + } catch { + return session; + } + } + + try { + Object.defineProperty(session, CDP_SOURCE_URL_SANITIZED, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + } catch { + (session as any)[CDP_SOURCE_URL_SANITIZED] = true; + } + + return session; +} + /** * Apply all stealth patches to a BrowserContext. * Must be called BEFORE any page is created / navigated. @@ -51,7 +136,7 @@ export async function applyBrowserLevelStealth( options: StealthScriptOptions = {} ): Promise { try { - const cdp = await (browser as any).newBrowserCDPSession(); + const cdp = wrapCDPSessionSourceUrlSanitizer(await (browser as any).newBrowserCDPSession()); const version = await cdp.send('Browser.getVersion'); const rawUA = version?.userAgent ?? ''; const explicitUA = options.userAgent?.trim(); @@ -93,7 +178,7 @@ async function applyCDPStealthToPage( options: StealthScriptOptions = {} ): Promise { try { - const cdp = await page.context().newCDPSession(page); + const cdp = wrapCDPSessionSourceUrlSanitizer(await page.context().newCDPSession(page)); const ua = await cdp.send('Browser.getVersion').catch(() => null); const rawUA = ua?.userAgent ?? ''; const explicitUA = options.userAgent?.trim(); @@ -196,7 +281,11 @@ function deriveLanguages(locale?: string): string[] { function buildStealthScript(options: StealthScriptOptions): string { const locale = normalizeLocale(options.locale) ?? 'en-US'; const languages = deriveLanguages(locale); - const configScript = `const __abStealth = ${JSON.stringify({ locale, languages })};`; + const configScript = `const __abStealth = ${JSON.stringify({ + locale, + languages, + allowWebGLContextFallback: options.allowWebGLContextFallback === true, + })};`; // Each patch is an IIFE so variable scoping is clean return [ @@ -204,11 +293,15 @@ function buildStealthScript(options: StealthScriptOptions): string { patchNavigatorWebdriver(), patchCssSupportsWebdriverHeuristic(), patchChromeRuntime(), + patchChromeLegacyApis(), + patchIframeContentWindow(), patchNavigatorLanguages(), + patchNavigatorVendor(), patchNavigatorPluginsAndMimeTypes(), patchNavigatorPermissions(), patchWebGLVendor(), patchCdcProperties(), + patchSourceUrlStackTraces(), patchWindowDimensions(), patchScreenDimensions(), patchScreenAvailability(), @@ -222,6 +315,7 @@ function buildStealthScript(options: StealthScriptOptions): string { patchContentIndex(), patchPrefersColorSchemeHeuristic(), patchPdfViewerEnabled(), + patchMediaCodecs(), patchMediaDevices(), patchUserAgentData(), patchUserAgent(), @@ -339,6 +433,223 @@ function patchChromeRuntime(): string { })();`; } +/** + * Add deprecated-but-still-probed Chrome APIs: chrome.app, chrome.csi, chrome.loadTimes. + */ +function patchChromeLegacyApis(): string { + return `(function(){ + const chromeObject = ('chrome' in window && window.chrome) ? window.chrome : null; + if (!chromeObject) return; + const nativeNow = Date.now; + const nativeToString = Function.prototype.toString; + const timing = window.performance && window.performance.timing ? window.performance.timing : null; + const getNavigationEntry = () => { + try { + return performance.getEntriesByType('navigation')[0] || { nextHopProtocol: 'h2', type: 'other' }; + } catch { + return { nextHopProtocol: 'h2', type: 'other' }; + } + }; + const defineValue = (target, key, value) => { + try { + Object.defineProperty(target, key, { + value, + configurable: true, + writable: true, + }); + return true; + } catch { + return false; + } + }; + const patchFunctionShape = (fn, name) => { + try { + Object.defineProperty(fn, 'name', { value: name, configurable: true }); + Object.defineProperty(fn, 'toString', { + value: () => nativeToString.call(nativeNow).replace('now', name), + configurable: true, + }); + } catch {} + }; + + if (!('app' in chromeObject)) { + const invokeError = (name) => new TypeError('Error in invocation of app.' + name + '()'); + const app = { + isInstalled: false, + InstallState: { + DISABLED: 'disabled', + INSTALLED: 'installed', + NOT_INSTALLED: 'not_installed', + }, + RunningState: { + CANNOT_RUN: 'cannot_run', + READY_TO_RUN: 'ready_to_run', + RUNNING: 'running', + }, + getDetails: function getDetails() { + if (arguments.length) throw invokeError('getDetails'); + return null; + }, + getIsInstalled: function getIsInstalled() { + if (arguments.length) throw invokeError('getIsInstalled'); + return false; + }, + runningState: function runningState() { + if (arguments.length) throw invokeError('runningState'); + return 'cannot_run'; + }, + }; + defineValue(chromeObject, 'app', app); + } + + if (!('csi' in chromeObject) && timing) { + const csi = function csi() { + return { + onloadT: timing.domContentLoadedEventEnd, + startE: timing.navigationStart, + pageT: Date.now() - timing.navigationStart, + tran: 15, + }; + }; + patchFunctionShape(csi, 'csi'); + defineValue(chromeObject, 'csi', csi); + } + + if (!('loadTimes' in chromeObject) && timing) { + const toFixed = (num, fixed) => { + const matcher = new RegExp('^-?\\\\d+(?:.\\\\d{0,' + (fixed || -1) + '})?'); + const match = String(num).match(matcher); + return match ? match[0] : String(num); + }; + const loadTimes = function loadTimes() { + const navigationEntry = getNavigationEntry(); + const nextHopProtocol = navigationEntry.nextHopProtocol || 'h2'; + let firstPaint = timing.loadEventEnd / 1000; + try { + const paintEntries = performance.getEntriesByType('paint'); + if (paintEntries && paintEntries[0] && typeof paintEntries[0].startTime === 'number') { + firstPaint = (paintEntries[0].startTime + performance.timeOrigin) / 1000; + } + } catch {} + return { + connectionInfo: nextHopProtocol, + npnNegotiatedProtocol: ['h2', 'hq'].includes(nextHopProtocol) ? nextHopProtocol : 'unknown', + navigationType: navigationEntry.type || 'other', + wasAlternateProtocolAvailable: false, + wasFetchedViaSpdy: ['h2', 'hq'].includes(nextHopProtocol), + wasNpnNegotiated: ['h2', 'hq'].includes(nextHopProtocol), + firstPaintAfterLoadTime: 0, + requestTime: timing.navigationStart / 1000, + startLoadTime: timing.navigationStart / 1000, + commitLoadTime: timing.responseStart / 1000, + finishDocumentLoadTime: timing.domContentLoadedEventEnd / 1000, + finishLoadTime: timing.loadEventEnd / 1000, + firstPaintTime: toFixed(firstPaint, 3), + }; + }; + patchFunctionShape(loadTimes, 'loadTimes'); + defineValue(chromeObject, 'loadTimes', loadTimes); + } +})();`; +} + +/** + * Fix srcdoc iframe.contentWindow signals used by classic HEADCHR_IFRAME checks. + * We only intercept iframe creation and srcdoc assignment to keep impact minimal. + */ +function patchIframeContentWindow(): string { + return `(function(){ + if (typeof document === 'undefined' || typeof document.createElement !== 'function') return; + const nativeCreateElement = document.createElement.bind(document); + const nativeSrcdocDescriptor = + typeof HTMLIFrameElement !== 'undefined' + ? Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc') + : null; + const srcdocGetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.get; + const srcdocSetter = nativeSrcdocDescriptor && nativeSrcdocDescriptor.set; + const iframeProxyMap = new WeakMap(); + const patchedIframes = new WeakSet(); + + const ensureContentWindowProxy = (iframe) => { + if (!iframe || iframeProxyMap.has(iframe)) return; + try { + if (iframe.contentWindow) return; + } catch {} + const proxy = new Proxy(window, { + get(target, key) { + if (key === 'self') return proxy; + if (key === 'frameElement') return iframe; + if (key === '0') return undefined; + return Reflect.get(target, key, target); + }, + }); + iframeProxyMap.set(iframe, proxy); + try { + Object.defineProperty(iframe, 'contentWindow', { + get: () => proxy, + set: () => undefined, + enumerable: true, + configurable: false, + }); + } catch {} + }; + + const patchIframeSrcdoc = (iframe) => { + if (!iframe || patchedIframes.has(iframe)) return; + patchedIframes.add(iframe); + try { + Object.defineProperty(iframe, 'srcdoc', { + configurable: true, + get() { + if (typeof srcdocGetter === 'function') { + return srcdocGetter.call(this); + } + return ''; + }, + set(value) { + ensureContentWindowProxy(this); + if (typeof srcdocSetter === 'function') { + srcdocSetter.call(this, value); + } else { + this.setAttribute('srcdoc', String(value ?? '')); + } + }, + }); + } catch {} + }; + + const patchedCreateElement = function(...args) { + const element = nativeCreateElement(...args); + try { + const name = args && args.length > 0 ? String(args[0]).toLowerCase() : ''; + if (name === 'iframe') { + patchIframeSrcdoc(element); + } + } catch {} + return element; + }; + try { + Object.defineProperty(patchedCreateElement, 'name', { + value: 'createElement', + configurable: true, + }); + Object.defineProperty(patchedCreateElement, 'toString', { + value: () => nativeCreateElement.toString(), + configurable: true, + }); + } catch {} + try { + Object.defineProperty(document, 'createElement', { + value: patchedCreateElement, + configurable: true, + writable: true, + }); + } catch { + try { document.createElement = patchedCreateElement; } catch {} + } +})();`; +} + /** * Keep navigator.language + navigator.languages aligned with launch locale. */ @@ -362,6 +673,38 @@ function patchNavigatorLanguages(): string { })();`; } +/** + * Keep navigator.vendor aligned with regular Chrome. + */ +function patchNavigatorVendor(): string { + return `(function(){ + const ua = String(navigator.userAgent || ''); + if (!/Chrome\\//.test(ua) || /Firefox\\//.test(ua)) return; + const target = 'Google Inc.'; + const proto = Object.getPrototypeOf(navigator); + try { + if (navigator.vendor === target) return; + } catch {} + const defineVendor = (targetObj) => { + if (!targetObj) return false; + try { + Object.defineProperty(targetObj, 'vendor', { + get: () => target, + configurable: true, + }); + return true; + } catch { + return false; + } + }; + if (defineVendor(proto)) { + try { delete (navigator).vendor; } catch {} + return; + } + defineVendor(navigator); +})();`; +} + /** * Inject realistic navigator.plugins and navigator.mimeTypes arrays. * Headless Chrome reports an empty PluginArray; real Chrome always has a few. @@ -500,8 +843,104 @@ function patchNavigatorPermissions(): string { function patchWebGLVendor(): string { return `(function(){ const getCtx = HTMLCanvasElement.prototype.getContext; + const WEBGL_VENDOR = 'Intel Inc.'; + const WEBGL_RENDERER = 'Intel Iris OpenGL Engine'; + const DEBUG_RENDERER_INFO = { + UNMASKED_VENDOR_WEBGL: 0x9245, + UNMASKED_RENDERER_WEBGL: 0x9246, + }; + + const createFallbackWebGLContext = (canvas, requestedType) => { + const isWebGL2 = requestedType === 'webgl2'; + const ctx = { + __abFallbackWebGLContext: true, + canvas, + drawingBufferWidth: canvas.width || 300, + drawingBufferHeight: canvas.height || 150, + VENDOR: 0x1F00, + RENDERER: 0x1F01, + VERSION: 0x1F02, + SHADING_LANGUAGE_VERSION: 0x8B8C, + getExtension(name) { + if (name === 'WEBGL_debug_renderer_info') return DEBUG_RENDERER_INFO; + return null; + }, + getSupportedExtensions() { + return ['WEBGL_debug_renderer_info']; + }, + getContextAttributes() { + return { + alpha: true, + antialias: true, + depth: true, + desynchronized: false, + failIfMajorPerformanceCaveat: false, + powerPreference: 'default', + premultipliedAlpha: true, + preserveDrawingBuffer: false, + stencil: false, + }; + }, + getParameter(param) { + if (param === DEBUG_RENDERER_INFO.UNMASKED_VENDOR_WEBGL || param === this.VENDOR) { + return WEBGL_VENDOR; + } + if (param === DEBUG_RENDERER_INFO.UNMASKED_RENDERER_WEBGL || param === this.RENDERER) { + return WEBGL_RENDERER; + } + if (param === this.VERSION) { + return isWebGL2 + ? 'WebGL 2.0 (OpenGL ES 3.0 Chromium)' + : 'WebGL 1.0 (OpenGL ES 2.0 Chromium)'; + } + if (param === this.SHADING_LANGUAGE_VERSION) { + return isWebGL2 + ? 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)' + : 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)'; + } + return 0; + }, + getError() { return 0; }, + clear() {}, + clearColor() {}, + createBuffer() { return {}; }, + bindBuffer() {}, + bufferData() {}, + createProgram() { return {}; }, + createShader() { return {}; }, + shaderSource() {}, + compileShader() {}, + attachShader() {}, + linkProgram() {}, + useProgram() {}, + viewport() {}, + drawArrays() {}, + readPixels() {}, + finish() {}, + flush() {}, + }; + try { + const proto = + requestedType === 'webgl2' && typeof WebGL2RenderingContext !== 'undefined' + ? WebGL2RenderingContext.prototype + : typeof WebGLRenderingContext !== 'undefined' + ? WebGLRenderingContext.prototype + : null; + if (proto) Object.setPrototypeOf(ctx, proto); + } catch {} + return ctx; + }; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { const ctx = getCtx.call(this, type, attrs); + if ( + (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') && + !ctx && + __abStealth && + __abStealth.allowWebGLContextFallback === true + ) { + return createFallbackWebGLContext(this, type); + } if (ctx && (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl')) { const origGetParameter = ctx.getParameter.bind(ctx); ctx.getParameter = function(param) { @@ -509,13 +948,15 @@ function patchWebGLVendor(): string { if (ext) { if (param === ext.UNMASKED_VENDOR_WEBGL) { const real = origGetParameter(param); - return (real && real.includes('SwiftShader')) ? 'Intel Inc.' : real; + return (real && real.includes('SwiftShader')) ? WEBGL_VENDOR : real; } if (param === ext.UNMASKED_RENDERER_WEBGL) { const real = origGetParameter(param); - return (real && real.includes('SwiftShader')) ? 'Intel Iris OpenGL Engine' : real; + return (real && real.includes('SwiftShader')) ? WEBGL_RENDERER : real; } } + if (param === ctx.VENDOR) return WEBGL_VENDOR; + if (param === ctx.RENDERER) return WEBGL_RENDERER; return origGetParameter(param); }; } @@ -542,6 +983,60 @@ function patchCdcProperties(): string { })();`; } +/** + * Remove Playwright/Puppeteer sourceURL tokens from error stacks. + * This mirrors the intent of the sourceurl evasion: reduce obvious + * automation-only script labels in stack traces. + */ +function patchSourceUrlStackTraces(): string { + return `(function(){ + if (typeof Error === 'undefined') return; + const sanitizeStack = (value) => { + if (typeof value !== 'string') return value; + let stack = value; + stack = stack.replace(/\\/\\/# sourceURL=.*$/gm, ''); + stack = stack.replace(/__playwright_evaluation_script__/g, ''); + stack = stack.replace(/__puppeteer_evaluation_script__/g, ''); + stack = stack.replace(/__pw_evaluation_script__/g, ''); + return stack; + }; + + const nativePrepare = Error.prepareStackTrace; + Error.prepareStackTrace = function(error, structuredStackTrace) { + let stackString; + if (typeof nativePrepare === 'function') { + stackString = nativePrepare.call(this, error, structuredStackTrace); + } else { + const name = error && error.name ? String(error.name) : 'Error'; + const message = error && error.message ? String(error.message) : ''; + const header = message ? name + ': ' + message : name; + const frames = Array.isArray(structuredStackTrace) + ? structuredStackTrace.map((frame) => ' at ' + String(frame)) + : []; + stackString = [header].concat(frames).join('\\n'); + } + return sanitizeStack(String(stackString)); + }; + + if (typeof Error.captureStackTrace === 'function') { + const nativeCapture = Error.captureStackTrace; + Error.captureStackTrace = function(targetObject, constructorOpt) { + nativeCapture.call(this, targetObject, constructorOpt); + try { + const stack = targetObject && targetObject.stack; + if (typeof stack === 'string') { + Object.defineProperty(targetObject, 'stack', { + value: sanitizeStack(stack), + configurable: true, + writable: true, + }); + } + } catch {} + }; + } +})();`; +} + /** * contentWindow on cross-origin iframes: Playwright sometimes returns null * where real browsers return a (restricted) Window object. @@ -1016,6 +1511,68 @@ function patchPdfViewerEnabled(): string { })();`; } +/** + * Chromium headless can under-report support for common media codecs. + * Patch canPlayType for a narrow set of high-signal probes. + */ +function patchMediaCodecs(): string { + return `(function(){ + if (typeof HTMLMediaElement === 'undefined' || !HTMLMediaElement.prototype) return; + const nativeCanPlayType = HTMLMediaElement.prototype.canPlayType; + if (typeof nativeCanPlayType !== 'function') return; + const parseInput = (value) => { + const input = String(value || '').trim(); + const [mimePart, codecPart] = input.split(';'); + const mime = String(mimePart || '').trim().toLowerCase(); + const codecs = []; + if (codecPart && codecPart.includes('codecs=')) { + const normalized = codecPart + .replace(/^[^=]*=/, '') + .replace(/^\\s*["']?/, '') + .replace(/["']?\\s*$/, ''); + normalized + .split(',') + .map((codec) => codec.trim().toLowerCase()) + .filter(Boolean) + .forEach((codec) => codecs.push(codec)); + } + return { mime, codecs }; + }; + const patchedCanPlayType = function(type) { + const { mime, codecs } = parseInput(type); + if (mime === 'video/mp4' && codecs.includes('avc1.42e01e')) { + return 'probably'; + } + if (mime === 'audio/x-m4a' && codecs.length === 0) { + return 'maybe'; + } + if (mime === 'audio/aac' && codecs.length === 0) { + return 'probably'; + } + return nativeCanPlayType.call(this, type); + }; + try { + Object.defineProperty(patchedCanPlayType, 'name', { + value: 'canPlayType', + configurable: true, + }); + Object.defineProperty(patchedCanPlayType, 'toString', { + value: () => nativeCanPlayType.toString(), + configurable: true, + }); + } catch {} + try { + Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', { + value: patchedCanPlayType, + configurable: true, + writable: true, + }); + } catch { + try { HTMLMediaElement.prototype.canPlayType = patchedCanPlayType; } catch {} + } +})();`; +} + /** * navigator.mediaDevices.enumerateDevices should return at least some devices * instead of an empty array (headless default).