Compare commits

..
Author SHA1 Message Date
CloakHQ a0c7704c4b release: v0.3.20 — 48 patches, WebRTC IP spoofing, proxy signal removal 2026-04-06 01:54:35 +02:00
lilos ccda93669e feat(humanize): implement CDP Isolated Worlds and trusted keyboard events (fixes #110) 2026-04-06 01:43:46 +02:00
CloakHQ eb4efef329 feat: add --fingerprint-webrtc-ip flag with auto-resolve support
Two ways to spoof WebRTC ICE candidate IPs:

1. --fingerprint-webrtc-ip=auto in args: resolves proxy exit IP via
   HTTP call through the proxy (ipify.org). No extra deps needed.

2. geoip=True: auto-injects the flag for free (exit IP already
   resolved during timezone/locale lookup, zero extra network cost).

Explicit IP (--fingerprint-webrtc-ip=1.2.3.4) also supported.
User-provided values always take precedence.

Python + JS wrappers, README docs, tests.
2026-04-06 01:16:10 +02:00
CloakHQ 25d34dcea3 feat(cloakserve): add connection tracking, configurable data dir, better status endpoint
- Move `import websockets` to top-level (guaranteed by [serve] extra)
- Add --data-dir flag with smart default (Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve)
- Store launch params (tz/locale/proxy) on ChromeProcess for conflict logging
- Enhance GET / to return per-process detail (pid, port, seed, connections, config)
- Add connection refcounting in WS handlers for status visibility
- Add first-launch-wins note to README
- Add tests for data-dir, Docker detection, and connection tracking
2026-04-05 22:41:33 +02:00
CloakHQ c9e4f58353 feat: rewrite cloakserve as CDP multiplexer with per-connection fingerprint seeds
Spawns a separate Chrome process per unique fingerprint seed, all behind
a single port (9222). Clients specify seeds and fingerprint params via
query string on the CDP URL:

  connect_over_cdp("http://host:9222?fingerprint=12345&timezone=Asia/Tokyo")

Supports all --fingerprint-* flags as query params, geoip=true for
auto timezone/locale from proxy IP, and proxy= for per-process proxies.

- Rewrite bin/cloakserve from 57-line wrapper to aiohttp CDP multiplexer
- Add ChromePool with per-seed process management and port allocation
- Bidirectional WebSocket proxy for CDP traffic
- URL rewriting for /json/version, /json/list, and WS paths
- Rename _build_args -> build_args, _maybe_resolve_geoip -> maybe_resolve_geoip
- Add aiohttp + websockets to serve optional deps
- Dockerfile installs .[serve] extras
- Add 20 unit tests for cloakserve (param parsing, CLI args, URL rewriting)
2026-04-05 22:30:18 +02:00
CloakHQ c58b691f1c chore(deps): bump actions/checkout, docker/setup-qemu, docker/setup-buildx, docker/login-action; group Dependabot PRs 2026-04-05 22:26:14 +02:00
CloakHQ 1b91a33e51 chore: update integration examples and gitignore
- browser_use: migrate to BrowserSession + bundled ChatOpenAI API
- crawl4ai: add browser_mode="cdp" param
- gitignore: add captures/
2026-04-02 01:32:08 +02:00
CloakHQ 1bfd5ca036 docs: update patch count to 42, add --fingerprint-noise flag, bump version refs 2026-03-30 20:46:16 +02:00
38 changed files with 4730 additions and 257 deletions
+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -22,7 +22,7 @@ jobs:
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20
+8 -8
View File
@@ -24,7 +24,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -42,7 +42,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -62,7 +62,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -80,7 +80,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
@@ -102,14 +102,14 @@ jobs:
attestations: write
packages: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
+2
View File
@@ -65,3 +65,5 @@ deploy.sh
.env
debug
publish-docker.sh
captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
+12
View File
@@ -6,6 +6,18 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.3.20] — 2026-04-06
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
- **[binary]** WebGL rendering accuracy improvements for headed mode
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
- **[meta]** Bump GitHub Actions dependencies
## [0.3.19] — 2026-03-30
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /app
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir ".[serve]"
# JS wrapper
COPY js/ js/
+49 -17
View File
@@ -40,7 +40,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p>
- **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -128,13 +128,14 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
---
## Latest: v0.3.17 (Chromium 145.0.7632.159.7)
## Latest: v0.3.20 (Chromium 145.0.7632.159.9)
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **33 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **48 fingerprint patches** (Linux x64) — 6 new patches covering WebRTC IP spoofing, proxy signal removal, and network timing normalization
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
- **Humanize CDP isolation** — keyboard events now use isolated worlds and trusted dispatch for better behavioral stealth
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
@@ -221,7 +222,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 33 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
The binary includes 48 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -253,11 +254,19 @@ browser = launch(args=["--disable-gpu"])
browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost)
# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com)
browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"])
# Explicit WebRTC IP (no network call)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"])
# Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True)
@@ -573,6 +582,8 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
@@ -645,11 +656,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 33 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 33 | ✅ Latest |
| Linux x86_64 | 145 | 42 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 33 | ✅ |
The wrapper auto-downloads the correct binary for your platform.
@@ -753,7 +764,28 @@ services:
start_period: 10s
```
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`).
**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint:
```python
# Each seed gets unique canvas noise, client rects, and other browser signals
b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111")
b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222")
# Full identity control via query params
b3 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=33333"
"&timezone=Asia/Tokyo&locale=ja-JP&platform=macos"
"&hardware-concurrency=4&device-memory=8"
)
# Auto-detect timezone/locale from proxy exit IP
b4 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=44444"
"&proxy=http://proxy:8080&geoip=true"
)
```
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
@@ -987,9 +1019,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
| Linux x64 — Chromium 145 (48 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
@@ -1011,7 +1043,7 @@ All releases are signed for supply chain verification.
```bash
# Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.7
git verify-tag chromium-v145.0.7632.159.9
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
+610 -30
View File
@@ -1,31 +1,54 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium.
Spawns a separate Chrome process per unique fingerprint seed, routing CDP
connections through a single port. Each seed gets its own browser identity.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
cloakserve # default, backward compat
cloakserve --port=9222 # custom port
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
Client:
browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345")
browser = pw.chromium.connect_over_cdp(
"http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US"
)
"""
import signal
from __future__ import annotations
import asyncio
import json
import logging
import os
import random
import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from urllib.parse import parse_qs
from cloakbrowser.config import get_default_stealth_args
from pathlib import Path
import aiohttp
import websockets
from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args
from cloakbrowser.download import ensure_binary
PORT = 9222
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("cloakserve")
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=0.0.0.0",
# Sane defaults for running Chrome directly (outside Playwright)
# Args for running Chrome directly (outside Playwright).
# Playwright normally adds its own version of these.
BASE_CHROME_ARGS = [
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
@@ -33,24 +56,581 @@ chrome_args = [
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
# GPU blocklist bypass: Chromium blocks WebGL on software GPUs in
# Docker/Xvfb. Without this, WebGL vendor/renderer spoofing fails. #58
"--ignore-gpu-blocklist",
] + get_default_stealth_args() + sys.argv[1:]
]
chrome = subprocess.Popen(chrome_args)
time.sleep(2)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
BASE_CDP_PORT = 5100
def cleanup(sig, frame):
chrome.terminate()
sys.exit(0)
# ---------------------------------------------------------------------------
# ChromeProcess — one running Chrome instance
# ---------------------------------------------------------------------------
@dataclass
class ChromeProcess:
seed: str
process: subprocess.Popen
cdp_port: int
user_data_dir: str
timezone: str | None = None
locale: str | None = None
proxy: str | None = None
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
# ---------------------------------------------------------------------------
# ChromePool — manages multiple Chrome processes keyed by seed
# ---------------------------------------------------------------------------
chrome.wait()
class ChromePool:
def __init__(
self,
binary: str,
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
self._next_port = BASE_CDP_PORT
# Connection refcounting for status reporting
self._connections: dict[str, int] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
self._locks[seed] = asyncio.Lock()
return self._locks[seed]
def _allocate_port(self) -> int:
"""Find a free port starting from _next_port."""
for _ in range(100):
port = self._next_port
self._next_port += 1
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError("No free ports available for Chrome CDP")
def connect(self, seed_key: str) -> None:
"""Increment connection refcount for a seed."""
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
def disconnect(self, seed_key: str) -> None:
"""Decrement connection refcount for a seed."""
count = self._connections.get(seed_key, 0) - 1
if count <= 0:
self._connections.pop(seed_key, None)
else:
self._connections[seed_key] = count
async def get_or_launch(
self,
seed: str | None,
extra_args: list[str] | None = None,
timezone: str | None = None,
locale: str | None = None,
proxy: str | None = None,
geoip: bool = False,
) -> ChromeProcess:
"""Get existing or launch new Chrome process for a seed."""
# No seed = default shared process
if seed is None:
seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999))
else:
seed_key = seed
actual_seed = seed
lock = self._get_lock(seed_key)
async with lock:
# Check if already running (including default fast-path)
if seed_key in self._processes:
proc = self._processes[seed_key]
if proc.process.poll() is None:
if any([extra_args, timezone, locale, proxy, geoip]):
logger.warning(
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
"ignoring new params (first-launch wins)",
seed_key, proc.cdp_port,
proc.timezone, proc.locale, proc.proxy,
)
return proc
# Dead — clean up
await self._cleanup_process(seed_key)
# Resolve geoip if requested
exit_ip = None
if geoip and proxy:
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
# Build Chrome args via shared logic
fp_extra = [f"--fingerprint={actual_seed}"]
if extra_args:
fp_extra.extend(extra_args)
if proxy:
fp_extra.append(f"--proxy-server={proxy}")
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
fp_extra = list(fp_extra or [])
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(
stealth_args=True,
extra_args=fp_extra,
timezone=timezone,
locale=locale,
headless=self._headless,
)
# Allocate port and user data dir
port = self._allocate_port()
user_data_dir = os.path.join(self._data_dir, seed_key)
os.makedirs(user_data_dir, exist_ok=True)
full_args = (
[self._binary]
+ BASE_CHROME_ARGS
+ chrome_args
+ self._global_args
+ [
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={user_data_dir}",
]
)
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
process = subprocess.Popen(
full_args,
stdout=subprocess.DEVNULL,
)
# Wait for CDP to be ready
if not await self._wait_for_cdp(port):
process.kill()
await asyncio.to_thread(process.wait, timeout=5)
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
raise web.HTTPBadGateway(
text=json.dumps({"error": "Chrome failed to start"}),
content_type="application/json",
)
cp = ChromeProcess(
seed=actual_seed,
process=process,
cdp_port=port,
user_data_dir=user_data_dir,
timezone=timezone,
locale=locale,
proxy=proxy,
)
self._processes[seed_key] = cp
if seed is None:
self._default = cp
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
return cp
async def _cleanup_process(self, key: str) -> None:
"""Terminate a Chrome process and clean up."""
proc = self._processes.pop(key, None)
if not proc:
return
if proc.process.poll() is None:
proc.process.terminate()
try:
await asyncio.to_thread(proc.process.wait, timeout=5)
except subprocess.TimeoutExpired:
proc.process.kill()
# Clean up user data dir (can be slow for large profiles)
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
if self._default is proc:
self._default = None
self._locks.pop(key, None)
self._connections.pop(key, None)
async def shutdown(self) -> None:
"""Terminate all Chrome processes."""
for key in list(self._processes.keys()):
await self._cleanup_process(key)
logger.info("All Chrome processes terminated")
@staticmethod
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
"""Poll Chrome's /json/version until ready."""
deadline = time.monotonic() + timeout
delay = 0.1
session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=1)
)
try:
while time.monotonic() < deadline:
try:
async with session.get(
f"http://127.0.0.1:{port}/json/version"
) as resp:
if resp.status == 200:
return True
except Exception:
pass
await asyncio.sleep(delay)
delay = min(delay * 2, 1.0)
return False
finally:
await session.close()
# ---------------------------------------------------------------------------
# Query param parsing
# ---------------------------------------------------------------------------
# Params that need special handling (not simple --fingerprint-{name}= mapping)
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
def parse_connection_params(query_string: str) -> dict:
"""Parse query params into connection config."""
qs = parse_qs(query_string, keep_blank_values=False)
result: dict = {
"seed": None,
"timezone": None,
"locale": None,
"proxy": None,
"geoip": False,
"extra_args": [],
}
for key, values in qs.items():
val = values[0]
if key == "fingerprint":
result["seed"] = val
elif key == "timezone":
result["timezone"] = val
elif key == "locale":
result["locale"] = val
elif key == "proxy":
result["proxy"] = val
elif key == "geoip":
result["geoip"] = val.lower() in ("true", "1", "yes")
elif key not in SPECIAL_PARAMS:
# Generic fingerprint param: map to --fingerprint-{key}={val}
result["extra_args"].append(f"--fingerprint-{key}={val}")
return result
# ---------------------------------------------------------------------------
# HTTP handlers
# ---------------------------------------------------------------------------
def _ws_scheme(request: web.Request) -> str:
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
return "wss" if proto == "https" else "ws"
async def handle_root(request: web.Request) -> web.Response:
"""Health check / process status."""
pool: ChromePool = request.app["pool"]
processes = {}
for key, proc in pool._processes.items():
if proc.process.poll() is None:
processes[key] = {
"pid": proc.process.pid,
"port": proc.cdp_port,
"seed": proc.seed,
"connections": pool._connections.get(key, 0),
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
}
return web.json_response({
"status": "ok",
"active": len(processes),
"processes": processes,
})
async def handle_json_version(request: web.Request) -> web.Response:
"""Proxy /json/version with optional per-seed routing."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/version",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as exc:
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
# Rewrite webSocketDebuggerUrl to route through our multiplexer
host = request.headers.get("Host", f"localhost:{request.app['port']}")
seed_key = params["seed"]
if seed_key:
ws_path = f"fingerprint/{seed_key}/devtools/browser"
else:
ws_path = "devtools/browser"
# Extract the browser GUID from Chrome's original URL
orig_ws = data.get("webSocketDebuggerUrl", "")
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
scheme = _ws_scheme(request)
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
return web.json_response(data)
async def handle_json_list(request: web.Request) -> web.Response:
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/list",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as exc:
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
host = request.headers.get("Host", f"localhost:{request.app['port']}")
scheme = _ws_scheme(request)
seed_key = params["seed"]
for entry in data:
if "webSocketDebuggerUrl" in entry:
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
if seed_key:
entry["webSocketDebuggerUrl"] = (
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
)
else:
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
return web.json_response(data)
# ---------------------------------------------------------------------------
# WebSocket proxy
# ---------------------------------------------------------------------------
async def proxy_cdp_websocket(
client_ws: web.WebSocketResponse,
target_url: str,
label: str,
) -> None:
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
try:
async with websockets.connect(
target_url, max_size=None, ping_interval=None, ping_timeout=None,
) as cdp_ws:
logger.info("%s: connected to %s", label, target_url)
async def client_to_cdp():
try:
async for msg in client_ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await cdp_ws.send(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await cdp_ws.send(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
break
except Exception as exc:
logger.debug("%s [c->cdp]: %s", label, exc)
async def cdp_to_client():
try:
async for msg in cdp_ws:
if isinstance(msg, str):
await client_ws.send_str(msg)
else:
await client_ws.send_bytes(msg)
except Exception as exc:
logger.debug("%s [cdp->c]: %s", label, exc)
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
done, pending = await asyncio.wait(
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
logger.info("%s: disconnected", label)
except Exception as exc:
logger.error("%s error: %s", label, exc)
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
pool: ChromePool = request.app["pool"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=None)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect("__default__")
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
finally:
pool.disconnect("__default__")
return ws
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
pool: ChromePool = request.app["pool"]
seed = request.match_info["seed"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=seed)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect(seed)
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
finally:
pool.disconnect(seed)
return ws
async def on_shutdown(app: web.Application) -> None:
await app["pool"].shutdown()
# ---------------------------------------------------------------------------
# CLI arg parsing
# ---------------------------------------------------------------------------
def _default_data_dir() -> str:
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv"):
return "/tmp/cloakserve"
return str(Path.home() / ".cloakbrowser" / "cloakserve")
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args)."""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--remote-debugging-port=",
"--remote-debugging-address=",
)
for arg in argv:
if arg.startswith("--port="):
config["port"] = int(arg.split("=", 1)[1])
elif arg.startswith("--data-dir="):
config["data_dir"] = arg.split("=", 1)[1]
elif arg == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
elif arg.startswith(consumed_prefixes):
pass # Strip these silently
else:
passthrough.append(arg)
if config["data_dir"] is None:
config["data_dir"] = _default_data_dir()
return config, passthrough
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
binary = ensure_binary()
config, global_args = parse_cli_args(sys.argv[1:])
pool = ChromePool(
binary=binary,
global_args=global_args,
headless=config["headless"],
data_dir=config["data_dir"],
)
app = web.Application()
app["pool"] = pool
app["port"] = config["port"]
# Routes
app.router.add_get("/", handle_root)
app.router.add_get("/json/version", handle_json_version)
app.router.add_get("/json/version/", handle_json_version)
app.router.add_get("/json/list", handle_json_list)
app.router.add_get("/json/list/", handle_json_list)
app.router.add_get("/json", handle_json_list)
app.router.add_get("/json/", handle_json_list)
# WebSocket routes — seed-specific (must be before default to match first)
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
# WebSocket routes — default (no seed)
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
app.on_shutdown.append(on_shutdown)
port = config["port"]
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
logger.info(
"Connect: playwright.chromium.connect_over_cdp("
"\"http://localhost:%d?fingerprint=<seed>\")",
port,
)
web.run_app(app, host="0.0.0.0", port=port, print=None)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -11,7 +11,7 @@ Usage:
browser.close()
"""
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__
@@ -40,6 +40,8 @@ __all__ = [
"check_for_update",
"CHROMIUM_VERSION",
"get_default_stealth_args",
"build_args",
"maybe_resolve_geoip",
"ProxySettings",
"HumanConfig",
"resolve_human_config",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.19"
__version__ = "0.3.20"
+101 -21
View File
@@ -101,8 +101,12 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -186,8 +190,12 @@ async def launch_async( # noqa: C901
async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -284,8 +292,12 @@ def launch_persistent_context(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -399,8 +411,12 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -497,7 +513,11 @@ def launch_context(
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
@@ -590,31 +610,91 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _maybe_resolve_geoip(
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param."""
if proxy is None:
return None
raw = proxy.get("server") if isinstance(proxy, dict) else proxy
if not raw:
return None
return _ensure_proxy_scheme(raw)
def maybe_resolve_geoip(
geoip: bool,
proxy: str | ProxySettings | None,
timezone: str | None,
locale: str | None,
) -> tuple[str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
if not geoip or not proxy or (timezone is not None and locale is not None):
return timezone, locale
) -> tuple[str | None, str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled.
from .geoip import resolve_proxy_geo
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
"""
if not geoip or not proxy:
return timezone, locale, None
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
from .geoip import resolve_proxy_geo_with_ip
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
return timezone, locale, None
# When both tz/locale are explicit, still resolve exit IP for WebRTC
if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
return timezone, locale
return timezone, locale, exit_ip
def _build_args(
def _resolve_webrtc_args(
args: list[str] | None,
proxy: str | ProxySettings | None,
) -> list[str] | None:
"""Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
Returns args unchanged if no ``auto`` value is present.
"""
if not args:
return args
idx = None
for i, a in enumerate(args):
if a == "--fingerprint-webrtc-ip=auto":
idx = i
break
if idx is None:
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
args = list(args)
del args[idx]
return args
try:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
except Exception:
logger.debug("WebRTC IP resolution failed — removing flag")
args = list(args)
del args[idx]
return args
if exit_ip:
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
args = list(args)
del args[idx]
return args
def build_args(
stealth_args: bool,
extra_args: list[str] | None,
timezone: str | None = None,
+2 -2
View File
@@ -15,10 +15,10 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.8"
CHROMIUM_VERSION = "145.0.7632.159.9"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.8",
"linux-x64": "145.0.7632.159.9",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
+16 -4
View File
@@ -53,6 +53,18 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
return tz, locale
def resolve_proxy_geo_with_ip(
proxy_url: str,
) -> tuple[str | None, str | None, str | None]:
"""Resolve timezone, locale, and exit IP from a proxy.
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
db_path = _ensure_geoip_db()
if db_path is None:
return None, None
return None, None, None
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
return None, None
return None, None, None
try:
with geoip2.database.Reader(str(db_path)) as reader:
@@ -82,10 +94,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
return timezone, locale, ip
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
return None, None, ip
# ---------------------------------------------------------------------------
+276 -13
View File
@@ -3,14 +3,20 @@
Activated via humanize=True in launch() / launch_async().
Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
Stealth-aware (fixes #110):
- isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
- Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
- Falls back to page.evaluate only when CDP session is unavailable
Supports both sync and async Playwright APIs.
"""
from __future__ import annotations
import json
import logging
import sys
from typing import Any
from typing import Any, Optional
from .config import HumanConfig, HumanPreset, resolve_config
from .config import rand, rand_range, sleep_ms, async_sleep_ms
@@ -33,6 +39,153 @@ __all__ = [
logger = logging.getLogger("cloakbrowser.human")
# ============================================================================
# CDP Isolated World — stealth DOM evaluation
# ============================================================================
class _SyncIsolatedWorld:
"""Manages a CDP isolated execution context for DOM reads (sync).
Produces clean Error.stack traces (no 'eval at evaluate :302:')
and is invisible to querySelector monkey-patches in the main world.
Context ID is invalidated on navigation and auto-recreated on next call.
"""
__slots__ = ("_page", "_cdp", "_context_id")
def __init__(self, page: Any):
self._page = page
self._cdp: Any = None
self._context_id: Optional[int] = None
def _ensure_cdp(self) -> Any:
if self._cdp is None:
self._cdp = self._page.context.new_cdp_session(self._page)
return self._cdp
def _create_world(self) -> int:
cdp = self._ensure_cdp()
tree = cdp.send("Page.getFrameTree")
frame_id = tree["frameTree"]["frame"]["id"]
result = cdp.send("Page.createIsolatedWorld", {
"frameId": frame_id,
"worldName": "",
"grantUniveralAccess": True,
})
self._context_id = result["executionContextId"]
return self._context_id
def evaluate(self, expression: str) -> Any:
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
if self._context_id is None:
self._create_world()
for attempt in range(2):
try:
result = self._cdp.send("Runtime.evaluate", {
"expression": expression,
"contextId": self._context_id,
"returnByValue": True,
})
if "exceptionDetails" in result:
if attempt == 0:
self._create_world()
continue
return None
return result.get("result", {}).get("value")
except Exception:
if attempt == 0:
self._context_id = None
try:
self._create_world()
except Exception:
return None
continue
return None
return None
def invalidate(self) -> None:
"""Mark context as stale — call after navigation."""
self._context_id = None
def get_cdp_session(self) -> Any:
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
return self._ensure_cdp()
class _AsyncIsolatedWorld:
"""Manages a CDP isolated execution context for DOM reads (async).
Same as _SyncIsolatedWorld but uses await for all CDP calls.
"""
__slots__ = ("_page", "_cdp", "_context_id")
def __init__(self, page: Any):
self._page = page
self._cdp: Any = None
self._context_id: Optional[int] = None
async def _ensure_cdp(self) -> Any:
if self._cdp is None:
self._cdp = await self._page.context.new_cdp_session(self._page)
return self._cdp
async def _create_world(self) -> int:
cdp = await self._ensure_cdp()
tree = await cdp.send("Page.getFrameTree")
frame_id = tree["frameTree"]["frame"]["id"]
result = await cdp.send("Page.createIsolatedWorld", {
"frameId": frame_id,
"worldName": "",
"grantUniveralAccess": True,
})
self._context_id = result["executionContextId"]
return self._context_id
async def evaluate(self, expression: str) -> Any:
"""Evaluate JS in isolated world. Auto-recreates on stale context."""
if self._context_id is None:
await self._create_world()
for attempt in range(2):
try:
result = await self._cdp.send("Runtime.evaluate", {
"expression": expression,
"contextId": self._context_id,
"returnByValue": True,
})
if "exceptionDetails" in result:
if attempt == 0:
await self._create_world()
continue
return None
return result.get("result", {}).get("value")
except Exception:
if attempt == 0:
self._context_id = None
try:
await self._create_world()
except Exception:
return None
continue
return None
return None
def invalidate(self) -> None:
"""Mark context as stale — call after navigation."""
self._context_id = None
async def get_cdp_session(self) -> Any:
"""Get the underlying CDP session (reused for Input.dispatchKeyEvent)."""
return await self._ensure_cdp()
# ============================================================================
# Cursor state
# ============================================================================
class _CursorState:
__slots__ = ("x", "y", "initialized")
@@ -42,7 +195,30 @@ class _CursorState:
self.initialized: bool = False
# ============================================================================
# Stealth DOM queries — isolated world with evaluate fallback
# ============================================================================
def _is_input_element(page: Any, selector: str) -> bool:
"""Check if selector is an input element. Uses CDP isolated world when available."""
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
if world is not None:
try:
escaped = json.dumps(selector)
result = world.evaluate(
f"(() => {{"
f" const el = document.querySelector({escaped});"
f" if (!el) return false;"
f" const tag = el.tagName.toLowerCase();"
f" return tag === 'input' || tag === 'textarea'"
f" || el.getAttribute('contenteditable') === 'true';"
f"}})()"
)
return bool(result)
except Exception:
pass
# Fallback: page.evaluate (detectable — should only happen if CDP fails)
try:
return page.evaluate(
"""(sel) => {
@@ -59,6 +235,24 @@ def _is_input_element(page: Any, selector: str) -> bool:
async def _async_is_input_element(page: Any, selector: str) -> bool:
"""Check if selector is an input element (async). Uses CDP isolated world when available."""
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
if world is not None:
try:
escaped = json.dumps(selector)
result = await world.evaluate(
f"(() => {{"
f" const el = document.querySelector({escaped});"
f" if (!el) return false;"
f" const tag = el.tagName.toLowerCase();"
f" return tag === 'input' || tag === 'textarea'"
f" || el.getAttribute('contenteditable') === 'true';"
f"}})()"
)
return bool(result)
except Exception:
pass
try:
return await page.evaluate(
"""(sel) => {
@@ -75,7 +269,22 @@ async def _async_is_input_element(page: Any, selector: str) -> bool:
def _is_selector_focused(page: Any, selector: str) -> bool:
"""Check if the element matching selector is currently focused."""
"""Check if the element matching selector is currently focused.
Uses CDP isolated world when available."""
world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None)
if world is not None:
try:
escaped = json.dumps(selector)
result = world.evaluate(
f"(() => {{"
f" const el = document.querySelector({escaped});"
f" return el === document.activeElement;"
f"}})()"
)
return bool(result)
except Exception:
pass
try:
return page.evaluate(
"""(sel) => {
@@ -89,7 +298,22 @@ def _is_selector_focused(page: Any, selector: str) -> bool:
async def _async_is_selector_focused(page: Any, selector: str) -> bool:
"""Check if the element matching selector is currently focused (async)."""
"""Check if the element matching selector is currently focused (async).
Uses CDP isolated world when available."""
world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None)
if world is not None:
try:
escaped = json.dumps(selector)
result = await world.evaluate(
f"(() => {{"
f" const el = document.querySelector({escaped});"
f" return el === document.activeElement;"
f"}})()"
)
return bool(result)
except Exception:
pass
try:
return await page.evaluate(
"""(sel) => {
@@ -216,7 +440,6 @@ def _patch_locator_class_sync():
def _humanized_press(self, key, **kwargs):
if _is_humanized(self):
selector = _get_selector(self)
# Only click if not already focused — avoids redundant mouse moves
if not _is_selector_focused(self.page, selector):
self.page.click(selector)
sleep_ms(rand(50, 150))
@@ -516,6 +739,17 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
page._original = originals
page._human_cfg = cfg
# --- Stealth infrastructure ---
try:
stealth = _SyncIsolatedWorld(page)
page._stealth_world = stealth
cdp_session = stealth.get_cdp_session()
except Exception:
stealth = None
page._stealth_world = None
cdp_session = None
logger.debug("Could not create CDP session — stealth features disabled")
raw_mouse: RawMouse = type("_RawMouse", (), {
"move": originals.mouse_move,
"down": originals.mouse_down,
@@ -539,6 +773,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
def _human_goto(url: str, **kwargs: Any) -> Any:
response = originals.goto(url, **kwargs)
# Invalidate isolated world after navigation (context ID becomes stale)
if stealth is not None:
stealth.invalidate()
return response
def _human_click(selector: str, **kwargs: Any) -> None:
@@ -593,7 +830,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
sleep_ms(rand_range(cfg.field_switch_delay))
_human_click(selector)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, cfg)
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
sleep_ms(rand_range(cfg.field_switch_delay))
@@ -603,7 +840,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, cfg)
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
def _human_check(selector: str, **kwargs: Any) -> None:
try:
@@ -646,7 +883,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
human_click(raw_mouse, False, cfg)
def _human_keyboard_type(text: str, **kwargs: Any) -> None:
human_type(page, raw_keyboard, text, cfg)
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
page.goto = _human_goto
page.click = _human_click
@@ -659,10 +896,10 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
page.press = _human_press
page.mouse.move = _human_mouse_move
page.mouse.click = _human_mouse_click
page.keyboard.type = _human_keyboard_type
page.keyboard.type = _human_keyboard_type
# --- Patch Frame-level methods (for sub-frames) ---
_patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
# Initialize cursor immediately so it doesn't visibly jump from (0,0)
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
@@ -690,6 +927,10 @@ def _patch_frames_sync(
def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
response = _orig_goto(url, **kwargs)
# Invalidate isolated world after navigation
stealth_world = getattr(page, '_stealth_world', None)
if stealth_world is not None:
stealth_world.invalidate()
for frame in _iter_frames(page):
if not getattr(frame, "_human_patched", False):
_patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
@@ -709,7 +950,6 @@ def _patch_single_frame_sync(
return
frame._human_patched = True
# Save originals for methods that need fallback
_orig_frame_select_option = frame.select_option
_orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None)
@@ -841,6 +1081,7 @@ def patch_browser(browser: Any, cfg: HumanConfig) -> None:
def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"""Replace page methods with human-like implementations (async)."""
originals = type("Originals", (), {
"click": page.click,
"type": page.type,
@@ -863,6 +1104,19 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
page._original = originals
page._human_cfg = cfg
# --- Stealth infrastructure (lazy-initialized, async) ---
stealth = _AsyncIsolatedWorld(page)
page._stealth_world = stealth
cdp_session_holder: list[Any] = [None] # mutable container for closure
async def _ensure_cdp() -> Any:
if cdp_session_holder[0] is None:
try:
cdp_session_holder[0] = await stealth.get_cdp_session()
except Exception:
logger.debug("Could not create async CDP session")
return cdp_session_holder[0]
raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), {
"move": originals.mouse_move,
"down": originals.mouse_down,
@@ -886,6 +1140,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
async def _human_goto(url: str, **kwargs: Any) -> Any:
response = await originals.goto(url, **kwargs)
# Invalidate isolated world after navigation
stealth.invalidate()
return response
async def _human_click(selector: str, **kwargs: Any) -> None:
@@ -940,7 +1196,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay))
await _human_click(selector)
await async_sleep_ms(rand(100, 250))
await async_human_type(page, raw_keyboard, text, cfg)
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay))
@@ -950,7 +1207,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
await async_human_type(page, raw_keyboard, value, cfg)
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
async def _human_check(selector: str, **kwargs: Any) -> None:
try:
@@ -988,7 +1246,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
await async_human_click(raw_mouse, False, cfg)
async def _human_keyboard_type(text: str, **kwargs: Any) -> None:
await async_human_type(page, raw_keyboard, text, cfg)
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
page.goto = _human_goto
page.click = _human_click
@@ -1024,6 +1283,10 @@ def _patch_frames_async(
async def _frame_aware_goto(url: str, **kwargs: Any) -> Any:
response = await _orig_goto(url, **kwargs)
# Invalidate isolated world after navigation
stealth_world = getattr(page, '_stealth_world', None)
if stealth_world is not None:
stealth_world.invalidate()
for frame in _iter_frames(page):
if not getattr(frame, "_human_patched", False):
_patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals)
+97 -20
View File
@@ -1,9 +1,14 @@
"""cloakbrowser-human — Human-like keyboard input."""
"""cloakbrowser-human — Human-like keyboard input.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
Falls back to page.evaluate when no CDP session is available.
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from typing import Any, Optional, Protocol
from .config import HumanConfig, rand, rand_range, sleep_ms
@@ -28,6 +33,25 @@ NEARBY_KEYS = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}
# CDP key code for each shift symbol's physical key.
_SHIFT_SYMBOL_CODES: dict[str, str] = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
}
# Windows virtual key codes for Input.dispatchKeyEvent.
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
}
def _get_nearby_key(ch: str) -> str:
"""Return a random adjacent key for the given character."""
@@ -39,7 +63,17 @@ def _get_nearby_key(ch: str) -> str:
return ch
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
def human_type(
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing.
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
@@ -62,7 +96,7 @@ def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None
if ch.isupper() and ch.isalpha():
_type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
_type_shift_symbol(page, raw, ch, cfg)
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
else:
_type_normal_char(raw, ch, cfg)
@@ -86,22 +120,65 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
raw.up("Shift")
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _type_shift_symbol(
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character.
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
sleep_ms(rand_range(cfg.key_hold))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _inter_char_delay(cfg: HumanConfig) -> None:
+76 -19
View File
@@ -2,15 +2,19 @@
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from typing import Any, Optional, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
class AsyncRawKeyboard(Protocol):
@@ -20,7 +24,17 @@ class AsyncRawKeyboard(Protocol):
async def insert_text(self, text: str) -> None: ...
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
async def async_human_type(
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing (async).
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
@@ -43,7 +57,7 @@ async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: Hum
if ch.isupper() and ch.isalpha():
await _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
await _type_shift_symbol(page, raw, ch, cfg)
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
else:
await _type_normal_char(raw, ch, cfg)
@@ -67,22 +81,65 @@ async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: Hum
await raw.up("Shift")
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _type_shift_symbol(
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character (async).
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
await async_sleep_ms(rand_range(cfg.key_hold))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _inter_char_delay(cfg: HumanConfig) -> None:
+4 -6
View File
@@ -3,14 +3,13 @@
browser-use handles AI agent logic, CloakBrowser handles bot detection.
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
Requires: pip install browser-use cloakbrowser langchain-openai
Requires: pip install browser-use cloakbrowser
Set OPENAI_API_KEY (or swap for another LLM provider).
"""
import asyncio
from browser_use import Agent, Browser, BrowserConfig
from langchain_openai import ChatOpenAI
from browser_use import Agent, BrowserSession, ChatOpenAI
from cloakbrowser import launch_async
@@ -23,14 +22,13 @@ async def main():
)
# Step 2: Connect browser-use to the stealth browser via CDP
config = BrowserConfig(cdp_url="http://127.0.0.1:9242")
browser = Browser(config=config)
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
# Step 3: Run your AI agent — it browses through CloakBrowser
agent = Agent(
task="Go to https://www.google.com and search for 'browser automation'",
llm=ChatOpenAI(model="gpt-4o-mini"),
browser=browser,
browser_session=session,
)
result = await agent.run()
+1 -1
View File
@@ -21,7 +21,7 @@ async def main():
)
# Step 2: Connect Crawl4AI to the stealth browser via CDP
browser_config = BrowserConfig(cdp_url="http://127.0.0.1:9243")
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
run_config = CrawlerRunConfig()
async with AsyncWebCrawler(config=browser_config) as crawler:
+1 -1
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.19",
"version": "0.3.20",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+2 -2
View File
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.8";
export const CHROMIUM_VERSION = "145.0.7632.159.9";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.8",
"linux-x64": "145.0.7632.159.9",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
+53 -8
View File
@@ -44,6 +44,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
export interface GeoResult {
timezone: string | null;
locale: string | null;
exitIp: string | null;
}
/**
@@ -65,12 +66,12 @@ export async function resolveProxyGeo(
}
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null };
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null };
if (!ip) return { timezone: null, locale: null, exitIp: null };
try {
const buf = fs.readFileSync(dbPath);
@@ -80,9 +81,9 @@ export async function resolveProxyGeo(
const countryCode: string | null = result?.country?.iso_code ?? null;
const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale };
return { timezone, locale, exitIp: ip };
} catch {
return { timezone: null, locale: null };
return { timezone: null, locale: null, exitIp: ip };
}
}
@@ -265,20 +266,64 @@ function maybeTriggerUpdate(dbPath: string): void {
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Shared by the Playwright and Puppeteer wrappers.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
const exitIp = geoExitIp ?? undefined;
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
exitIp,
};
}
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
*/
export async function resolveWebrtcArgs(
options: LaunchOptions
): Promise<string[] | undefined> {
const args = options.args;
if (!args) return args;
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args;
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
if (!proxyUrl) {
const result = [...args];
result.splice(idx, 1);
return result;
}
proxyUrl = ensureProxyScheme(proxyUrl);
try {
const ip = await resolveExitIp(proxyUrl);
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
result.splice(idx, 1);
}
return result;
} catch {
const result = [...args];
result.splice(idx, 1);
return result;
}
}
+209 -19
View File
@@ -4,12 +4,17 @@
* Activated via humanize: true in launch() / launchContext().
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
*
* Stealth-aware (fixes #110):
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*/
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
@@ -23,13 +28,148 @@ export { scrollToElement } from './scroll.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation
// ============================================================================
/**
* Manages a CDP isolated execution context for DOM reads.
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
* and is invisible to querySelector monkey-patches in the main world.
*
* Context ID is invalidated on navigation and auto-recreated on next call.
*/
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.context().newCDPSession(this.page);
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = tree.frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = result.executionContextId;
this.contextId = ctxId;
return ctxId;
}
/**
* Evaluate a JS expression in the isolated world.
* Auto-recreates the world if the context was invalidated (navigation).
* Returns the result value, or undefined on failure.
*/
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if (result.exceptionDetails) {
// Context was likely invalidated by navigation
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return result.result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try {
await this.createWorld();
} catch {
return undefined;
}
continue;
}
return undefined;
}
}
return undefined;
}
/** Mark context as stale — call after navigation. */
invalidate(): void {
this.contextId = null;
}
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
async function isInputElement(page: Page, selector: string): Promise<boolean> {
// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
// ============================================================================
/**
* Check if selector matches an input/textarea/contenteditable element.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
@@ -39,13 +179,37 @@ async function isInputElement(page: Page, selector: string): Promise<boolean> {
}, selector).catch(() => false);
}
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
/**
* Check if the element matching selector is currently focused.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
@@ -82,6 +246,21 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
// --- Stealth infrastructure ---
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try {
cdpSession = await stealth.getCdpSession();
} catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
@@ -105,11 +284,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}
}
// --- goto ---
// --- goto (invalidate isolated world on navigation) ---
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
// Patch any new frames after navigation
patchFrames(page, cfg, cursor, raw, rawKb, originals);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
@@ -122,7 +301,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
@@ -139,7 +318,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
@@ -169,7 +348,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// --- fill (clears existing content first) ---
@@ -181,12 +361,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
await humanType(page, rawKb, value, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, value, cfg, cdp);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
@@ -226,7 +407,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(50, 150));
@@ -235,11 +416,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// --- tap ---
@@ -277,7 +459,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => {
await humanType(page, rawKb, text, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// Store helpers for frame patching
@@ -301,7 +484,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}).catch(() => {});
// --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals);
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
}
@@ -321,13 +504,20 @@ function patchFrames(
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals);
patchSingleFrame(frame, page, cfg, originals, stealth);
}
}
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
@@ -374,7 +564,7 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
if (!await isSelectorFocused(stealth, page, selector)) {
await (page as any).click(selector);
}
await sleep(rand(50, 150));
+104 -18
View File
@@ -1,8 +1,12 @@
/**
* cloakbrowser-human — Human-like keyboard input.
*
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
* Falls back to page.evaluate when no CDPSession is available.
*/
import type { Page } from 'playwright-core';
import type { Page, CDPSession } from 'playwright-core';
import { RawKeyboard } from './mouse.js';
import { HumanConfig, rand, randRange, sleep } from './config.js';
@@ -22,6 +26,31 @@ const NEARBY_KEYS: Record<string, string> = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
/**
* CDP key code for each shift symbol's physical key.
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
*/
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
/**
* Windows virtual key codes for shift symbols.
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
*/
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
@@ -37,11 +66,24 @@ function getNearbyKey(ch: string): string {
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
/**
* Type text with human-like per-character timing, mistype simulation,
* and realistic shift handling.
*
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
* producing isTrusted=true events with no evaluate stack trace.
* If null/undefined, falls back to page.evaluate (detectable).
*/
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
const chars = [...text]; // Handle emoji surrogate pairs correctly
@@ -72,7 +114,7 @@ export async function humanType(
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg);
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
@@ -99,23 +141,67 @@ async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig):
await raw.up('Shift');
}
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
/**
* Type a shift symbol character.
*
* Stealth path (cdpSession provided):
* Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
*
* Fallback path (no cdpSession):
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
* Detectable via isTrusted=false and evaluate stack frame.
*/
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
// --- Stealth path: CDP Input.dispatchKeyEvent ---
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8, // Shift modifier flag
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
// --- Fallback path: page.evaluate (detectable) ---
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
+20 -7
View File
@@ -9,7 +9,7 @@ import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
@@ -38,8 +38,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
const browser = await chromium.launch({
executablePath: binaryPath,
@@ -87,11 +91,16 @@ export async function launchContext(
): Promise<BrowserContext> {
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false });
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
let context: BrowserContext;
try {
@@ -154,8 +163,12 @@ export async function launchPersistentContext(
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
+7 -3
View File
@@ -9,7 +9,7 @@ import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/**
* Launch stealth Chromium browser via Puppeteer.
@@ -28,8 +28,12 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials,
+12
View File
@@ -182,6 +182,18 @@ describe("buildArgs deduplication", () => {
});
});
describe("buildArgs webrtc IP", () => {
it("passes --fingerprint-webrtc-ip from args", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
});
it("does not inject when not in args", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
});
});
describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
+1
View File
@@ -14,6 +14,7 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
}));
describe("puppeteer launch", () => {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -56,6 +56,7 @@ dependencies = [
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts]
+56 -15
View File
@@ -1,24 +1,24 @@
"""Unit tests for _build_args timezone/locale injection and timezone alias."""
"""Unit tests for build_args timezone/locale injection and timezone alias."""
from cloakbrowser.browser import _build_args, _resolve_timezone
from cloakbrowser.browser import build_args, _resolve_timezone
def test_timezone_injected():
"""--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
args = build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected():
"""--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
args = build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected():
"""Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
args = build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
@@ -26,7 +26,7 @@ def test_both_injected():
def test_timezone_independent_of_stealth_args():
"""--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
args = build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
@@ -36,7 +36,7 @@ def test_timezone_independent_of_stealth_args():
def test_no_flags_when_not_set():
"""No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None)
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
@@ -44,7 +44,7 @@ def test_no_flags_when_not_set():
def test_extra_args_preserved():
"""Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
args = build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
@@ -90,7 +90,7 @@ def test_resolve_both_none():
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
args = build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887"
@@ -98,7 +98,7 @@ def test_user_fingerprint_overrides_default():
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
args = build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux"
@@ -106,7 +106,7 @@ def test_user_platform_overrides_default():
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
@@ -118,7 +118,7 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US",
@@ -133,7 +133,7 @@ def test_locale_param_overrides_user_arg():
def test_no_duplicate_flags():
"""No flag key should appear more than once in the output."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
@@ -145,7 +145,7 @@ def test_no_duplicate_flags():
def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
args = build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
@@ -156,5 +156,46 @@ def test_override_logs_debug(caplog):
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
# --- WebRTC IP spoofing ---
def test_webrtc_ip_passed_through_args():
"""--fingerprint-webrtc-ip in args should pass through to output."""
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
def test_webrtc_ip_not_present_by_default():
"""No --fingerprint-webrtc-ip when not in args."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
def test_resolve_webrtc_args_auto():
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
from cloakbrowser.browser import _resolve_webrtc_args
from unittest.mock import patch
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
def test_resolve_webrtc_args_explicit_ip_unchanged():
"""Explicit IP in args should not be touched."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
def test_resolve_webrtc_args_no_flag():
"""No webrtc flag in args should return args unchanged."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
assert result == ["--no-sandbox"]
+244
View File
@@ -0,0 +1,244 @@
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
# Load cloakserve as a module from bin/ (no .py extension).
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["cloakserve"] = _mod
_loader.exec_module(_mod)
parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
# ---------------------------------------------------------------------------
# parse_connection_params
# ---------------------------------------------------------------------------
class TestParseConnectionParams:
def test_empty_query(self):
result = parse_connection_params("")
assert result["seed"] is None
assert result["extra_args"] == []
def test_fingerprint_seed(self):
result = parse_connection_params("fingerprint=12345")
assert result["seed"] == "12345"
def test_timezone_and_locale(self):
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
assert result["timezone"] == "Asia/Tokyo"
assert result["locale"] == "ja-JP"
def test_proxy(self):
result = parse_connection_params("proxy=http://proxy:8080")
assert result["proxy"] == "http://proxy:8080"
def test_geoip_true_variants(self):
for val in ("true", "1", "yes", "True", "YES"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is True, f"geoip={val} should be True"
def test_geoip_false(self):
for val in ("false", "0", "no", "anything"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is False, f"geoip={val} should be False"
def test_generic_fingerprint_params(self):
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
result = parse_connection_params(qs)
assert "--fingerprint-platform=windows" in result["extra_args"]
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
def test_special_params_not_in_extra_args(self):
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
result = parse_connection_params(qs)
assert result["extra_args"] == []
def test_multiple_values_takes_first(self):
result = parse_connection_params("fingerprint=111&fingerprint=222")
assert result["seed"] == "111"
# ---------------------------------------------------------------------------
# parse_cli_args
# ---------------------------------------------------------------------------
class TestParseCliArgs:
def test_defaults(self):
config, passthrough = parse_cli_args([])
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert passthrough == []
def test_custom_port(self):
config, _ = parse_cli_args(["--port=8080"])
assert config["port"] == 8080
def test_headless_false(self):
config, passthrough = parse_cli_args(["--headless=false"])
assert config["headless"] is False
# headless flag still passed through to Chrome
assert "--headless=false" in passthrough
def test_strips_remote_debugging_flags(self):
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
config, passthrough = parse_cli_args(args)
assert passthrough == ["--no-sandbox"]
def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
_, passthrough = parse_cli_args(args)
assert passthrough == args
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
assert "--port=9222" not in passthrough
assert "--no-sandbox" in passthrough
def test_custom_data_dir(self):
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
assert config["data_dir"] == "/custom/path"
assert "--data-dir=/custom/path" not in passthrough
def test_data_dir_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@patch("os.path.exists", return_value=False)
def test_default_data_dir_bare_metal(self, _mock):
result = _default_data_dir()
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
class TestURLRewriting:
"""Test the URL rewriting logic used by /json/version and /json/list."""
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_version."""
if seed:
ws_path = f"fingerprint/{seed}/devtools/browser"
else:
ws_path = "devtools/browser"
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
return f"{scheme}://{host}/{ws_path}/{guid}"
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_list."""
ws_tail = orig_ws.split("/devtools/")[-1]
if seed:
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
else:
return f"{scheme}://{host}/devtools/{ws_tail}"
def test_version_rewrite_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", "12345")
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
def test_version_rewrite_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", None)
assert result == "ws://container:9222/devtools/browser/abc-123"
def test_list_rewrite_page_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", "99")
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
def test_list_rewrite_page_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", None)
assert result == "ws://host:9222/devtools/page/DEF-456"
def test_list_rewrite_browser(self):
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
def test_wss_scheme_version(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
def test_wss_scheme_list(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
# ---------------------------------------------------------------------------
# Connection refcounting
# ---------------------------------------------------------------------------
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
)
def test_connect_increments(self):
pool = self._make_pool()
pool.connect("seed1")
assert pool._connections["seed1"] == 1
pool.connect("seed1")
assert pool._connections["seed1"] == 2
def test_disconnect_decrements(self):
pool = self._make_pool()
pool.connect("seed1")
pool.connect("seed1")
pool.disconnect("seed1")
assert pool._connections["seed1"] == 1
def test_disconnect_to_zero_removes_key(self):
pool = self._make_pool()
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" not in pool._connections
def test_disconnect_below_zero_safe(self):
pool = self._make_pool()
pool.disconnect("nonexistent")
assert "nonexistent" not in pool._connections
def test_multiple_seeds_independent(self):
pool = self._make_pool()
pool.connect("a")
pool.connect("b")
pool.connect("a")
pool.disconnect("a")
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
+17 -12
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.browser import maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_is_private_ip,
@@ -92,51 +92,56 @@ def test_resolve_geo_returns_none_when_db_missing():
# ---------------------------------------------------------------------------
# _maybe_resolve_geoip (browser.py helper)
# maybe_resolve_geoip (browser.py helper)
# ---------------------------------------------------------------------------
def test_maybe_resolve_skips_when_geoip_false():
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
"""Explicit values should still resolve exit IP for WebRTC."""
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "1.2.3.4"
def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins
def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US"
def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "5.6.7.8"
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -114,7 +114,7 @@ def test_color_scheme(mock_launch, _mock_bin):
assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
+11 -11
View File
@@ -26,7 +26,7 @@ def _make_mock_pw_and_context():
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -42,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -56,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -71,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -103,7 +103,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -116,7 +116,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale — flows to binary args, not CDP context."""
@@ -150,7 +150,7 @@ def test_persistent_context_timezone_id_alias(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context()
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None))
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
+16 -13
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl:
@@ -68,49 +68,52 @@ class TestBuildProxyKwargs:
class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
tz, locale, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
assert ip == "1.2.3.4"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London"
assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
tz, locale, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert locale is None
assert ip is None
def test_geoip_no_proxy_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(True, None, None, None)
tz, locale, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
assert ip is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6"))
def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin"
assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
tz, locale, _ip = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized.
tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None)
tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
+168
View File
@@ -0,0 +1,168 @@
# tests/test_stealth_reproduction_110.py
"""
Exact reproduction of issue #110 detection vectors.
Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol)
are fixed with CDP isolated worlds.
"""
import asyncio
import pytest
@pytest.mark.slow
class TestIssue110Reproduction:
@pytest.mark.asyncio
async def test_exact_reproduction_from_issue(self):
"""Exact detection script from issue #110 — must produce zero detections."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
# === EXACT detection from issue #110 ===
await page.evaluate("""
() => {
window.__detections = {
evaluateQS: [],
untrustedKeydown: []
};
// Detection 1: querySelector from evaluate context
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__detections.evaluateQS.push(sel);
}
}
return origQS(sel);
};
// Detection 2: untrusted keyboard events
document.addEventListener('keydown', (e) => {
if (!e.isTrusted) {
window.__detections.untrustedKeydown.push(e.key);
}
}, true);
}
""")
# === Trigger all three vectors from issue ===
# Vector 1: isInputElement — click triggers querySelector check
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Vector 2+3: typeShiftSymbol — type text with shift symbols
await page.keyboard.type('Hello!@#$%^&*()')
await asyncio.sleep(0.5)
# === Verify: zero detections ===
detections = await page.evaluate('() => window.__detections')
qs_leaks = detections['evaluateQS']
untrusted = detections['untrustedKeydown']
print(f"\n{'='*60}")
print(f"Issue #110 Reproduction Results:")
print(f" querySelector from evaluate: {len(qs_leaks)} detections")
print(f" Untrusted keyboard events: {len(untrusted)} detections")
print(f"{'='*60}")
assert len(qs_leaks) == 0, (
f"LEAK: querySelector called from evaluate context: {qs_leaks}"
)
assert len(untrusted) == 0, (
f"LEAK: Untrusted keyboard events detected: {untrusted}"
)
await browser.close()
@pytest.mark.asyncio
async def test_all_21_shift_symbols_trusted(self):
"""Every single shift symbol must produce isTrusted=true."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__keyResults = { trusted: [], untrusted: [] };
const input = document.querySelector('#searchInput');
input.addEventListener('keydown', (e) => {
const list = e.isTrusted ? 'trusted' : 'untrusted';
window.__keyResults[list].push(e.key);
}, true);
}
""")
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Type ALL 21 shift symbols
all_shift = '!@#$%^&*()_+{}|:"<>?~'
await page.keyboard.type(all_shift)
await asyncio.sleep(1)
results = await page.evaluate('() => window.__keyResults')
print(f"\n{'='*60}")
print(f"All 21 Shift Symbols Test:")
print(f" Trusted: {results['trusted']}")
print(f" Untrusted: {results['untrusted']}")
print(f"{'='*60}")
# Every shift symbol must be trusted
for sym in all_shift:
assert sym in results['trusted'], f"'{sym}' NOT in trusted events"
assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events"
await browser.close()
@pytest.mark.asyncio
async def test_clear_uses_isolated_world(self):
"""clear() calls isSelectorFocused — must not leak evaluate."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__evalLeaks = [];
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__evalLeaks.push(sel);
}
}
return origQS(sel);
};
}
""")
# fill → click + type (isInputElement + isSelectorFocused)
await page.locator('#searchInput').fill('some text')
await asyncio.sleep(0.3)
# clear → isSelectorFocused check
await page.locator('#searchInput').clear()
await asyncio.sleep(0.3)
leaks = await page.evaluate('() => window.__evalLeaks')
assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}"
val = await page.locator('#searchInput').input_value()
assert val == '', f"clear() didn't clear: '{val}'"
await browser.close()
File diff suppressed because it is too large Load Diff