Compare commits

..
Author SHA1 Message Date
CloakHQ 749ed561a6 fix(humanize): strip custom kwargs before forwarding to Playwright select_option
originals.select_option(**kwargs) passes human_config/force to Playwright
which rejects unknown kwargs with TypeError.
2026-05-15 20:51:30 +02:00
CloakHQ f4f6a3f8fd fix(humanize): forward human_config in all chained methods, use evaluate args in handle pointer checks
- Add human_config=kwargs.get("human_config") to check/uncheck/select_option/press inner calls (sync+async+JS)
- Convert check_pointer_events_handle from f-string interpolation to evaluate args pattern (sync+async+JS)
2026-05-15 19:17:11 +02:00
CloakHQ 39f807f246 feat(humanize): add Playwright-style actionability checks to all interaction methods
Humanized locator/page methods now perform pre-action validation matching
Playwright's native behavior: attached, visible, enabled, editable, stable,
and receives-pointer-events checks with retry loop and backoff.

- New error hierarchy: ActionabilityError base with ElementNotAttachedError,
  ElementNotVisibleError, ElementNotStableError, ElementNotEnabledError,
  ElementNotEditableError, ElementNotReceivingEventsError
- force=True parameter skips all actionability checks (matches Playwright)
- Shared deadline across all steps (checks + scroll + stable + pointer)
- Post-scroll stability check only runs when scroll actually happened
- Chained methods (type/fill/check/uncheck/press) skip inner click checks
  but still run pointer-events check at actual click coordinates
- Frame methods now forward kwargs (force, timeout, human_config)
- Locator patches forward force via _forward_kwargs
- Python sync + async, JS/TS implementation
2026-05-12 22:58:03 +02:00
50 changed files with 944 additions and 3587 deletions
-18
View File
@@ -8,21 +8,3 @@ updates:
actions:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
python:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/js"
schedule:
interval: "weekly"
groups:
javascript:
patterns:
- "*"
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
+9 -9
View File
@@ -24,7 +24,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
@@ -100,20 +100,20 @@ jobs:
attestations: write
packages: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
- 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 }}
- name: Build and push
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
-1
View File
@@ -46,7 +46,6 @@ js/dist/
*.whl
AGENTS.md
.beads
result
# Private docs (launch posts, strategy)
docs/
-40
View File
@@ -8,46 +8,6 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
## [0.3.32] — 2026-06-20
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding
- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234)
- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352)
- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374)
- **[meta]** Bump GitHub Actions in the actions group (#358)
## [0.3.31] — 2026-05-26
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
## [0.3.30] — 2026-05-21
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
- **[binary]** Rendering consistency improvements across Linux and Windows — corrected GPU, display, and graphics parameters to match stock Chrome 146 profiles
- **[binary]** Windows: native GPU/rendering values now pass through directly instead of being spoofed, matching real hardware behavior
- **[binary]** Storage normalization fix for Windows
- **[binary]** HTTP proxy inline credential support at the network layer
- **[wrapper]** Update `PLATFORM_CHROMIUM_VERSIONS` for linux-x64 and windows-x64 to 146.0.7680.177.5
## [0.3.29] — 2026-05-20
- **[wrapper]** **Security**: `cloakserve` — guard WebSocket origins to prevent browser-origin CSRF via CDP proxy (thanks [@0xlally](https://github.com/0xlally) for the report, [@honor2030](https://github.com/honor2030) for the fix, #239, #240)
- **[wrapper]** **Security**: Lambda example — add URL scheme validation, SSRF protection, post-navigation re-validation, remove unsafe caller-controlled options (#233)
- **[wrapper]** **Security**: CI — isolate `workflow_dispatch` input to avoid shell injection in attest-release (thanks [@aaronjmars](https://github.com/aaronjmars), #223)
- **[wrapper]** **Security**: JS — bump tar + transitive deps via npm audit fix (thanks [@aaronjmars](https://github.com/aaronjmars), #222)
- **[wrapper]** Add `extension_paths` parameter for loading Chrome extensions in all launch functions (thanks [@zackycodes](https://github.com/zackycodes), #210)
- **[wrapper]** Humanize: add Playwright-style actionability checks — auto-wait for visible, enabled, stable elements before humanized actions (#228)
- **[wrapper]** JS: export composable launch helpers — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations (thanks [@honor2030](https://github.com/honor2030), #244)
- **[wrapper]** JS: add `launchPersistentContext()` to Puppeteer wrapper (#261)
- **[wrapper]** Add `flake.nix` for Nix/NixOS (thanks [@Seryiza](https://github.com/Seryiza), #220)
- **[meta]** JS: sync package-lock metadata (thanks [@245678000000](https://github.com/245678000000), #219)
## [0.3.28] — 2026-05-11
- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217)
+23 -159
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>
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
- **49 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
@@ -59,7 +59,7 @@ from cloakbrowser import launch
browser = launch()
page = browser.new_page()
page.goto("https://example.com")
page.goto("https://protected-site.com") # no more blocks
browser.close()
```
@@ -69,34 +69,12 @@ import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.goto('https://protected-site.com');
await browser.close();
```
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
**For sites with anti-bot protection**, add a residential proxy and these flags:
```python
browser = launch(
proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter
geoip=True, # match timezone + locale to proxy IP
headless=False, # some sites detect headless even with C++ patches
humanize=True, # human-like mouse, keyboard, scroll
)
```
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA).
## Install
**Python:**
@@ -150,19 +128,17 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
---
## Latest: v0.3.32 (Chromium 146.0.7680.177.5)
## Latest: v0.3.28 (Chromium 146.0.7680.177.4)
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
- **HTTP proxy inline credentials** — new network-layer support for proxies with inline authentication
- **`extension_paths`** — load Chrome extensions in all launch functions
- **Humanize actionability** — auto-wait for visible, enabled, stable elements before humanized actions
- **Per-call `human_config`** — override humanize settings on individual method calls
- **Composable JS helpers** — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
- **`launch_context_async()`** — async counterpart to `launch_context()`. Forwards kwargs to `browser.new_context()` for `storage_state`, `permissions`, `extra_http_headers` without a persistent profile folder.
- **JS `contextOptions` escape hatch** — forward arbitrary options (including `storageState`) to Playwright's `newContext()` from `launchContext()` / `launchPersistentContext()`.
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE.
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
- **57 fingerprint patches** — additional detection-vector coverage (WebAuthn, AAC audio, window position) and WebGL/canvas consistency fixes
- **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
@@ -250,7 +226,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 58 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking.
The binary includes 49 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.
@@ -383,7 +359,6 @@ Use this when you need to:
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm))
```python
from cloakbrowser import launch_persistent_context
@@ -396,16 +371,9 @@ ctx.close() # profile saved
# Next run — cookies, localStorage restored automatically
ctx = launch_persistent_context("./my-profile", headless=False)
# Load Chrome extensions
ctx = launch_persistent_context(
"./my-profile",
headless=False,
extension_paths=["./my-extension"],
)
```
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`, `extension_paths`.
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
Async version: `launch_persistent_context_async()`.
@@ -420,26 +388,6 @@ ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quo
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### Widevine / DRM
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
```python
from cloakbrowser import launch_persistent_context
# WidevineCdm sideloaded next to the binary -> Widevine works on first launch
ctx = launch_persistent_context("./my-profile", headless=False)
```
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
### CLI
Pre-download the binary or check installation status from the command line:
@@ -458,7 +406,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '146.0.7680.177.5', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '146.0.7680.177.3', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -623,8 +571,6 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
## Fingerprint Management
@@ -794,11 +740,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 146 | 58 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
| Linux x86_64 | 146 | 57 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 57 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 146 | 58 | ✅ Latest |
| Windows x86_64 | 146 | 57 | ✅ Latest |
The wrapper auto-downloads the correct binary for your platform.
@@ -862,26 +808,6 @@ print(page.title())
browser.close()
```
If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact:
```bash
curl http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/devtools/browser/<browser-id>
curl 'http://localhost:9222/json/version?fingerprint=11111' | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/fingerprint/11111/devtools/browser/<browser-id>
```
When `cloakserve` runs behind a reverse proxy or TLS terminator, forward the public host/protocol headers so generated WebSocket URLs use the address clients can actually reach:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
```
With those headers, `/json/version` returns public endpoints such as `wss://cdp.example.com/fingerprint/11111/devtools/browser/<browser-id>` instead of an internal container host.
Pass extra flags to the browser:
```bash
@@ -892,10 +818,6 @@ docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
# Reap disconnected per-seed browser processes after 5 minutes
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --idle-timeout=300
```
Stop the server:
@@ -947,9 +869,7 @@ b4 = pw.chromium.connect_over_cdp(
)
```
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).
By default, per-seed processes stay alive until `cloakserve` exits. If clients create many unique seeds, set `--idle-timeout=SECONDS` or `CLOAKSERVE_IDLE_TIMEOUT=SECONDS` to automatically terminate a seed's Chrome process after its last CDP WebSocket disconnects. `0`, `off`, `false`, `none`, or `disabled` disable idle cleanup. When cleanup runs, the seed's temporary profile directory under `--data-dir` is removed too. Check active processes at `GET /` (returns JSON with PIDs, ports, connection counts, idle timeout, and pending cleanup status).
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:
@@ -1057,53 +977,6 @@ If you're still blocked after this, check the font setup below.
---
### Detected by FingerprintJS?
FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause:
| Detection | Cause | Fix |
|-----------|-------|-----|
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
```python
browser = launch(
headless=False,
proxy="http://user:pass@residential-proxy:port",
geoip=True,
args=[
"--fingerprint-noise=false", # prevents tampering detection
"--fingerprint-screen-width=1920", # match your viewport
"--fingerprint-screen-height=1080",
],
)
```
```javascript
const browser = await launch({
headless: false,
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
args: [
'--fingerprint-noise=false',
'--fingerprint-screen-width=1920',
'--fingerprint-screen-height=1080',
],
});
```
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
---
### Blocked on Kasada / Akamai sites despite correct config?
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
@@ -1270,9 +1143,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 146 (58 patches) | ✅ Released |
| Linux x64 — Chromium 146 (57 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 146 (58 patches) | ✅ Released |
| Windows x64 — Chromium 146 (57 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
@@ -1294,7 +1167,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-v146.0.7680.177.5
git verify-tag chromium-v146.0.7680.177.3
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
@@ -1320,16 +1193,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening
- [@dgtlmoon](https://github.com/dgtlmoon) — graceful pw.stop() cleanup
- [@zackycodes](https://github.com/zackycodes) — Chrome extension loading
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
- [@245678000000](https://github.com/245678000000) — package-lock sync
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, CDP WebSocket URL rewrite, composable JS launch helpers
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
- [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration
+5 -194
View File
@@ -18,7 +18,6 @@ Client:
from __future__ import annotations
import asyncio
import ipaddress
import json
import logging
import os
@@ -30,7 +29,7 @@ import subprocess
import sys
import time
from dataclasses import dataclass
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs
from pathlib import Path
@@ -65,91 +64,6 @@ BASE_CDP_PORT = 5100
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
RESERVED_SEEDS = {"__default__"}
TRUSTED_WS_ORIGINS = {"devtools://devtools", "chrome-devtools://devtools"}
def _host_port_from_netloc(netloc: str, default_port: int) -> tuple[str, int] | None:
"""Return a normalized (host, port) pair for an Origin/Host netloc."""
if "," in netloc:
return None
try:
parsed = urlparse(f"//{netloc.strip()}")
authority = parsed.netloc.rsplit("@", 1)[-1]
if (
not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or authority.endswith(":")
or parsed.path
or parsed.params
or parsed.query
or parsed.fragment
):
return None
return (parsed.hostname.lower(), parsed.port if parsed.port is not None else default_port)
except ValueError:
return None
def _is_loopback_host(hostname: str) -> bool:
"""Return True for localhost and loopback IP literals."""
hostname = hostname.strip("[]").rstrip(".").lower()
if hostname == "localhost":
return True
try:
return ipaddress.ip_address(hostname).is_loopback
except ValueError:
return False
def _origin_is_allowed(
origin: str | None,
host: str | None,
request_scheme: str = "http",
) -> bool:
"""Return True when a WebSocket Origin is safe to proxy to local CDP."""
if origin is None:
# Playwright/Puppeteer and other non-browser CDP clients commonly omit
# Origin. Keep those clients working while rejecting browser-origin CSRF.
return True
origin = origin.strip()
if not origin or origin.lower() == "null":
return False
if origin in TRUSTED_WS_ORIGINS:
return True
try:
parsed = urlparse(origin)
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.path or parsed.params or parsed.query or parsed.fragment:
return False
origin_default_port = 443 if parsed.scheme == "https" else 80
request_scheme = request_scheme.split(",", 1)[0].strip().lower()
request_default_port = 443 if request_scheme in ("https", "wss") else 80
origin_host = _host_port_from_netloc(parsed.netloc, origin_default_port)
request_host = _host_port_from_netloc(host or "", request_default_port)
if origin_host is None or request_host is None:
return False
if not _is_loopback_host(request_host[0]):
return False
return origin_host == request_host
def _reject_untrusted_origin(request: web.Request) -> web.Response | None:
"""Reject browser-origin WebSocket upgrades that would expose local CDP."""
origin = request.headers.get("Origin")
host = request.headers.get("Host")
scheme = request.headers.get("X-Forwarded-Proto", getattr(request, "scheme", "http"))
if _origin_is_allowed(origin, host, request_scheme=scheme):
return None
logger.warning("Rejected CDP WebSocket from untrusted Origin %r for Host %r", origin, host)
return web.Response(status=403, text="Forbidden: untrusted WebSocket origin\n")
# ---------------------------------------------------------------------------
@@ -181,7 +95,6 @@ class ChromePool:
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
idle_timeout: float = 0.0,
):
self._binary = binary
self._global_args = global_args
@@ -190,14 +103,12 @@ class ChromePool:
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._idle_timeout = idle_timeout
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] = {}
self._idle_tasks: dict[str, asyncio.Task] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
@@ -227,7 +138,6 @@ class ChromePool:
def connect(self, seed_key: str) -> None:
"""Increment connection refcount for a seed."""
self._cancel_idle_cleanup(seed_key)
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
def disconnect(self, seed_key: str) -> None:
@@ -235,54 +145,9 @@ class ChromePool:
count = self._connections.get(seed_key, 0) - 1
if count <= 0:
self._connections.pop(seed_key, None)
self._schedule_idle_cleanup(seed_key)
else:
self._connections[seed_key] = count
def _cancel_idle_cleanup(self, seed_key: str) -> None:
task = self._idle_tasks.pop(seed_key, None)
if task is None or task.done():
return
try:
current_task = asyncio.current_task()
except RuntimeError:
current_task = None
if task is not current_task:
task.cancel()
def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None:
if self._idle_tasks.get(seed_key) is task:
self._idle_tasks.pop(seed_key, None)
def _schedule_idle_cleanup(self, seed_key: str) -> None:
if self._idle_timeout <= 0 or seed_key not in self._processes:
return
self._cancel_idle_cleanup(seed_key)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(
self._cleanup_after_idle(seed_key, self._idle_timeout),
name=f"cloakserve-idle-cleanup-{seed_key}",
)
self._idle_tasks[seed_key] = task
task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task))
async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None:
try:
await asyncio.sleep(timeout)
if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes:
return
logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key)
await self._cleanup_process(seed_key)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Idle cleanup failed for seed=%s", seed_key)
async def get_or_launch(
self,
seed: str | None,
@@ -320,8 +185,6 @@ class ChromePool:
if seed_key in self._processes:
proc = self._processes[seed_key]
if proc.process.poll() is None:
if seed_key in self._idle_tasks:
self._schedule_idle_cleanup(seed_key)
if any([extra_args, timezone, locale, proxy, geoip]):
logger.warning(
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
@@ -411,7 +274,6 @@ class ChromePool:
async def _cleanup_process(self, key: str) -> None:
"""Terminate a Chrome process and clean up."""
self._cancel_idle_cleanup(key)
proc = self._processes.pop(key, None)
if not proc:
return
@@ -429,13 +291,6 @@ class ChromePool:
async def shutdown(self) -> None:
"""Terminate all Chrome processes."""
idle_tasks = list(self._idle_tasks.values())
self._idle_tasks.clear()
for task in idle_tasks:
if not task.done():
task.cancel()
if idle_tasks:
await asyncio.gather(*idle_tasks, return_exceptions=True)
for key in list(self._processes.keys()):
await self._cleanup_process(key)
logger.info("All Chrome processes terminated")
@@ -512,21 +367,9 @@ def parse_connection_params(query_string: str) -> dict:
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)
proto = proto.split(",", 1)[0].strip().lower()
return "wss" if proto == "https" else "ws"
def _external_host(request: web.Request) -> str:
"""Return the public host to use in rewritten CDP WebSocket URLs."""
fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}"
forwarded_host = request.headers.get("X-Forwarded-Host")
if forwarded_host:
public_host = forwarded_host.split(",", 1)[0].strip()
if public_host:
return public_host
return fallback_host
async def handle_root(request: web.Request) -> web.Response:
"""Health check / process status."""
pool: ChromePool = request.app["pool"]
@@ -538,7 +381,6 @@ async def handle_root(request: web.Request) -> web.Response:
"port": proc.cdp_port,
"seed": proc.seed,
"connections": pool._connections.get(key, 0),
"idle_cleanup_pending": key in pool._idle_tasks,
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
@@ -546,7 +388,6 @@ async def handle_root(request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"active": len(processes),
"idle_timeout": pool._idle_timeout,
"processes": processes,
})
@@ -577,7 +418,7 @@ async def handle_json_version(request: web.Request) -> web.Response:
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
# Rewrite webSocketDebuggerUrl to route through our multiplexer
host = _external_host(request)
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"
@@ -618,7 +459,7 @@ async def handle_json_list(request: web.Request) -> web.Response:
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 = _external_host(request)
host = request.headers.get("Host", f"localhost:{request.app['port']}")
scheme = _ws_scheme(request)
seed_key = params["seed"]
@@ -686,12 +527,8 @@ async def proxy_cdp_websocket(
logger.error("%s error: %s", label, exc)
async def handle_ws_default(request: web.Request) -> web.StreamResponse:
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
rejected = _reject_untrusted_origin(request)
if rejected is not None:
return rejected
pool: ChromePool = request.app["pool"]
path = request.match_info.get("path", "")
@@ -709,12 +546,8 @@ async def handle_ws_default(request: web.Request) -> web.StreamResponse:
return ws
async def handle_ws_seed(request: web.Request) -> web.StreamResponse:
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
rejected = _reject_untrusted_origin(request)
if rejected is not None:
return rejected
pool: ChromePool = request.app["pool"]
seed = request.match_info["seed"]
path = request.match_info.get("path", "")
@@ -748,23 +581,6 @@ def _default_data_dir() -> str:
return str(Path.home() / ".cloakbrowser" / "cloakserve")
def _parse_idle_timeout(value: str) -> float:
value = value.strip()
if value.lower() in {"0", "false", "off", "none", "disabled"}:
return 0.0
timeout = float(value)
if timeout < 0:
raise ValueError("--idle-timeout must be greater than or equal to 0")
return timeout
def _default_idle_timeout() -> float:
value = os.environ.get("CLOAKSERVE_IDLE_TIMEOUT")
if value is None:
return 0.0
return _parse_idle_timeout(value)
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args).
@@ -780,14 +596,12 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"default_seed": None,
"default_locale": None,
"default_timezone": None,
"idle_timeout": _default_idle_timeout(),
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--idle-timeout=",
"--remote-debugging-port=",
"--remote-debugging-address=",
)
@@ -797,8 +611,6 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
config["port"] = int(arg.split("=", 1)[1])
elif arg.startswith("--data-dir="):
config["data_dir"] = arg.split("=", 1)[1]
elif arg.startswith("--idle-timeout="):
config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1])
elif arg == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
@@ -843,7 +655,6 @@ def main() -> None:
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
idle_timeout=config["idle_timeout"],
)
app = web.Application()
-8
View File
@@ -1,12 +1,4 @@
#!/bin/bash
# Clean up any stale Xvfb lock left behind by a previous container instance.
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
# existing lock — leaving the container with no X server, every Chrome
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
# returning 502 forever. See CloakHQ/CloakBrowser#283.
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.32"
__version__ = "0.3.28"
+13 -130
View File
@@ -22,7 +22,6 @@ from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary
from .human.config import HumanConfigOverrides, HumanPreset
from .widevine import seed_widevine_hint
logger = logging.getLogger("cloakbrowser")
@@ -65,7 +64,6 @@ def launch(
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -77,7 +75,6 @@ def launch(
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
— passed directly to Playwright.
args: Additional Chromium CLI arguments to pass.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags.
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
@@ -115,8 +112,7 @@ def launch(
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -163,7 +159,6 @@ async def launch_async( # noqa: C901
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch(). Returns a Playwright Browser object.
@@ -172,7 +167,6 @@ async def launch_async( # noqa: C901
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments to pass.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
@@ -208,7 +202,7 @@ async def launch_async( # noqa: C901
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -259,7 +253,6 @@ def launch_persistent_context(
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext.
@@ -275,7 +268,6 @@ def launch_persistent_context(
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
@@ -314,7 +306,7 @@ def launch_persistent_context(
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -337,8 +329,6 @@ def launch_persistent_context(
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
seed_widevine_hint(user_data_dir, binary_path)
pw = sync_playwright().start()
context = pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
@@ -387,7 +377,6 @@ async def launch_persistent_context_async(
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_persistent_context().
@@ -402,7 +391,6 @@ async def launch_persistent_context_async(
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
@@ -444,7 +432,7 @@ async def launch_persistent_context_async(
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 or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -467,8 +455,6 @@ async def launch_persistent_context_async(
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
seed_widevine_hint(user_data_dir, binary_path)
pw = await async_playwright().start()
context = await pw.chromium.launch_persistent_context(
user_data_dir=os.fspath(user_data_dir),
@@ -516,7 +502,6 @@ def launch_context(
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -528,7 +513,6 @@ def launch_context(
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
@@ -560,7 +544,7 @@ def launch_context(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -617,7 +601,6 @@ async def launch_context_async(
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
extension_paths: list[str] | None = None,
**kwargs: Any,
) -> Any:
"""Async version of launch_context().
@@ -631,7 +614,6 @@ async def launch_context_async(
headless: Run in headless mode (default True).
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments.
extension_paths: List of Chrome extension paths to load.
stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
@@ -680,7 +662,7 @@ async def launch_context_async(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {}
if user_agent:
@@ -779,7 +761,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _assemble_proxy_url(
def _assemble_socks_url(
scheme: str,
host: str,
port: int | None,
@@ -790,7 +772,7 @@ def _assemble_proxy_url(
query: str = "",
fragment: str = "",
) -> str:
"""Build a proxy URL from already-percent-encoded credentials and host parts.
"""Build a SOCKS URL from already-percent-encoded credentials and host parts.
``enc_pass is None`` means no password (no colon in userinfo). Empty string
means present-but-empty (colon preserved). This mirrors the distinction
@@ -821,7 +803,7 @@ def _reconstruct_socks_url(proxy: ProxySettings) -> str:
enc_user = quote(username, safe="")
# Dict convention: empty/missing password → no colon.
enc_pass = quote(password, safe="") if password else None
return _assemble_proxy_url(
return _assemble_socks_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
@@ -861,7 +843,7 @@ def _normalize_socks_string_url(url: str) -> str:
else:
raw_pass = None
enc_pass = None
normalized = _assemble_proxy_url(
normalized = _assemble_socks_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
@@ -975,7 +957,6 @@ def build_args(
timezone: str | None = None,
locale: str | None = None,
headless: bool = True,
extension_paths: list[str] | None = None,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags.
@@ -1019,15 +1000,6 @@ def build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag
if extension_paths:
abs_paths = [os.path.abspath(p) for p in extension_paths]
ext_val = ",".join(abs_paths)
seen["--load-extension"] = f"--load-extension={ext_val}"
seen["--disable-extensions-except"] = (
f"--disable-extensions-except={ext_val}"
)
return list(seen.values())
@@ -1066,81 +1038,6 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result
def _has_credentials(proxy: str | ProxySettings) -> bool:
"""Check if the proxy has inline or dict-level credentials."""
if isinstance(proxy, dict):
return bool(proxy.get("username"))
return "@" in proxy
def _reconstruct_http_url(proxy: ProxySettings) -> str:
"""Reconstruct an HTTP(S) proxy URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(_ensure_proxy_scheme(server))
enc_user = quote(username, safe="")
enc_pass = quote(password, safe="") if password else None
return _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
def _normalize_http_string_url(url: str) -> str:
"""Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
Same pattern as ``_normalize_socks_string_url`` — decode then re-encode to
ensure Chromium's proxy URL parser handles special chars correctly.
"""
normalized = url if "://" in url else f"http://{url}"
try:
parsed = urlparse(normalized)
_ = parsed.port
except ValueError as e:
logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e)
return normalized
if parsed.username is None and parsed.password is None:
return normalized
raw_user = parsed.username or ""
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
if parsed.password is not None:
raw_pass = parsed.password
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
else:
raw_pass = None
enc_pass = None
result = _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
)
if enc_user != raw_user or enc_pass != raw_pass:
logger.info(
"Auto URL-encoded HTTP proxy credentials (special characters "
"detected). Pre-encode the URL to suppress this notice."
)
return result
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
def _supports_http_proxy_inline_auth() -> bool:
"""Check if the current platform's binary supports HTTP proxy inline credentials.
Requires both a supported platform AND a binary version with preemptive proxy auth.
"""
from .config import get_platform_tag, get_chromium_version, _version_tuple
tag = get_platform_tag()
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
return False
return _version_tuple(get_chromium_version()) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
@@ -1154,9 +1051,8 @@ def _resolve_proxy_config(
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
--proxy-server flag with inline credentials, bypassing Playwright's CDP
auth interceptor which breaks on some proxies and Google domains (#182).
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
so SOCKS5 is passed via --proxy-server Chrome arg instead.
Returns:
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
@@ -1177,20 +1073,7 @@ def _resolve_proxy_config(
# passwords at '=' and other special chars (#157).
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
# HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
# CDP auth interceptor, pass directly to Chrome via --proxy-server with
# inline creds. Chrome sends Proxy-Authorization preemptively, avoiding
# the 407 round-trip that breaks on some proxies (#182).
if _has_credentials(proxy) and _supports_http_proxy_inline_auth():
if isinstance(proxy, dict):
url = _reconstruct_http_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
return {}, extra_args
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
# HTTP/HTTPS without credentials: use Playwright's proxy dict
# HTTP/HTTPS: use Playwright's proxy dict as before
if isinstance(proxy, dict):
return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)}, []
+3 -3
View File
@@ -15,14 +15,14 @@ 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 = "146.0.7680.177.5"
CHROMIUM_VERSION = "146.0.7680.177.3"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "146.0.7680.177.5",
"linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "146.0.7680.177.5",
"windows-x64": "146.0.7680.177.4",
}
# ---------------------------------------------------------------------------
+32 -86
View File
@@ -1257,16 +1257,13 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_click(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
@@ -1274,16 +1271,13 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_dblclick(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
@@ -1293,11 +1287,8 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_hover(**kwargs)
@@ -1307,16 +1298,13 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_type(text, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
@@ -1326,16 +1314,13 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_fill(value, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
@@ -1382,11 +1367,8 @@ def _patch_single_element_handle_sync(
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
info = _move_to_element()
if info is None:
return _orig_select_option(value, **kwargs)
@@ -1398,11 +1380,8 @@ def _patch_single_element_handle_sync(
def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
if el.is_checked():
return
@@ -1412,18 +1391,15 @@ def _patch_single_element_handle_sync(
if info is None:
return _orig_check(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
if not el.is_checked():
return
@@ -1433,18 +1409,15 @@ def _patch_single_element_handle_sync(
if info is None:
return _orig_uncheck(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
current = el.is_checked()
if current == checked:
@@ -1456,7 +1429,7 @@ def _patch_single_element_handle_sync(
return _orig_set_checked(checked, **kwargs)
if info:
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
@@ -2185,16 +2158,13 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_click(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
@@ -2202,16 +2172,13 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_dblclick(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
@@ -2221,11 +2188,8 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_hover(**kwargs)
@@ -2235,16 +2199,13 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_type(text, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp()
@@ -2255,16 +2216,13 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_fill(value, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
@@ -2311,11 +2269,8 @@ def _patch_single_element_handle_async(
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
info = await _move_to_element()
if info is None:
return await _orig_select_option(value, **kwargs)
@@ -2327,11 +2282,8 @@ def _patch_single_element_handle_async(
async def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
if await el.is_checked():
return
@@ -2341,18 +2293,15 @@ def _patch_single_element_handle_async(
if info is None:
return await _orig_check(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
async def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
if not await el.is_checked():
return
@@ -2362,18 +2311,15 @@ def _patch_single_element_handle_async(
if info is None:
return await _orig_uncheck(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
try:
current = await el.is_checked()
if current == checked:
@@ -2385,7 +2331,7 @@ def _patch_single_element_handle_async(
return await _orig_set_checked(checked, **kwargs)
if info:
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
+11 -23
View File
@@ -196,14 +196,8 @@ def ensure_stable(
# Pointer-events check (post-scroll, at actual click coordinates)
# ---------------------------------------------------------------------------
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
# is the iframe offset, needed to map page-space click coords into the frame's
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
_POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -211,11 +205,8 @@ _POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}"""
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
_POINTER_EVENTS_HANDLE_JS = """(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -239,19 +230,17 @@ def check_pointer_events(
"""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
loc = page.locator(selector).first
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
if result and result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
@@ -333,16 +322,15 @@ def check_pointer_events_handle(
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
box = el.bounding_box()
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
except Exception:
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
if result and result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
+7 -10
View File
@@ -140,19 +140,17 @@ async def async_check_pointer_events(
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
loc = page.locator(selector).first
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
if result and result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
@@ -229,16 +227,15 @@ async def async_check_pointer_events_handle(
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
box = await el.bounding_box()
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
except Exception:
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
if result and result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
-112
View File
@@ -1,112 +0,0 @@
"""Widevine CDM hint-file seeding for persistent contexts.
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
is a proprietary Google binary we can't redistribute). Users sideload it by
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
binary (see issue #96).
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
reads a "hint file" from the user-data-dir, and a later async component-updater
pass that writes that hint file. On a fresh profile the hint file doesn't exist
on the first launch, and Playwright passes ``--disable-component-update``, so the
updater never writes it Widevine only works after a manual two-launch dance.
This module pre-seeds the hint file before launch so a sideloaded CDM works on
the very first launch. It never bundles, downloads, or copies the CDM itself
it only writes the hint when a CDM the user provided is already present.
Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows
the CDM can't initialise (DRM host verification), and macOS uses a different CDM
layout, so seeding is a no-op there.
"""
from __future__ import annotations
import json
import logging
import os
import platform
from pathlib import Path
logger = logging.getLogger("cloakbrowser")
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
def _seeding_disabled() -> bool:
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
return val in ("0", "false", "off", "no")
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
"""Locate a sideloaded Widevine CDM directory, or None if absent.
Resolution:
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` where a user
naturally drops it, and where it ends up for both downloaded and
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
A directory counts only if it contains ``manifest.json`` (so we don't seed a
hint pointing at a bogus path). The returned path is absolute and
symlink-resolved (``Path.resolve()``).
"""
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
# `is not None` (not truthiness): a present-but-empty env var is "set" and
# used exclusively — it resolves to an invalid path and skips seeding.
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
"""Write the Widevine CDM hint file into a persistent profile before launch.
``binary_path`` is the resolved chrome executable; the CDM is looked for next
to it. No-op on non-Linux platforms, when seeding is disabled via
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises
a failure here must not break the browser launch.
"""
if platform.system() != "Linux":
return
if _seeding_disabled():
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
return
if not user_data_dir:
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
# a persistent hint can't be placed there, and "" would pollute the CWD.
return
# Everything below is best-effort and must never break the browser launch,
# so the whole body (resolution + write) is guarded.
try:
cdm_dir = resolve_widevine_cdm_dir(binary_path)
if cdm_dir is None:
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
logger.warning(
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
"skipping Widevine hint seeding"
)
else:
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
return
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
hint_dir.mkdir(parents=True, exist_ok=True)
hint_file = hint_dir / _HINT_FILENAME
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
try:
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
return # already seeded correctly
except Exception:
logger.warning("Existing Widevine hint unreadable; rewriting")
hint_file.write_text(content, encoding="utf-8")
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
except Exception as e:
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
@@ -70,7 +70,7 @@ Only `url` is required. Everything else is optional.
| Field | Type | Default |
|---|---|---|
| `url` | str | required `http://` and `https://` only |
| `url` | str | required |
| `proxy` | str / dict | none — `http://user:pass@host:port` or a Playwright proxy dict |
| `humanize` | bool | `false` — enable human-like mouse / keyboard / scroll |
| `human_preset` | str | `"default"` or `"careful"` |
@@ -79,6 +79,7 @@ Only `url` is required. Everything else is optional.
| `locale` | str | none — BCP-47, e.g. `"en-US"` |
| `viewport` | `{width,height}` | `1920x947` (cloakbrowser default) |
| `user_agent` | str | none |
| `extra_args` | `list[str]` | `[]` — extra Chromium CLI flags |
### Navigation
@@ -101,6 +102,8 @@ Only `url` is required. Everything else is optional.
| `wait_for_selector` | str | none — CSS or XPath |
| `wait_for_selector_state` | str | `"visible"` — also `attached` / `detached` / `hidden` |
| `wait_for_selector_timeout_ms` | int | `30000` |
| `wait_for_function` | str | none — JS expression returning truthy when ready |
| `wait_for_function_timeout_ms` | int | `30000` |
| `wait_ms` | int | none — fixed pause |
### Capture
@@ -115,7 +118,7 @@ Only `url` is required. Everything else is optional.
The handler retries transient navigation failures inline within the same Lambda invocation. Two layers, both built-in:
- **Launch retries** — 3 attempts with 0.3 s + 0.6 s backoff. Recovers Xvfb / Chromium spawn races at cold start. Fast and cheap; not configurable.
- **Strategy retries** — default 1 attempt, configurable via the `retries` event field. Recovers specific post-launch error classes by relaunching with adjusted internal Chromium args / page-load budgets.
- **Strategy retries** — default 1 attempt, configurable via the `retries` event field. Recovers specific post-launch error classes by relaunching with adjusted Chromium args / page-load budgets.
| Field | Type | Default |
|---|---|---|
@@ -173,22 +176,6 @@ For latency-sensitive use cases: provision concurrency, schedule a CloudWatch/Ev
If you see empty/missing dynamic content on cold-start invocations, raise `max_settle_ms` in the event payload (e.g. `25000`) — the default `15000` is tuned for warm runs.
## Security
The handler validates all incoming URLs before navigation:
- **Scheme restriction** — only `http://` and `https://` are accepted. `file://`, `data:`, `javascript:`, and other schemes are rejected.
- **SSRF protection** — hostnames are resolved before navigation and checked against private, loopback, link-local, reserved, and multicast IP ranges. This blocks access to cloud metadata endpoints (e.g. `169.254.169.254`), localhost services, and internal networks.
- **Post-navigation re-validation** — the final URL is re-checked after page load and after post-navigation waits to catch server-side redirects to blocked destinations.
- **No caller-controlled Chromium flags** — the handler does not accept arbitrary CLI flags from the event. Internal retry strategies add flags as needed (e.g. `--ignore-certificate-errors` for cert errors).
- **No arbitrary JS execution**`wait_for_function` is not exposed. Use `wait_for_selector` or `smart_wait` instead.
**Limitations**:
- Post-navigation re-validation prevents response *exfiltration*, but does not prevent the browser from *making* the request. If an internal endpoint has side effects on GET, the request will still reach it before validation rejects the response. Use network-level controls (security groups, VPC) to protect side-effect-bearing internal endpoints.
- DNS rebinding attacks can bypass pre-navigation IP checks in theory, though the post-navigation re-validation provides a second layer of defense.
**Trust boundary**: if this handler is exposed to untrusted callers (Lambda Function URL, API Gateway without auth, public ALB), add an authentication layer (API Gateway authorizer, IAM auth, etc.). The URL validation above is defense-in-depth, not a substitute for access control.
## License
The patched Chromium binary inside the upstream `cloakhq/cloakbrowser` image is governed by the **CloakBrowser Binary License** (published at https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md). Internal organizational use (private ECR, your own scraping pipelines, your own business) is free. Exposing this Lambda as a paid API to third-party customers — i.e. browser-as-a-service — requires an OEM/SaaS license from CloakHQ (`cloakhq@pm.me`). Do not push the resulting image to a public registry; that would be redistribution and is prohibited.
@@ -5,7 +5,7 @@ Always runs **headed** via the Xvfb display started by `lambda-entrypoint.sh`.
Event schema (all fields except `url` are optional):
Launch options (passed to cloakbrowser.launch_context_async):
url str required, the page to scrape (http/https only)
url str required, the page to scrape
proxy str|dict http://user:pass@host:port or Playwright proxy dict
humanize bool False enable human-like mouse/keyboard/scroll
human_preset str "default" | "careful"
@@ -14,6 +14,7 @@ Event schema (all fields except `url` are optional):
locale str BCP-47, e.g. "en-US"
viewport {width,height} defaults to 1920x947 (cloakbrowser DEFAULT_VIEWPORT)
user_agent str custom UA (rare cloakbrowser sets one already)
extra_args list[str] additional Chromium CLI flags
Navigation options (passed to page.goto):
wait_until str "load"|"domcontentloaded"|"networkidle"|"commit"
@@ -34,6 +35,8 @@ Event schema (all fields except `url` are optional):
wait_for_selector str CSS or XPath selector
wait_for_selector_state str "attached"|"detached"|"visible"|"hidden", default "visible"
wait_for_selector_timeout_ms int 30000
wait_for_function str JS expression that returns truthy when ready
wait_for_function_timeout_ms int 30000
wait_ms int fixed pause in ms (page.wait_for_timeout)
Capture options:
@@ -61,14 +64,11 @@ from __future__ import annotations
import asyncio
import base64
import ipaddress
import json
import logging
import socket
import subprocess
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from cloakbrowser import launch_context_async
@@ -76,26 +76,6 @@ logger = logging.getLogger("cloakbrowser.lambda")
logger.setLevel(logging.INFO)
def _validate_url(url: str) -> None:
"""Reject non-HTTP schemes and URLs that resolve to private/internal IPs."""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(
f"Only http:// and https:// URLs are supported, got: {parsed.scheme!r}"
)
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for info in infos:
addr = ipaddress.ip_address(info[4][0])
if not addr.is_global:
raise ValueError("URLs targeting private/internal networks are blocked")
def _diag_snapshot() -> str:
"""Capture Xvfb status, Xvfb log, X11 socket state, and env for error reports."""
import os
@@ -138,7 +118,7 @@ def _build_launch_kwargs(event: dict) -> dict:
# Lambda's restricted process model can't fork from Chromium's zygote
# — without this, child renderer processes fail to spawn.
"--no-zygote",
*event.get("_strategy_args", []),
*event.get("extra_args", []),
],
}
for key in ("proxy", "humanize", "human_preset", "geoip",
@@ -179,7 +159,7 @@ async def _smart_wait(page, dom_stable_ms: int = 1500, max_settle_ms: int = 1500
_EXPLICIT_WAIT_KEYS = (
"wait_for_load_state", "wait_for_selector", "wait_ms",
"wait_for_load_state", "wait_for_selector", "wait_for_function", "wait_ms",
)
@@ -204,6 +184,11 @@ async def _post_nav_waits(page, event: dict) -> None:
state=event.get("wait_for_selector_state", "visible"),
timeout=event.get("wait_for_selector_timeout_ms", 30000),
)
if "wait_for_function" in event:
await page.wait_for_function(
event["wait_for_function"],
timeout=event.get("wait_for_function_timeout_ms", 30000),
)
if "wait_ms" in event:
await page.wait_for_timeout(event["wait_ms"])
@@ -250,7 +235,7 @@ def _classify_error(err: Exception) -> dict | None:
msg = str(err)
if "ERR_CERT" in msg:
return {
"_strategy_args": ["--ignore-certificate-errors"],
"extra_args": ["--ignore-certificate-errors"],
"goto_timeout_ms": 60000,
}
if ("Timeout" in msg and "exceeded" in msg) or "ERR_CONNECTION_TIMED_OUT" in msg:
@@ -278,10 +263,8 @@ async def _attempt_scrape(url: str, event: dict) -> dict:
wait_until=event.get("wait_until", "domcontentloaded"),
timeout=event.get("goto_timeout_ms", 30000),
)
_validate_url(page.url)
await _post_nav_waits(page, event)
_validate_url(page.url)
result: dict = {
"title": await page.title(),
@@ -323,8 +306,6 @@ async def _run(event: dict) -> dict:
set to 0 to disable retry entirely).
"""
url = event["url"]
_validate_url(url)
event = {k: v for k, v in event.items() if k not in ("extra_args", "_strategy_args")}
retries_left = max(0, int(event.get("retries", 1)))
history: list[dict] = []
current_event = event
@@ -345,8 +326,8 @@ async def _run(event: dict) -> dict:
})
logger.warning("attempt %d failed (%s); retrying with strategy=%s",
len(history), str(e)[:120], strategy)
merged_args = list(current_event.get("_strategy_args", [])) + list(strategy.get("_strategy_args", []))
current_event = {**current_event, **strategy, "_strategy_args": merged_args}
merged_args = list(current_event.get("extra_args", [])) + list(strategy.get("extra_args", []))
current_event = {**current_event, **strategy, "extra_args": merged_args}
retries_left -= 1
# No backoff: strategy overrides change goto budget directly;
# the prior failure was either fast (cert reject) or already
+11 -12
View File
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
Default Playwright typically scores 0.1-0.3.
"""
import re
import time
from cloakbrowser import launch
@@ -13,20 +13,19 @@ print("Launching stealth browser...", flush=True)
browser = launch(headless=True)
page = browser.new_page()
# Google's official reCAPTCHA v3 demo — scores automatically on page load.
# Google's official reCAPTCHA v3 demo
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
page.wait_for_load_state("networkidle")
# The score renders only after an async token + backend-verify round-trip,
# which can finish *after* "networkidle". Wait for the actual result text
# instead of a proxy signal, or the screenshot races the scoring.
page.wait_for_function(
"() => document.body.innerText.includes('Received response from our backend')",
timeout=20000,
)
# Click to trigger reCAPTCHA scoring
button = page.query_selector("button")
if button:
button.click()
time.sleep(3)
# Extract score from the rendered response
match = re.search(r'"score":\s*([0-9.]+)', page.inner_text("body"))
print(f"reCAPTCHA v3 score: {match.group(1) if match else 'not found'}")
# Extract score from page
content = page.content()
print("Page loaded. Check the score in the response.")
print(f"URL: {page.url}")
# Take screenshot as proof
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-237
View File
@@ -1,237 +0,0 @@
{
description = "CloakBrowser development shell with Nix-packaged Chromium binaries";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = lib.genAttrs supportedSystems;
packageInfo = {
x86_64-linux = {
platformTag = "linux-x64";
version = "146.0.7680.177.5";
hash = "sha256-ShK83pX6G7G+7ytBq15cJ8Nr544749DayMZNcFIWZw4=";
};
aarch64-linux = {
platformTag = "linux-arm64";
version = "146.0.7680.177.3";
hash = "sha256-i3HOU7T9ExMnMxox+6ODXXGILRm/qr3njdD1OQvRb0U=";
};
};
cloakbrowserBinaryLicense = {
shortName = "cloakbrowser-binary";
fullName = "CloakBrowser Binary License";
url = "https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md";
free = false;
redistributable = false;
};
mkPkgs = system: import nixpkgs {
inherit system;
config.allowUnfree = true;
};
runtimeLibraries = pkgs: with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libGL
libpulseaudio
libxkbcommon
mesa
nspr
nss
pango
systemd
wayland
libx11
libxcb
libxcomposite
libxcursor
libxdamage
libxext
libxfixes
libxi
libxrandr
libxrender
libxscrnsaver
libxshmfence
libxtst
];
fontPackages = pkgs: with pkgs; [
freefont_ttf
ipafont
liberation_ttf
noto-fonts
noto-fonts-cjk-sans
noto-fonts-color-emoji
tlwg
unifont
wqy_zenhei
];
desktopPackages = pkgs: with pkgs; [
adwaita-icon-theme
gsettings-desktop-schemas
xdg-utils
];
mkCloakBrowserChromium = pkgs: system:
let
info = packageInfo.${system} or (throw "CloakBrowser flake package currently supports only x86_64-linux and aarch64-linux.");
archiveName = "cloakbrowser-${info.platformTag}.tar.gz";
chromiumVersion = info.version;
libs = runtimeLibraries pkgs;
desktopDeps = desktopPackages pkgs;
fonts = fontPackages pkgs;
fontsConf = pkgs.makeFontsConf {
fontDirectories = fonts;
};
in
pkgs.stdenvNoCC.mkDerivation {
pname = "cloakbrowser-chromium";
version = chromiumVersion;
src = pkgs.fetchurl {
url = "https://cloakbrowser.dev/chromium-v${chromiumVersion}/${archiveName}";
inherit (info) hash;
};
dontUnpack = true;
nativeBuildInputs = with pkgs; [
autoPatchelfHook
makeWrapper
];
buildInputs = libs ++ desktopDeps;
runtimeDependencies = libs;
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/cloakbrowser" "$out/bin"
tar -xzf "$src" -C "$out/lib/cloakbrowser"
chmod +x "$out/lib/cloakbrowser/chrome"
chmod +x "$out/lib/cloakbrowser/chromedriver"
runHook postInstall
'';
postFixup = ''
makeWrapper "$out/lib/cloakbrowser/chrome" "$out/bin/cloakbrowser-chrome" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libs}" \
--prefix XDG_DATA_DIRS : "$GSETTINGS_SCHEMAS_PATH:$XDG_ICON_DIRS" \
--suffix PATH : "${lib.makeBinPath [ pkgs.xdg-utils ]}" \
--set FONTCONFIG_FILE "${fontsConf}" \
--set CHROME_WRAPPER "cloakbrowser-chrome"
makeWrapper "$out/lib/cloakbrowser/chromedriver" "$out/bin/cloakbrowser-chromedriver" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libs}"
'';
meta = {
description = "Official CloakBrowser patched Chromium binary";
homepage = "https://github.com/CloakHQ/CloakBrowser";
license = cloakbrowserBinaryLicense;
mainProgram = "cloakbrowser-chrome";
platforms = supportedSystems;
sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
};
};
in
{
packages = forAllSystems (system:
let
pkgs = mkPkgs system;
cloakbrowserChromium = mkCloakBrowserChromium pkgs system;
in
{
inherit cloakbrowserChromium;
default = cloakbrowserChromium;
});
apps = forAllSystems (system:
let
cloakbrowserChromium = self.packages.${system}.cloakbrowserChromium;
in
{
default = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
meta.description = "Run CloakBrowser Chromium";
};
cloakbrowser-chrome = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
meta.description = "Run CloakBrowser Chromium";
};
cloakbrowser-chromedriver = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chromedriver";
meta.description = "Run the CloakBrowser Chromedriver binary";
};
});
devShells = forAllSystems (system:
let
pkgs = mkPkgs system;
cloakbrowserChromium = self.packages.${system}.cloakbrowserChromium;
python = pkgs.python312.withPackages (ps: with ps; [
aiohttp
geoip2
hatchling
httpx
playwright
pytest
pytest-asyncio
socksio
websockets
]);
in
{
default = pkgs.mkShell {
packages = [
cloakbrowserChromium
python
pkgs.cacert
pkgs.curl
pkgs.git
pkgs.jq
pkgs.nodejs_20
pkgs.which
pkgs.xdotool
pkgs.xvfb-run
]
++ runtimeLibraries pkgs
++ fontPackages pkgs;
CLOAKBROWSER_BINARY_PATH = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
};
});
};
}
+3 -35
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.**
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, 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
@@ -39,24 +39,11 @@ import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.goto('https://protected-site.com');
console.log(await page.title());
await browser.close();
```
**For sites with anti-bot protection**, add a residential proxy and these flags:
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true, // match timezone + locale to proxy IP
headless: false, // some sites detect headless even with C++ patches
humanize: true, // human-like mouse, keyboard, scroll
});
```
See the [main README](https://github.com/CloakHQ/CloakBrowser#troubleshooting) for site-specific troubleshooting (FingerprintJS, Kasada, reCAPTCHA).
### Puppeteer
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
@@ -66,7 +53,7 @@ import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.goto('https://protected-site.com');
console.log(await page.title());
await browser.close();
```
@@ -202,18 +189,6 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
### Widevine / DRM
The binary supports Widevine, but the CDM is proprietary and can't be redistributed. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launchPersistentContext()` enables Widevine on the **first** launch — the wrapper auto-seeds the CDM hint file into the profile. This plays DRM-protected video (Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support. **Linux only.** A sideloaded CDM is the opt-in (no flag); set `CLOAKBROWSER_WIDEVINE_CDM` for a custom path or `CLOAKBROWSER_WIDEVINE=0` to disable. See the [main README](https://github.com/CloakHQ/CloakBrowser#widevine--drm) for details.
## Migrate From Playwright
@@ -255,13 +230,6 @@ const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
// Load Chrome extensions
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
extensionPaths: ['./my-extension'],
});
```
This also gives you cookie and localStorage persistence across sessions.
+603 -211
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.32",
"version": "0.3.28",
"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",
@@ -81,12 +81,12 @@
"tar": "^7.0.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "1.60",
"puppeteer-core": "^25.0.4",
"socks-proxy-agent": "^10.0.0",
"typescript": "^6.0.3",
"playwright-core": "^1.53.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"scripts": {
+1 -12
View File
@@ -1,7 +1,7 @@
/**
* Shared argument builder for Playwright and Puppeteer wrappers.
*/
import path from "path";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
@@ -55,16 +55,5 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(k, flag);
}
}
if (options.extensionPaths?.length) {
const absPaths = options.extensionPaths.map(p => path.resolve(p));
const joined = absPaths.join(",");
seen.set("--load-extension", `--load-extension=${joined}`);
seen.set(
"--disable-extensions-except",
`--disable-extensions-except=${joined}`
);
}
return [...seen.values()];
}
+3 -3
View File
@@ -27,14 +27,14 @@ 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 = "146.0.7680.177.5";
export const CHROMIUM_VERSION = "146.0.7680.177.3";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "146.0.7680.177.5",
"linux-x64": "146.0.7680.177.3",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "146.0.7680.177.5",
"windows-x64": "146.0.7680.177.4",
};
// ---------------------------------------------------------------------------
+2 -7
View File
@@ -425,16 +425,11 @@ async function extractZip(archivePath: string, destDir: string): Promise<void> {
if (process.platform === "win32") {
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
// with recently-closed Node.js file handles. Use ZipFile API directly.
// Pass paths via env vars (not interpolated into the script) so a quote or
// other special char in the path can't break out and be parsed as code.
execFileSync("powershell", [
"-NoProfile", "-Command",
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
], {
timeout: 120_000,
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
});
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
], { timeout: 120_000 });
} else {
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
}
+11 -16
View File
@@ -197,11 +197,8 @@ export async function ensureStable(
// Pointer-events check (post-scroll, at actual click coordinates)
// ---------------------------------------------------------------------------
const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -209,11 +206,8 @@ const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
const POINTER_EVENTS_HANDLE_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -231,18 +225,18 @@ export async function checkPointerEvents(
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any = null;
try {
const loc = pageOrFrame.locator(selector).first();
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
} catch {
result = null;
}
if (!result || result.hit) return;
if (result && result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
@@ -323,16 +317,17 @@ export async function checkPointerEventsHandle(
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any;
try {
const box = await el.boundingBox();
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
} catch {
result = null;
}
if (!result || result.hit) return;
if (result && result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
+16 -34
View File
@@ -196,12 +196,10 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
};
@@ -218,12 +216,10 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
@@ -239,9 +235,7 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElHover(options);
};
@@ -254,12 +248,10 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = (options as any)?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
@@ -275,12 +267,10 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
@@ -308,9 +298,7 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
@@ -328,16 +316,14 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const checked = await el.isChecked();
if (checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -351,16 +337,14 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const checked = await el.isChecked();
if (!checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -375,16 +359,14 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
}
+12 -29
View File
@@ -691,12 +691,7 @@ function patchSingleFrame(
const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
const moveToFrameSelector = async (
selector: string,
options: HumanActionOptions | undefined,
inputBias: boolean,
remainingMs: () => number,
) => {
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
@@ -704,9 +699,9 @@ function patchSingleFrame(
const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
}
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector);
@@ -718,32 +713,23 @@ function patchSingleFrame(
};
const frameClick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameClick(selector, options);
await humanClick(raw, moved.isInput, moved.callCfg);
};
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
const moved = await moveToFrameSelector(selector, options, false);
if (!moved) return origFrameHover(selector, options);
};
(frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameDblclick(selector, options);
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
@@ -834,11 +820,8 @@ function patchSingleFrame(
timeout?: number;
trial?: boolean;
}) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(1, deadline - Date.now());
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
@@ -854,7 +837,7 @@ function patchSingleFrame(
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
return origFrameDragAndDrop(source, target, options);
}
};
}
+1 -1
View File
@@ -16,7 +16,7 @@
*/
// Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
+45 -74
View File
@@ -3,14 +3,13 @@
* Mirrors Python cloakbrowser/browser.py.
*/
import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core";
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.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 {
@@ -45,72 +44,6 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
return rest;
}
/**
* Build Playwright BrowserContext options for CloakBrowser without launching a browser
* or creating a context.
*
* Useful when integrating CloakBrowser with an existing Playwright Browser while
* keeping the wrapper's stealth-safe defaults for `newContext()`.
*/
export function buildContextOptions(
options: LaunchContextOptions = {}
): BrowserContextOptions {
return {
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
} as BrowserContextOptions;
}
/**
* Build Playwright launch options for CloakBrowser without starting Chromium.
*
* Useful when integrating CloakBrowser with a custom Playwright build or another
* wrapper that needs to call `chromium.launch()` itself.
*/
export async function buildLaunchOptions(
options: LaunchOptions = {}
): Promise<PlaywrightLaunchOptions> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
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 ?? []), ...proxyArgs] });
return {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...options.launchOptions,
} as PlaywrightLaunchOptions;
}
/**
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright browser.
*/
export async function humanizeBrowser(
browser: Browser,
options: LaunchOptions = {}
): Promise<void> {
if (!options.humanize) return;
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
/**
* Launch stealth Chromium browser via Playwright.
*
@@ -126,8 +59,36 @@ export async function humanizeBrowser(
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core");
const browser = await chromium.launch(await buildLaunchOptions(options));
await humanizeBrowser(browser, options);
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
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 ?? []), ...proxyArgs] });
const browser = await chromium.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
return browser;
}
@@ -165,7 +126,14 @@ export async function launchContext(
let context: BrowserContext;
try {
context = await browser.newContext(buildContextOptions(options));
context = await browser.newContext({
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
} catch (err) {
await browser.close();
throw err;
@@ -228,8 +196,6 @@ export async function launchPersistentContext(
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
seedWidevineHint(options.userDataDir, binaryPath);
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
@@ -238,7 +204,12 @@ export async function launchPersistentContext(
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...buildContextOptions(options),
// contextOptions before explicit wrapper fields so explicit wins.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions,
});
+3 -112
View File
@@ -2,8 +2,6 @@
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
export interface ParsedProxy {
server: string;
username?: string;
@@ -157,106 +155,11 @@ export function normalizeSocksStringUrl(urlStr: string): string {
}
}
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
export function supportsHttpProxyInlineAuth(): boolean {
try {
const tag = getPlatformTag();
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
const current = parseVersion(getChromiumVersion());
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
}
return true; // equal = supported
} catch {
return false;
}
}
function hasCredentials(proxy: string | ProxyDict): boolean {
if (typeof proxy === "string") return proxy.includes("@");
return !!proxy.username;
}
/**
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
*/
export function reconstructHttpUrl(proxy: ProxyDict): string {
if (!proxy.username) return proxy.server;
const url = new URL(ensureProxyScheme(proxy.server));
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
return url.href.replace(/\/$/, "");
}
/**
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
* Same pattern as normalizeSocksStringUrl.
*/
export function normalizeHttpStringUrl(urlStr: string): string {
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
if (!schemeMatch) return normalized;
const [, scheme, rest] = schemeMatch;
const hostStart = rest.search(/[/?#]/);
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
const atIdx = authority.lastIndexOf("@");
if (atIdx === -1) return normalized;
const userinfo = authority.slice(0, atIdx);
const hostPart = authority.slice(atIdx + 1);
const bracketEnd = hostPart.lastIndexOf("]");
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
if (portColonIdx !== -1) {
const portStr = hostPart.slice(portColonIdx + 1);
if (portStr && !/^\d+$/.test(portStr)) {
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
return normalized;
}
}
const hostAndRest = hostPart + suffix;
const colonIdx = userinfo.indexOf(":");
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
const hasPassword = colonIdx !== -1;
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
try {
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
let userinfoPart: string;
if (encPass !== null) {
userinfoPart = `${encUser}:${encPass}@`;
} else if (encUser) {
userinfoPart = `${encUser}@`;
} else {
userinfoPart = "";
}
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return result;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
return normalized;
}
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
@@ -274,19 +177,7 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
return { proxyArgs: args };
}
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
const httpUrl = reconstructHttpUrl(proxy);
const args = [`--proxy-server=${httpUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
// HTTP/HTTPS: use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
+58 -126
View File
@@ -9,76 +9,74 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
/**
* Launch stealth Chromium browser via Puppeteer.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* * // With humanize — human-like mouse, keyboard, scroll
* const browser = await launch({ humanize: true });
* const page = await browser.newPage();
* await page.goto('[https://example.com](https://example.com)');
* await page.click('#login'); // Bézier curve mouse movement
* await page.type('#email', 'user@example.com'); // Per-character timing
* ```
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
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}`];
}
return { binaryPath, args: buildArgs({ ...options, ...resolved, args: resolvedArgs }) };
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
/**
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
* HTTP on supported platforms: inline credentials via --proxy-server.
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
*/
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
if (!options.proxy) return undefined;
if (isSocksProxy(options.proxy)) {
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
return undefined;
}
// On supported platforms: pass full URL with inline creds to --proxy-server
if (supportsHttpProxyInlineAuth()) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
// Puppeteer handles proxy via CLI args, not a separate option.
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
// HTTP: Chrome does NOT support inline credentials — strip them and
// use page.authenticate() for Proxy-Authorization headers instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
if (isSocksProxy(options.proxy)) {
// SOCKS5: pass full URL with credentials to Chrome directly
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
} else if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
}
const url = options.proxy.username
? reconstructHttpUrl(options.proxy)
: options.proxy.server;
args.push(`--proxy-server=${url}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
return undefined;
}
// Unsupported platform: strip credentials, fall back to page.authenticate()
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
return username ? { username, password: password ?? "" } : undefined;
}
const browser = await puppeteer.default.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...options.launchOptions,
});
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
return username ? { username, password: password ?? "" } : undefined;
}
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
async function applyPostLaunch(
browser: Browser,
options: LaunchOptions,
proxyAuth?: { username: string; password: string },
): Promise<void> {
// Monkey-patch newPage() to auto-authenticate proxy credentials
if (proxyAuth) {
const origNewPage = browser.newPage.bind(browser);
const auth = proxyAuth;
@@ -89,6 +87,9 @@ async function applyPostLaunch(
};
}
// Human-like behavioral patching — FULL coverage, same as Playwright.
// This enables Bézier mouse movements, organic typing rhythms, and
// natural scrolling to bypass advanced anti-bot detection.
if (options.humanize) {
const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js');
@@ -98,75 +99,6 @@ async function applyPostLaunch(
);
patchBrowser(browser, cfg);
}
}
/**
* Launch stealth Chromium browser via Puppeteer.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* // With humanize — human-like mouse, keyboard, scroll
* const browser = await launch({ humanize: true });
* const page = await browser.newPage();
* await page.goto('https://example.com');
* await page.click('#login'); // Bézier curve mouse movement
* await page.type('#email', 'user@example.com'); // Per-character timing
* ```
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const { binaryPath, args } = await resolveArgs(options);
const proxyAuth = resolveProxy(options, args);
const browser = await puppeteer.default.launch({
...options.launchOptions,
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
});
await applyPostLaunch(browser, options, proxyAuth);
return browser;
}
/**
* Launch stealth Chromium with a persistent user profile via Puppeteer.
* Passes `userDataDir` to Puppeteer's launch options so cookies,
* localStorage, and session data persist across launches.
*
* @example
* ```ts
* import { launchPersistentContext } from 'cloakbrowser/puppeteer';
* const browser = await launchPersistentContext({
* userDataDir: './chrome-profile',
* headless: false,
* proxy: 'http://user:pass@proxy:8080',
* });
* const page = await browser.newPage();
* await page.goto('https://example.com');
* await browser.close();
* ```
*/
export async function launchPersistentContext(
options: LaunchOptions & { userDataDir: string }
): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const { binaryPath, args } = await resolveArgs(options);
const proxyAuth = resolveProxy(options, args);
seedWidevineHint(options.userDataDir, binaryPath);
const browser = await puppeteer.default.launch({
...options.launchOptions,
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
userDataDir: options.userDataDir,
});
await applyPostLaunch(browser, options, proxyAuth);
return browser;
}
-2
View File
@@ -17,8 +17,6 @@ export interface LaunchOptions {
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */
args?: string[];
/** Chrome extension paths to load. */
extensionPaths?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
stealthArgs?: boolean;
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
-111
View File
@@ -1,111 +0,0 @@
/**
* Widevine CDM hint-file seeding for persistent contexts.
* Mirrors Python cloakbrowser/widevine.py.
*
* CloakBrowser's binary supports Widevine but ships no CDM (proprietary, can't
* redistribute). Users sideload it by copying a `WidevineCdm/` directory from a
* real Chrome install next to the binary (see issue #96). Chromium reads a
* "hint file" from the user-data-dir at early startup to register the CDM, but
* on a fresh profile it doesn't exist yet, and Playwright disables the component
* updater that would write it. This seeds the hint file before launch so a
* sideloaded CDM works on the first run. It never bundles, downloads, or copies
* the CDM only writes the hint when a user-provided CDM is already present.
*
* Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
*/
import fs from "node:fs";
import path from "node:path";
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
function isFile(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
/** Absolute, symlink-resolved path (mirrors Python's Path.resolve()). */
function realPath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}
function seedingDisabled(): boolean {
const val = (process.env.CLOAKBROWSER_WIDEVINE ?? "").trim().toLowerCase();
return val === "0" || val === "false" || val === "off" || val === "no";
}
/**
* Locate a sideloaded Widevine CDM directory, or null if absent.
*
* Resolution:
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` where a user naturally
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
*
* A directory counts only if it contains `manifest.json`. The returned path is
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
* @internal Exported for testing.
*/
export function resolveWidevineCdmDir(binaryPath: string): string | null {
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
// used exclusively — it resolves to an invalid path and skips seeding.
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
}
/**
* Write the Widevine CDM hint file into a persistent profile before launch.
* `binaryPath` is the resolved chrome executable; the CDM is looked for next to
* it. No-op on non-Linux, when disabled via CLOAKBROWSER_WIDEVINE, or when no
* sideloaded CDM is present. Never throws a failure must not break launch.
*/
export function seedWidevineHint(userDataDir: string, binaryPath: string): void {
if (process.platform !== "linux") return;
if (seedingDisabled()) return;
// Empty userDataDir = Playwright's ephemeral profile (its own temp dir);
// a persistent hint can't be placed there, and "" would pollute the CWD.
if (!userDataDir) return;
// Everything below is best-effort and must never break the browser launch,
// so the whole body (resolution + write) is guarded.
try {
const cdmDir = resolveWidevineCdmDir(binaryPath);
if (cdmDir === null) {
if (process.env.CLOAKBROWSER_WIDEVINE_CDM !== undefined) {
console.warn(
"[cloakbrowser] CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
"skipping Widevine hint seeding",
);
}
return;
}
const hintDir = path.join(userDataDir, "WidevineCdm");
fs.mkdirSync(hintDir, { recursive: true });
const hintFile = path.join(hintDir, HINT_FILENAME);
// cdmDir is already absolute/resolved.
const content = JSON.stringify({ Path: cdmDir });
try {
if (isFile(hintFile) && fs.readFileSync(hintFile, "utf-8") === content) {
return; // already seeded correctly
}
} catch {
console.warn("[cloakbrowser] Existing Widevine hint unreadable; rewriting");
}
fs.writeFileSync(hintFile, content);
} catch (e) {
// Best-effort: never break the launch, but surface the failure.
console.warn("[cloakbrowser] Failed to seed Widevine CDM hint file:", e);
}
}
-17
View File
@@ -1,17 +0,0 @@
import { test, expect } from "vitest";
import path from "path";
import { _buildArgsForTest } from "../src/playwright.js";
test("extension paths inject chrome flags", () => {
const args = _buildArgsForTest({
extensionPaths: ["./ext"],
});
const abs = path.resolve("./ext");
expect(args).toContain(`--load-extension=${abs}`);
expect(args).toContain(
`--disable-extensions-except=${abs}`
);
});
-120
View File
@@ -1479,123 +1479,3 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
spy.mockRestore();
});
});
// =========================================================================
// Issue #307: frame.click timeout should not multiply
// =========================================================================
describe("frame.click timeout budget (#307)", () => {
it("total wait time should not exceed the specified timeout", async () => {
const { patchPage } = await import("../src/human/index.js");
const TIMEOUT_MS = 500;
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
// Build a frame where the element does NOT exist:
// scrollIntoViewIfNeeded and boundingBox each wait until their
// individual timeout before failing, and origFrameClick does the same.
const frameLoc: any = {
boundingBox: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
return null;
}),
scrollIntoViewIfNeeded: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
}),
evaluate: vi.fn(async () => ({ hit: true })),
isChecked: vi.fn(async () => false),
};
frameLoc.first = vi.fn(() => frameLoc);
const origClickFn = vi.fn(async (_sel: string, opts?: any) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
});
const childFrame: any = {
click: origClickFn,
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => frameLoc),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default", {
mouse_min_steps: 1,
mouse_max_steps: 1,
idle_between_actions: false,
});
const cursor = { x: 0, y: 0, initialized: true };
patchPage(page as any, cfg, cursor as any);
const start = Date.now();
try {
await (childFrame as any).click("#does-not-exist", { timeout: TIMEOUT_MS });
} catch {
// expected — element doesn't exist
}
const elapsed = Date.now() - start;
// With the bug, elapsed ≈ 3 * TIMEOUT_MS (scrollIntoView + boundingBox + origClick).
// Fixed: elapsed should be ≈ 1 * TIMEOUT_MS (shared deadline).
// Allow 1.8x as upper bound to account for test overhead but catch the 3x bug.
expect(elapsed).toBeLessThan(TIMEOUT_MS * 1.8);
});
});
describe("pointer-events check fail-open", () => {
// When the check itself cannot run (evaluate / boundingBox throws -> result
// null), proceed with the click instead of blocking it until the timeout.
it("checkPointerEventsHandle returns promptly when evaluate throws", async () => {
const { checkPointerEventsHandle } = await import("../src/human/actionability.js");
const el = {
boundingBox: vi.fn().mockRejectedValue(new Error("stale handle")),
evaluate: vi.fn().mockRejectedValue(new Error("execution context destroyed")),
};
const start = Date.now();
await checkPointerEventsHandle(el as any, 100, 100, 2000); // must not throw
expect(Date.now() - start).toBeLessThan(500);
});
it("checkPointerEvents returns promptly when evaluate throws", async () => {
const { checkPointerEvents } = await import("../src/human/actionability.js");
const loc = {
first: () => loc,
boundingBox: vi.fn().mockRejectedValue(new Error("no element")),
evaluate: vi.fn().mockRejectedValue(new Error("no element")),
};
const page = { locator: vi.fn().mockReturnValue(loc) };
const start = Date.now();
await checkPointerEvents(page as any, "#x", 100, 100, null, 2000); // must not throw
expect(Date.now() - start).toBeLessThan(500);
});
it("checkPointerEventsHandle still throws when genuinely covered", async () => {
const { checkPointerEventsHandle, ElementNotReceivingEventsError } =
await import("../src/human/actionability.js");
const el = {
boundingBox: vi.fn().mockResolvedValue({ x: 0, y: 0, width: 10, height: 10 }),
evaluate: vi.fn().mockResolvedValue({ hit: false, covering: "DIV" }),
};
await expect(checkPointerEventsHandle(el as any, 5, 5, 200)).rejects.toBeInstanceOf(
ElementNotReceivingEventsError,
);
});
});
+9 -123
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { binaryInfo } from "../src/download.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
import * as config from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
@@ -22,113 +21,6 @@ describe("binaryInfo", () => {
});
});
describe("composable Playwright launch helpers", () => {
const origBinaryPath = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origBinaryPath) {
process.env.CLOAKBROWSER_BINARY_PATH = origBinaryPath;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("exports composable helpers from the package entrypoint", async () => {
const entry = await import("../src/index.js");
expect(entry.buildLaunchOptions).toBeTypeOf("function");
expect(entry.buildContextOptions).toBeTypeOf("function");
expect(entry.humanizeBrowser).toBeTypeOf("function");
});
it("buildContextOptions returns Playwright context options without launching a browser", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { buildContextOptions } = await import("../src/index.js");
const options = buildContextOptions({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "Context/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
expect(options).toMatchObject({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
storageState: "state.json",
});
expect(options.locale).toBeUndefined();
expect(options.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
it("buildContextOptions applies DEFAULT_VIEWPORT by default and allows null viewport", async () => {
const { buildContextOptions } = await import("../src/index.js");
expect(buildContextOptions().viewport).toEqual(DEFAULT_VIEWPORT);
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { buildLaunchOptions } = await import("../src/index.js");
const options = await buildLaunchOptions({
headless: false,
proxy: "http://user:pass@proxy.example:8080",
args: ["--custom-flag"],
launchOptions: { timeout: 1234 },
});
expect(options.executablePath).toBe("/fake/chrome");
expect(options.headless).toBe(false);
expect(options.args).toContain("--custom-flag");
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
expect(options.proxy).toEqual({
server: "http://proxy.example:8080",
username: "user",
password: "pass",
});
expect(options.timeout).toBe(1234);
} finally {
vi.restoreAllMocks();
}
});
it("humanizeBrowser patches an existing browser only when requested", async () => {
const { humanizeBrowser } = await import("../src/index.js");
const browser = {
contexts: () => [],
newContext: vi.fn(async () => ({})),
newPage: vi.fn(async () => ({ context: () => ({}) })),
};
const originalNewContext = browser.newContext;
await humanizeBrowser(browser as any, { humanize: false });
expect(browser.newContext).toBe(originalNewContext);
await humanizeBrowser(browser as any, { humanize: true });
expect(browser.newContext).not.toBe(originalNewContext);
});
});
// Integration tests require the binary — run with:
// CLOAKBROWSER_BINARY_PATH=/path/to/chrome npm test
describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
@@ -351,22 +243,16 @@ describe("launchPersistentContext (unit)", () => {
});
it("forwards proxy string", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
} finally {
vi.restoreAllMocks();
}
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
});
it("forwards userAgent and colorScheme", async () => {
+5 -103
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
import * as config from "../src/config.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
@@ -154,15 +153,10 @@ describe("resolveProxyConfig", () => {
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string on unsupported platform", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
it("returns playwright dict for http string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http dict", () => {
@@ -327,96 +321,4 @@ describe("resolveProxyConfig", () => {
debugSpy.mockRestore();
}
});
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
});
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
} finally {
vi.restoreAllMocks();
}
});
it("encodes special chars in http proxy password on supported platform v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on linux-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
});
+9 -138
View File
@@ -84,39 +84,16 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
it("monkey-patches newPage for proxy auth", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
});
it("injects timezone and locale as binary flags", async () => {
@@ -149,14 +126,6 @@ describe("puppeteer launch", () => {
expect(page.authenticate).not.toHaveBeenCalled();
});
it("forwards launchOptions to puppeteer launch", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ launchOptions: { slowMo: 50 } });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.slowMo).toBe(50);
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
@@ -170,101 +139,3 @@ describe("puppeteer launch", () => {
expect(page.authenticate).not.toHaveBeenCalled();
});
});
describe("puppeteer launchPersistentContext", () => {
let puppeteerMock: any;
let mockBrowser: any;
beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
authenticate: vi.fn(),
}),
close: vi.fn(),
};
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("passes userDataDir to puppeteer launch", async () => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile" });
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
expect.objectContaining({
userDataDir: "./my-profile",
executablePath: "/fake/chrome",
})
);
});
it("includes stealth args", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "http://user:pass@proxy:8080",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "socks5://user:pass@proxy:1080",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("forwards launchOptions to puppeteer launch", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile", launchOptions: { slowMo: 50 } });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.slowMo).toBe(50);
expect(callArgs.userDataDir).toBe("./my-profile");
});
it("injects timezone and locale as binary flags", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({
userDataDir: "./my-profile",
timezone: "Asia/Tokyo",
locale: "ja-JP",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(callArgs.args).toContain("--lang=ja-JP");
});
});
+1 -1
View File
@@ -418,7 +418,7 @@ describe("humanType mixed text with CDP", () => {
});
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0, typing_delay: 0 });
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
-57
View File
@@ -1,57 +0,0 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// Assert the persistent-context launchers actually invoke seedWidevineHint,
// so accidental removal of the wiring fails CI (parity with the Python
// test_persistent_context_seeds_widevine tests).
vi.mock("../src/widevine.js", () => ({
seedWidevineHint: vi.fn(),
resolveWidevineCdmDir: vi.fn(),
}));
vi.mock("../src/download.js", () => ({
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
}));
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)),
}));
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
describe("persistent context seeds Widevine (integration)", () => {
beforeEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
});
afterEach(() => {
vi.clearAllMocks();
});
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pw = await import("playwright-core");
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
close: vi.fn(),
pages: () => [],
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pptr = await import("puppeteer-core");
vi.mocked(pptr.default.launch).mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
close: vi.fn(),
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
});
-160
View File
@@ -1,160 +0,0 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
const tempDirs: string[] = [];
const origPlatform = process.platform;
function tmpDir(prefix: string): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(d);
return d;
}
function makeCdm(dir: string): string {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
return dir;
}
/** A fake chrome binary path inside its own dir. */
function fakeBinary(): string {
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
fs.mkdirSync(bdir, { recursive: true });
return path.join(bdir, "chrome");
}
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
beforeEach(() => {
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveWidevineCdmDir", () => {
it("returns env-var dir when it has manifest.json", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
});
it("returns null when dir lacks manifest.json", () => {
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
fs.mkdirSync(bogus, { recursive: true });
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
});
it("falls back to <binary dir>/WidevineCdm", () => {
const binary = fakeBinary();
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
});
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
it("empty env var is exclusive — no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
});
describe("seedWidevineHint", () => {
it("writes the hint file with the absolute CDM path", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
const hint = path.join(profile, HINT);
expect(fs.existsSync(hint)).toBe(true);
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
it("no-ops when no CDM present", () => {
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
process.env.CLOAKBROWSER_WIDEVINE = "0";
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("is idempotent", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
fs.realpathSync(cdm),
);
});
it("no-ops on non-Linux", () => {
setPlatform("win32");
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("skips empty userDataDir (no CWD pollution)", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
seedWidevineHint("", fakeBinary());
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
});
it("never throws on write failure", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
});
it("rewrites a mismatched existing hint", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
const hint = path.join(profile, HINT);
fs.mkdirSync(path.dirname(hint), { recursive: true });
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
});
+3 -341
View File
@@ -1,12 +1,9 @@
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
import asyncio
import importlib.machinery
import importlib.util
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -25,8 +22,6 @@ parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
_external_host = _mod._external_host
_ws_scheme = _mod._ws_scheme
SAFE_SEED_RE = _mod.SAFE_SEED_RE
RESERVED_SEEDS = _mod.RESERVED_SEEDS
@@ -93,7 +88,6 @@ class TestParseCliArgs:
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert config["idle_timeout"] == 0.0
assert passthrough == []
def test_custom_port(self):
@@ -132,31 +126,6 @@ class TestParseCliArgs:
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
def test_idle_timeout_not_in_passthrough(self):
config, passthrough = parse_cli_args(["--idle-timeout=30", "--no-sandbox"])
assert config["idle_timeout"] == 30.0
assert "--idle-timeout=30" not in passthrough
assert "--no-sandbox" in passthrough
@pytest.mark.parametrize("value", ["0", "off", "false", "none", "disabled"])
def test_idle_timeout_disabled_values(self, value):
config, _ = parse_cli_args([f"--idle-timeout={value}"])
assert config["idle_timeout"] == 0.0
def test_idle_timeout_env_default(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args([])
assert config["idle_timeout"] == 2.5
def test_idle_timeout_cli_overrides_env(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args(["--idle-timeout=9"])
assert config["idle_timeout"] == 9.0
def test_idle_timeout_rejects_negative_values(self):
with pytest.raises(ValueError):
parse_cli_args(["--idle-timeout=-1"])
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@@ -167,234 +136,13 @@ class TestParseCliArgs:
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# External host detection
# ---------------------------------------------------------------------------
class TestExternalHost:
"""Test public host selection for rewritten CDP WebSocket URLs."""
class _Request:
def __init__(self, headers, port=9222, scheme="http", query_string=""):
self.headers = headers
self.app = {"port": port}
self.scheme = scheme
self.query_string = query_string
def test_forwarded_host_overrides_internal_host(self):
request = self._Request({
"Host": "localhost:8080",
"X-Forwarded-Host": "cdp.example.com:443",
})
assert _external_host(request) == "cdp.example.com:443"
def test_forwarded_host_uses_first_value(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "public.example.com, internal:9222",
})
assert _external_host(request) == "public.example.com"
def test_blank_forwarded_host_falls_back_to_host_header(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": " ",
})
assert _external_host(request) == "internal:9222"
def test_falls_back_to_host_header(self):
request = self._Request({"Host": "localhost:9222"})
assert _external_host(request) == "localhost:9222"
def test_falls_back_to_app_port_without_host_header(self):
request = self._Request({}, port=9333)
assert _external_host(request) == "localhost:9333"
def test_forwarded_proto_selects_wss(self):
request = self._Request({"X-Forwarded-Proto": "https"}, scheme="http")
assert _ws_scheme(request) == "wss"
def test_forwarded_proto_uses_first_value(self):
request = self._Request({"X-Forwarded-Proto": "https, http"}, scheme="http")
assert _ws_scheme(request) == "wss"
class TestHandlerURLRewriting:
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
class _Request:
def __init__(self, headers, query_string="fingerprint=seed1", port=9222, scheme="http"):
self.headers = headers
self.query_string = query_string
self.scheme = scheme
self.app = {"port": port, "pool": self._Pool()}
class _Pool:
async def get_or_launch(self, **_kwargs):
return SimpleNamespace(cdp_port=5100)
class _FakeResponse:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
async def json(self):
return self._data
class _FakeSession:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
def get(self, *_args, **_kwargs):
return TestHandlerURLRewriting._FakeResponse(self._data)
def _patch_session(self, monkeypatch, data):
monkeypatch.setattr(
_mod.aiohttp,
"ClientSession",
lambda *_args, **_kwargs: self._FakeSession(data),
)
def test_json_version_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, {
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/browser/browser-guid",
})
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_version(request))
payload = json.loads(response.text)
assert payload["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/browser/browser-guid"
)
def test_json_list_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, [{
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/page/page-guid",
}])
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_list(request))
payload = json.loads(response.text)
assert payload[0]["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/page/page-guid"
)
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
class TestWebSocketOriginGuard:
"""Verify cloakserve rejects browser-origin CDP WebSocket hijacks."""
def test_absent_origin_allowed_for_non_browser_cdp_clients(self):
assert _mod._origin_is_allowed(None, "127.0.0.1:9555")
def test_matching_origin_host_allowed(self):
assert _mod._origin_is_allowed("http://127.0.0.1:9555", "127.0.0.1:9555")
def test_chrome_devtools_origin_allowed(self):
assert _mod._origin_is_allowed("devtools://devtools", "127.0.0.1:9555")
assert _mod._origin_is_allowed("chrome-devtools://devtools", "127.0.0.1:9555")
@pytest.mark.parametrize("origin", [
"http://attacker.example",
"https://attacker.example",
"http://PUBLIC_HOST:9555",
"http://attacker.example:9555",
"http://127.0.0.1:9555/",
"http://127.0.0.1:9555/path",
"http://127.0.0.1:9555?q=1",
"http://127.0.0.1:9555#fragment",
"http://user@127.0.0.1:9555",
"http://@127.0.0.1:9555",
"http://:@127.0.0.1:9555",
"http://127.0.0.1:",
"null",
"file://",
])
def test_untrusted_browser_origins_rejected(self, origin):
assert not _mod._origin_is_allowed(origin, "127.0.0.1:9555")
def test_public_origin_matching_host_is_still_rejected(self):
assert not _mod._origin_is_allowed("http://attacker.example:9555", "attacker.example:9555")
@pytest.mark.parametrize("host", [
"user@127.0.0.1:9555",
"127.0.0.1:9555/path",
"127.0.0.1:9555?x=1",
"127.0.0.1:9555#fragment",
"127.0.0.1:9555, attacker.example:9555",
"@127.0.0.1:9555",
":@127.0.0.1:9555",
"127.0.0.1:",
"[::1]:",
])
def test_malformed_host_is_rejected_even_when_hostname_is_loopback(self, host):
assert not _mod._origin_is_allowed("http://127.0.0.1:9555", host)
def test_request_scheme_controls_host_default_port(self):
assert _mod._origin_is_allowed("https://localhost", "localhost", request_scheme="https")
assert not _mod._origin_is_allowed("https://localhost", "localhost", request_scheme="http")
def test_ws_handler_rejects_untrusted_origin_before_launching_chrome(self):
class RejectingPool:
async def get_or_launch(self, **_kwargs):
raise AssertionError("untrusted origin should be rejected before launching Chrome")
request = SimpleNamespace(
headers={"Host": "127.0.0.1:9555", "Origin": "http://attacker.example"},
app={"pool": RejectingPool()},
match_info={"path": "browser/browser-guid"},
)
response = asyncio.run(_mod.handle_ws_default(request))
assert response.status == 403
assert "untrusted" in response.text.lower()
def test_seed_ws_handler_rejects_untrusted_origin_before_launching_chrome(self):
class RejectingPool:
async def get_or_launch(self, **_kwargs):
raise AssertionError("untrusted origin should be rejected before launching Chrome")
request = SimpleNamespace(
headers={"Host": "127.0.0.1:9555", "Origin": "http://attacker.example"},
app={"pool": RejectingPool()},
match_info={"seed": "abc123", "path": "page/page-guid"},
)
response = asyncio.run(_mod.handle_ws_seed(request))
assert response.status == 403
assert "untrusted" in response.text.lower()
class TestHandlerURLRewriting:
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
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."""
@@ -457,21 +205,12 @@ class TestHandlerURLRewriting:
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self, idle_timeout: float = 0.0):
def _make_pool(self):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
idle_timeout=idle_timeout,
)
def _track_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace()
def _track_live_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace(
process=SimpleNamespace(poll=lambda: None),
)
def test_connect_increments(self):
@@ -508,83 +247,6 @@ class TestConnectionTracking:
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
def test_idle_cleanup_disabled_by_default(self):
async def run():
pool = self._make_pool()
self._track_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
await asyncio.sleep(0)
assert pool._idle_tasks == {}
asyncio.run(run())
def test_disconnect_to_zero_schedules_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.01)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
await asyncio.sleep(0.05)
assert cleaned == ["seed1"]
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_reconnect_cancels_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.03)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
pool.connect("seed1")
await asyncio.sleep(0.06)
assert cleaned == []
assert pool._connections["seed1"] == 1
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_discovery_refreshes_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=1.0)
self._track_live_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
first_task = pool._idle_tasks["seed1"]
await pool.get_or_launch("seed1")
second_task = pool._idle_tasks["seed1"]
assert second_task is not first_task
pool._cancel_idle_cleanup("seed1")
await asyncio.sleep(0)
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
# ---------------------------------------------------------------------------
# Seed validation (CVE fix — path traversal via fingerprint param)
-33
View File
@@ -1,33 +0,0 @@
import os
from unittest.mock import MagicMock, patch
from cloakbrowser import launch
@patch("cloakbrowser.browser.ensure_binary")
@patch("cloakbrowser.browser._import_sync_playwright")
def test_extension_loading(mock_playwright_import, mock_ensure_binary):
mock_ensure_binary.return_value = "/fake/chrome"
mock_browser = MagicMock()
mock_pw = MagicMock()
mock_pw.chromium.launch.return_value = mock_browser
mock_pw_manager = MagicMock()
mock_pw_manager.return_value.start.return_value = mock_pw
mock_playwright_import.return_value = mock_pw_manager
launch(extension_paths=["./ext"])
mock_pw.chromium.launch.assert_called_once()
launch_call = mock_pw.chromium.launch.call_args
args = launch_call.kwargs["args"]
abs_path = os.path.abspath("./ext")
assert f"--load-extension={abs_path}" in args
assert f"--disable-extensions-except={abs_path}" in args
+2 -114
View File
@@ -708,7 +708,7 @@ class TestBrowserBotDetection:
time.sleep(0.3)
page.locator('#password').fill('SecurePass!123')
time.sleep(0.5)
page.locator('#loginForm button[type="submit"]').click()
page.locator('button[type="submit"]').click()
time.sleep(5)
body = page.locator('body').text_content()
assert '"superHumanSpeed": true' not in body
@@ -725,7 +725,7 @@ class TestBrowserBotDetection:
t0 = time.time()
page.locator('#email').fill('test@example.com')
page.locator('#password').fill('MyPassword!99')
page.locator('#loginForm button[type="submit"]').click()
page.locator('button[type="submit"]').click()
elapsed_ms = int((time.time() - t0) * 1000)
time.sleep(3)
assert elapsed_ms > 3000
@@ -1828,118 +1828,6 @@ class TestScrollIntoViewIfNeeded:
assert cursor.x == 200 and cursor.y == 200
# =========================================================================
# Issue #307: frame/page click timeout should not multiply
# =========================================================================
class TestTimeoutBudget307:
"""Verify timeout budget is shared across sequential operations."""
def test_page_click_total_time_within_budget(self):
"""page.click on a missing element should not exceed ~1x the timeout."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
TIMEOUT_MS = 500
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = MagicMock()
page.is_checked = MagicMock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = MagicMock(return_value={"hit": True})
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
loc = MagicMock()
loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
loc.is_visible = MagicMock(return_value=False)
loc.first = loc
page.locator = MagicMock(return_value=loc)
h.patch_page(page, cfg, cursor)
start = time.monotonic()
try:
page.click("#does-not-exist", timeout=TIMEOUT_MS)
except Exception:
pass
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < TIMEOUT_MS * 1.8, (
f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
)
class TestPointerEventsFailOpen:
"""The pointer-events check must fail open: when it cannot run (evaluate /
bounding_box throws -> result None), proceed with the click instead of
blocking it until the timeout expires."""
def test_handle_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability import check_pointer_events_handle
el = MagicMock()
el.bounding_box = MagicMock(side_effect=Exception("stale handle"))
el.evaluate = MagicMock(side_effect=Exception("execution context destroyed"))
start = time.monotonic()
check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
def test_locator_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability import check_pointer_events
page = MagicMock()
loc = MagicMock()
loc.first = loc
loc.bounding_box = MagicMock(side_effect=Exception("no element"))
loc.evaluate = MagicMock(side_effect=Exception("no element"))
page.locator = MagicMock(return_value=loc)
start = time.monotonic()
check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
def test_handle_still_raises_when_covered(self):
"""A genuine 'covered' result (not None) must still raise — fail-open
only applies when the check could not be determined."""
from cloakbrowser.human.actionability import (
check_pointer_events_handle, ElementNotReceivingEventsError,
)
el = MagicMock()
el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"})
with pytest.raises(ElementNotReceivingEventsError):
check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200)
def test_async_handle_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability_async import async_check_pointer_events_handle
from unittest.mock import AsyncMock
el = MagicMock()
el.bounding_box = AsyncMock(side_effect=Exception("stale handle"))
el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed"))
start = time.monotonic()
asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000))
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
-171
View File
@@ -1,171 +0,0 @@
"""Security tests for the AWS Lambda handler URL validation."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(
0, str(Path(__file__).resolve().parent.parent / "examples" / "integrations" / "aws_lambda")
)
from lambda_handler import _build_launch_kwargs, _classify_error, _validate_url
class TestSchemeValidation:
"""Fix 1: only http:// and https:// are accepted."""
@pytest.mark.parametrize("url", [
"file:///etc/passwd",
"file:///proc/self/environ",
"data:text/html,<h1>pwned</h1>",
"javascript:alert(1)",
"chrome://settings",
"about:blank",
"ftp://example.com/file",
"",
])
def test_rejects_non_http_schemes(self, url):
with pytest.raises(ValueError, match="Only http"):
_validate_url(url)
@pytest.mark.parametrize("url", [
"https://example.com",
"http://example.com",
"https://example.com/path?q=1",
"HTTP://EXAMPLE.COM",
])
def test_accepts_http_and_https(self, url):
_validate_url(url)
def test_rejects_missing_hostname(self):
with pytest.raises(ValueError, match="no hostname"):
_validate_url("http://")
class TestSSRFProtection:
"""Fix 2: block private, loopback, link-local, reserved, and metadata IPs."""
@pytest.mark.parametrize("url,label", [
("http://169.254.169.254", "AWS metadata"),
("http://169.254.169.254/latest/meta-data/", "AWS metadata path"),
("http://127.0.0.1", "loopback"),
("http://127.0.0.2", "loopback range"),
("http://localhost", "localhost"),
("http://10.0.0.1", "private 10.x"),
("http://172.16.0.1", "private 172.16"),
("http://192.168.1.1", "private 192.168"),
("http://0.0.0.0", "unspecified"),
("http://[::1]", "IPv6 loopback"),
])
def test_rejects_private_ips(self, url, label):
with pytest.raises(ValueError, match="private/internal"):
_validate_url(url)
def test_rejects_carrier_grade_nat(self):
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://100.64.0.1")
def test_rejects_unresolvable_hostname(self):
with pytest.raises(ValueError, match="Cannot resolve"):
_validate_url("http://this-host-does-not-exist-cb-test.invalid")
def test_rejects_ipv4_mapped_ipv6(self):
"""::ffff:127.0.0.1 should be blocked even though it's technically IPv6."""
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://[::ffff:127.0.0.1]")
class TestExtraArgsRemoval:
"""Fix 3: caller-controlled extra_args are ignored; internal _strategy_args work."""
def test_ignores_caller_extra_args(self):
event = {"url": "https://example.com", "extra_args": ["--remote-debugging-port=9222"]}
kwargs = _build_launch_kwargs(event)
assert "--remote-debugging-port=9222" not in kwargs["args"]
def test_includes_strategy_args(self):
event = {"url": "https://example.com", "_strategy_args": ["--ignore-certificate-errors"]}
kwargs = _build_launch_kwargs(event)
assert "--ignore-certificate-errors" in kwargs["args"]
def test_classify_error_uses_strategy_args(self):
result = _classify_error(Exception("ERR_CERT_AUTHORITY_INVALID"))
assert "_strategy_args" in result
assert "extra_args" not in result
def test_always_includes_lambda_hardening_flags(self):
kwargs = _build_launch_kwargs({"url": "https://example.com"})
assert "--disable-dev-shm-usage" in kwargs["args"]
assert "--no-zygote" in kwargs["args"]
def test_caller_cannot_inject_strategy_args(self):
"""_strategy_args in the caller event must be stripped by _run() before launch."""
from lambda_handler import _run
import inspect
source = inspect.getsource(_run)
assert '"_strategy_args"' in source and "extra_args" in source, \
"_run must strip both _strategy_args and extra_args from caller event"
class TestRedirectSSRF:
"""Fix 5: post-navigation re-validation catches redirects to blocked IPs.
These mock socket.getaddrinfo to simulate redirect scenarios without
needing a real browser or HTTP server.
"""
def test_validate_url_catches_redirect_target(self):
"""If Chromium followed a redirect to 169.254.169.254, the post-nav
_validate_url(page.url) call should reject it."""
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
def test_validate_url_catches_localhost_redirect(self):
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://127.0.0.1:8080/admin")
def test_code_flow_validates_before_content(self):
"""Verify that _attempt_scrape calls _validate_url(page.url) at line 282
BEFORE building the result dict at line 290 (sequential code path)."""
import ast
handler_path = (
Path(__file__).resolve().parent.parent
/ "examples" / "integrations" / "aws_lambda" / "lambda_handler.py"
)
source = handler_path.read_text()
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef) and node.name == "_attempt_scrape":
body = node.body
# Find the try block
for stmt in body:
if isinstance(stmt, ast.Try):
try_body = stmt.body
validate_lines = []
content_line = None
for s in try_body:
if isinstance(s, ast.Expr) and isinstance(s.value, ast.Call):
func = s.value.func
if isinstance(func, ast.Name) and func.id == "_validate_url":
validate_lines.append(s.lineno)
if isinstance(s, ast.AnnAssign):
if isinstance(s.target, ast.Name) and s.target.id == "result":
content_line = s.lineno
elif isinstance(s, ast.Assign):
for target in s.targets:
if isinstance(target, ast.Name) and target.id == "result":
content_line = s.lineno
assert len(validate_lines) >= 2, (
f"Expected 2 _validate_url calls, found {len(validate_lines)}"
)
assert content_line is not None
assert all(v < content_line for v in validate_lines), (
f"_validate_url (lines {validate_lines}) must come before "
f"result assignment (line {content_line})"
)
return
pytest.fail("Could not find _attempt_scrape function in source")
+2 -32
View File
@@ -165,11 +165,10 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
pw.stop.assert_called_once()
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform):
"""Proxy string parsed and passed (unsupported platform → Playwright dict)."""
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -258,32 +257,3 @@ async def test_persistent_context_async_timezone_id_alias(_mock_bin):
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
@patch("cloakbrowser.browser.seed_widevine_hint")
def test_persistent_context_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
"""Sync persistent launch seeds the Widevine hint with the profile path."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile")
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
@patch("cloakbrowser.browser.seed_widevine_hint")
async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
"""Async persistent launch seeds the Widevine hint with the profile path."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile")
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
+14 -118
View File
@@ -55,12 +55,12 @@ class TestBuildProxyKwargs:
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_proxy_with_auth(self, *_):
def test_proxy_with_auth(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
assert kwargs == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
assert args == []
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
@@ -68,9 +68,7 @@ class TestBuildProxyKwargs:
assert kwargs == {"proxy": proxy_dict}
assert args == []
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_proxy_dict_with_auth(self, *_):
def test_proxy_dict_with_auth(self):
proxy_dict = {
"server": "http://proxy:8080",
"username": "user",
@@ -78,11 +76,8 @@ class TestBuildProxyKwargs:
"bypass": ".example.com",
}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {}
assert args == [
"--proxy-server=http://user:pass@proxy:8080",
"--proxy-bypass-list=.example.com",
]
assert kwargs == {"proxy": proxy_dict}
assert args == []
class TestMaybeResolveGeoip:
@@ -188,12 +183,11 @@ class TestBareProxyFormat:
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_resolve_proxy_config_bare(self, *_):
def test_resolve_proxy_config_bare(self):
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
assert kwargs["proxy"]["username"] == "user"
assert kwargs["proxy"]["password"] == "pass"
assert "user" not in kwargs["proxy"]["server"]
class TestIsSocksProxy:
@@ -225,17 +219,11 @@ class TestResolveProxyConfig:
assert kwargs == {}
assert args == []
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_with_creds_returns_chrome_arg(self, *_):
def test_http_string_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
def test_http_string_no_creds_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["server"] == "http://proxy:8080"
assert kwargs["proxy"]["username"] == "user"
assert args == []
def test_http_dict_passthrough(self):
@@ -386,95 +374,3 @@ class TestResolveProxyConfig:
# Port 0 is an unusual but valid URL component; don't silently strip it.
_, args = _resolve_proxy_config("socks5://user:pass=1@host:0")
assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0"
# --- HTTP with credentials → --proxy-server (supported platforms + version) ---
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_with_creds_on_supported_platform(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_dict_with_creds_on_supported_platform(self, *_):
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_dict_with_creds_and_bypass(self, *_):
proxy = {
"server": "http://proxy:8080",
"username": "user",
"password": "pass",
"bypass": ".google.com",
}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "--proxy-server=http://user:pass@proxy:8080" in args
assert "--proxy-bypass-list=.google.com" in args
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_encodes_special_chars_in_password(self, *_):
_, args = _resolve_proxy_config("http://user:pass=123@proxy:8080")
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_encoding_idempotent(self, *_):
_, args = _resolve_proxy_config("http://user:pass%3D123@proxy:8080")
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="windows-x64")
def test_http_string_with_creds_on_windows(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_with_creds_old_version_falls_back(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert args == []
# --- HTTP with credentials on unsupported platform → fallback to Playwright ---
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_string_with_creds_on_macos_falls_back(self, _mock):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["username"] == "user"
assert args == []
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_dict_with_creds_on_macos_falls_back(self, _mock):
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-arm64")
def test_http_string_with_creds_on_linux_arm_falls_back(self, _mock):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert args == []
# --- HTTP without credentials (all platforms) ---
def test_http_no_creds_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert "proxy" in kwargs
assert args == []
def test_http_dict_no_creds_returns_playwright_dict(self):
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
-158
View File
@@ -1,158 +0,0 @@
"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py)."""
import json
import pytest
from cloakbrowser import widevine
from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint
_HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
@pytest.fixture(autouse=True)
def _force_linux(monkeypatch):
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
def _make_cdm(dirpath):
"""Create a fake WidevineCdm dir with a manifest.json."""
dirpath.mkdir(parents=True, exist_ok=True)
(dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}')
return dirpath
def _binary(tmp_path):
"""Return a fake chrome binary path inside its own dir."""
bdir = tmp_path / "bin"
bdir.mkdir(parents=True, exist_ok=True)
return bdir / "chrome"
def test_seeds_hint_next_to_binary(tmp_path):
"""CDM in <binary dir>/WidevineCdm -> hint file written with abs Path."""
binary = _binary(tmp_path)
cdm = _make_cdm(binary.parent / "WidevineCdm")
profile = tmp_path / "profile"
seed_widevine_hint(profile, binary)
hint = profile / _HINT
assert hint.is_file()
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
def test_seeds_hint_from_env_var(tmp_path, monkeypatch):
"""CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
def test_no_cdm_no_file(tmp_path):
"""No CDM present -> nothing written, no exception."""
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_kill_switch_disables(tmp_path, monkeypatch):
"""CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists."""
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm")))
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0")
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_idempotent(tmp_path, monkeypatch):
"""Seeding twice leaves the same correct content and doesn't error."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
binary = _binary(tmp_path)
seed_widevine_hint(profile, binary)
seed_widevine_hint(profile, binary)
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
def test_noop_on_non_linux(tmp_path, monkeypatch):
"""On non-Linux, seeding is a no-op even with a CDM present."""
monkeypatch.setattr(widevine.platform, "system", lambda: "Windows")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm")))
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_resolve_requires_manifest(tmp_path, monkeypatch):
"""A WidevineCdm dir without manifest.json is not treated as a CDM."""
bogus = tmp_path / "custom_cdm"
bogus.mkdir()
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None
def test_env_var_is_exclusive(tmp_path, monkeypatch):
"""An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir."""
binary = _binary(tmp_path)
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
bogus = tmp_path / "bogus"
bogus.mkdir() # set but no manifest.json
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
assert resolve_widevine_cdm_dir(binary) is None
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
binary = _binary(tmp_path)
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
assert resolve_widevine_cdm_dir(binary) is None
def test_empty_user_data_dir_skips(tmp_path, monkeypatch):
"""Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
monkeypatch.chdir(tmp_path)
seed_widevine_hint("", _binary(tmp_path))
assert not (tmp_path / "WidevineCdm").exists()
def test_never_raises_on_write_failure(tmp_path, monkeypatch):
"""A write failure (hint dir path is a file) must not raise — launch must not break."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
profile.mkdir()
# Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
(profile / "WidevineCdm").write_text("not a dir")
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch):
"""A non-UTF8 / mismatched existing hint is overwritten, without raising."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
hint = profile / "WidevineCdm" / _HINT.split("/")[-1]
hint.parent.mkdir(parents=True)
hint.write_bytes(b"\xff\xfe not valid utf-8")
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
# corrupt content replaced with a valid hint pointing at the CDM
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())