mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9eb4bbf0 | ||
|
|
10f492e95b | ||
|
|
660b6bf58c | ||
|
|
50bf14b3f9 | ||
|
|
d67c21abbe | ||
|
|
776630e08b | ||
|
|
402a884088 | ||
|
|
39db492b04 | ||
|
|
b06499b0c1 | ||
|
|
a6b1363244 | ||
|
|
b4a4ad21ab | ||
|
|
dcf9ba55d6 | ||
|
|
14ec2ebf5f | ||
|
|
0caa14bf7b | ||
|
|
2a99081850 | ||
|
|
7fc577e5c6 | ||
|
|
12d02c3547 | ||
|
|
243c1385a0 | ||
|
|
0f3dc7201b | ||
|
|
34d2f78e87 | ||
|
|
41be4e0e30 | ||
|
|
58ccdb683c | ||
|
|
8028ddefef | ||
|
|
864cae2493 | ||
|
|
7e626ee7a1 | ||
|
|
b91274cc98 | ||
|
|
7a9a61d4de | ||
|
|
34bc095b65 | ||
|
|
a23268c9e9 | ||
|
|
0437a3f1f5 | ||
|
|
8fdaa5a2d3 | ||
|
|
b0ea580cba | ||
|
|
6f4f92e7c7 | ||
|
|
ad4d946ca6 | ||
|
|
95a98b6747 | ||
|
|
23f1d4098c | ||
|
|
d45d7de9a9 |
@@ -0,0 +1,5 @@
|
||||
# Never let signing material enter a Docker build context / image.
|
||||
# The test image (test-infra/Dockerfile.test) uses selective COPY today, but
|
||||
# this is defense-in-depth against a future `COPY . .`.
|
||||
test-infra/signing/
|
||||
*.pem
|
||||
@@ -8,3 +8,21 @@ updates:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
python:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/js"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
javascript:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -16,9 +16,10 @@ jobs:
|
||||
contents: write # Download release assets
|
||||
steps:
|
||||
- name: Download release binaries
|
||||
run: gh release download ${{ github.event.inputs.tag }} --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
|
||||
run: gh release download "$RELEASE_TAG" --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag }}
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
- 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
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -46,6 +46,7 @@ js/dist/
|
||||
*.whl
|
||||
AGENTS.md
|
||||
.beads
|
||||
result
|
||||
|
||||
# Private docs (launch posts, strategy)
|
||||
docs/
|
||||
@@ -72,3 +73,4 @@ captures
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
.antigravitycli
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
# CloakBrowser Binary License
|
||||
|
||||
**Version 1.0 — February 2026**
|
||||
**Version 1.1 — June 2026**
|
||||
|
||||
Copyright (c) 2026 CloakHQ. All rights reserved.
|
||||
|
||||
@@ -14,7 +14,13 @@ The Binary is built on Chromium, which is open-source software by The Chromium A
|
||||
|
||||
## Grant of Use
|
||||
|
||||
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes. No fees are required.
|
||||
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes, subject to the Version-Specific Terms below.
|
||||
|
||||
## Version-Specific Terms
|
||||
|
||||
Starting with Chromium 148, downloading the **latest major** Binary version requires an active CloakBrowser Pro subscription. Previous **major** versions (v146 and earlier) remain available at no cost under this license. Each time a new **major** Chromium version is released, the **prior major version** becomes available for free download. Minor and patch updates to the latest major version are part of the Pro subscription.
|
||||
|
||||
Pro subscription details and pricing: https://cloakbrowser.dev
|
||||
|
||||
## Restrictions
|
||||
|
||||
|
||||
+51
-1
@@ -6,7 +6,57 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
## [0.4.0] — 2026-06-22
|
||||
|
||||
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
|
||||
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
|
||||
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
|
||||
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
|
||||
- **[binary]** **CloakBrowser Pro — first Pro build**: Chromium `148.0.7778.215.2` for linux-x64, linux-arm64, and windows-x64 — 59 source-level fingerprint patches (up from 58 on 146). macOS builds to follow. Available to Pro subscribers at cloakbrowser.dev; v146 remains free on GitHub Releases.
|
||||
- **[binary]** Rebased the full patch set across two Chromium major versions — 146 → 147 → 148 — re-applying and adapting every patch to current Chromium internals
|
||||
- **[binary]** Cross-API fingerprint consistency improvements for Chromium 148 profiles
|
||||
- **[binary]** WebRTC fingerprint hardening — network signals matched to real Chrome
|
||||
- **[binary]** Font metric alignment for Windows profiles via the opt-in `--fingerprint-windows-font-metrics` flag — requires Windows fonts installed (see README "Font Setup on Linux")
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
- **49 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
|
||||
- **58 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
|
||||
@@ -49,40 +49,67 @@ Same API, same code — just swap the import. <strong>3 lines of code, 30 second
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
|
||||
**Try it now** — no install needed:
|
||||
|
||||
```bash
|
||||
docker run --rm cloakhq/cloakbrowser cloaktest
|
||||
```
|
||||
|
||||
**Python:**
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch
|
||||
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto("https://protected-site.com") # no more blocks
|
||||
page.goto("https://example.com")
|
||||
browser.close()
|
||||
```
|
||||
|
||||
**JavaScript (Playwright):**
|
||||
|
||||
```javascript
|
||||
import { launch } from 'cloakbrowser';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.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:**
|
||||
|
||||
```bash
|
||||
pip install cloakbrowser
|
||||
```
|
||||
|
||||
**JavaScript / Node.js:**
|
||||
|
||||
```bash
|
||||
# With Playwright
|
||||
npm install cloakbrowser playwright-core
|
||||
@@ -94,6 +121,7 @@ npm install cloakbrowser puppeteer-core
|
||||
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
|
||||
|
||||
**Optional:** Auto-detect timezone/locale from proxy IP:
|
||||
|
||||
```bash
|
||||
pip install cloakbrowser[geoip]
|
||||
```
|
||||
@@ -114,31 +142,22 @@ page.goto("https://example.com")
|
||||
|
||||
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
|
||||
|
||||
## Browser Profile Manager
|
||||
|
||||
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
|
||||
```
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
|
||||
|
||||
→ **[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.3.26 (Chromium 146.0.7680.177.4)
|
||||
## Latest: v0.4.0 — CloakBrowser Pro (Chromium 148.0.7778.215.2)
|
||||
|
||||
- **`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)
|
||||
- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.2, 59 source-level patches) is now available to Pro subscribers; v146 stays free forever. Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
|
||||
- **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
|
||||
- **`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
|
||||
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
|
||||
- **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)
|
||||
- **`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
|
||||
@@ -156,6 +175,28 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
|
||||
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
|
||||
|
||||
## CloakBrowser Pro
|
||||
|
||||
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
|
||||
free-release model:
|
||||
|
||||
- **Free (v146)** — the previous binary, on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Goes stale within weeks as detection evolves.
|
||||
- **Pro (latest, Chromium 148.0.7778.215.2)** — the newest patches and Chromium upgrades first, so the [results below](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
|
||||
|
||||
Anti-bot detection updates constantly, and an older binary degrades fast.
|
||||
Pro keeps you on the build that's actively maintained against it.
|
||||
|
||||
Use Pro if CloakBrowser is part of production scraping, QA, monitoring, or
|
||||
automation where stale browser fingerprints cost you time or blocked runs.
|
||||
|
||||
Activate with your license key (env var, `license_key=` param, or `~/.cloakbrowser/license.key`):
|
||||
|
||||
```bash
|
||||
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
|
||||
```
|
||||
|
||||
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
|
||||
|
||||
## Test Results
|
||||
|
||||
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
|
||||
@@ -226,11 +267,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||
|
||||
Binary downloads are verified with SHA-256 checksums to ensure integrity.
|
||||
Binary downloads are verified against a pinned Ed25519 signature on the published checksums before extraction, so the download is confirmed authentic (genuinely ours) and not just intact. A compromised mirror cannot serve a tampered or downgraded binary.
|
||||
|
||||
## API
|
||||
|
||||
@@ -245,6 +286,9 @@ browser = launch()
|
||||
# Headed mode (see the browser window)
|
||||
browser = launch(headless=False)
|
||||
|
||||
# Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
|
||||
browser = launch(license_key="cb_xxxxxxxx")
|
||||
|
||||
# With proxy (HTTP or SOCKS5)
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
browser = launch(proxy="socks5://user:pass@proxy:1080")
|
||||
@@ -355,10 +399,12 @@ asyncio.run(main())
|
||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
||||
|
||||
Use this when you need to:
|
||||
|
||||
- **Stay logged in** across runs (cookies/sessions survive restarts)
|
||||
- **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
|
||||
@@ -371,9 +417,16 @@ 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`.
|
||||
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`, `extension_paths`.
|
||||
|
||||
Async version: `launch_persistent_context_async()`.
|
||||
|
||||
@@ -388,6 +441,26 @@ 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:
|
||||
@@ -406,7 +479,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
||||
|
||||
# Check binary installation status
|
||||
print(binary_info())
|
||||
# {'version': '146.0.7680.177.3', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
# {'version': '146.0.7680.177.5', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
|
||||
# Force re-download
|
||||
clear_cache()
|
||||
@@ -427,6 +500,9 @@ import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
// Basic
|
||||
const browser = await launch();
|
||||
|
||||
// Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
|
||||
const browser = await launch({ licenseKey: 'cb_xxxxxxxx' });
|
||||
|
||||
// With options
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
@@ -569,8 +645,10 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
||||
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||
| `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_SKIP_CHECKSUM` | `false` | Only applies to a custom `CLOAKBROWSER_DOWNLOAD_URL`: set to `true` to skip its checksum check. Signature verification on the official download path is mandatory and cannot be skipped. |
|
||||
| `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
|
||||
|
||||
@@ -587,9 +665,11 @@ The binary is **stealthy by default** — no flags needed. It auto-generates a r
|
||||
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
|
||||
|
||||
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
|
||||
>
|
||||
> ```python
|
||||
> browser = launch(args=["--fingerprint=12345"])
|
||||
> ```
|
||||
>
|
||||
> ```javascript
|
||||
> const browser = await launch({ args: ['--fingerprint=12345'] });
|
||||
> ```
|
||||
@@ -628,6 +708,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
||||
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
||||
| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) |
|
||||
| `--fingerprint-windows-font-metrics` | Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
|
||||
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
|
||||
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||
@@ -682,6 +763,7 @@ browser = launch(args=[
|
||||
## Examples
|
||||
|
||||
**Python** — see [`examples/`](examples/):
|
||||
|
||||
- [`basic.py`](examples/basic.py) — Launch and load a page
|
||||
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
|
||||
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
|
||||
@@ -689,6 +771,7 @@ browser = launch(args=[
|
||||
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
|
||||
|
||||
**JavaScript** — see [`js/examples/`](js/examples/):
|
||||
|
||||
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
|
||||
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
|
||||
@@ -740,11 +823,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 146 | 57 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 146 | 57 | ✅ Latest |
|
||||
| Linux x86_64 | 146 | 58 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
|
||||
| Windows x86_64 | 146 | 57 | ✅ Latest |
|
||||
| Windows x86_64 | 146 | 58 | ✅ Latest |
|
||||
|
||||
The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
@@ -754,6 +837,8 @@ The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
Pre-built image on Docker Hub — no install, no setup.
|
||||
|
||||
> **Pro:** the image ships with the free binary. Set `CLOAKBROWSER_LICENSE_KEY` (e.g. `-e CLOAKBROWSER_LICENSE_KEY=cb_xxx`, or in Compose) and the latest binary downloads at runtime.
|
||||
|
||||
### Quick test
|
||||
|
||||
```bash
|
||||
@@ -808,6 +893,26 @@ 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
|
||||
@@ -818,6 +923,10 @@ 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:
|
||||
@@ -869,7 +978,9 @@ 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). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
|
||||
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).
|
||||
|
||||
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
|
||||
|
||||
@@ -977,6 +1088,52 @@ 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` |
|
||||
| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (requires Windows fonts installed) |
|
||||
| **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-windows-font-metrics", # align font metrics (requires Windows fonts)
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@residential-proxy:port',
|
||||
geoip: true,
|
||||
args: [
|
||||
'--fingerprint-noise=false',
|
||||
'--fingerprint-windows-font-metrics', // align font metrics (requires Windows fonts)
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -1024,6 +1181,7 @@ For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTT
|
||||
### Something not working? Make sure you're on the latest version
|
||||
|
||||
Older versions may use outdated stealth args or download an older binary:
|
||||
|
||||
```bash
|
||||
pip install -U cloakbrowser # Python
|
||||
npm install cloakbrowser@latest # JavaScript
|
||||
@@ -1035,6 +1193,7 @@ docker pull cloakhq/cloakbrowser:latest # Docker
|
||||
### Binary download fails / timeout
|
||||
|
||||
Set a custom download URL or use a local binary:
|
||||
|
||||
```bash
|
||||
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
|
||||
```
|
||||
@@ -1044,11 +1203,13 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
|
||||
### New update broke something? Roll back to the previous version
|
||||
|
||||
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
|
||||
|
||||
```bash
|
||||
pip install cloakbrowser==0.3.21 # Python
|
||||
npm install cloakbrowser@0.3.21 # JavaScript
|
||||
docker pull cloakhq/cloakbrowser:0.3.21 # Docker
|
||||
```
|
||||
|
||||
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
|
||||
|
||||
---
|
||||
@@ -1056,6 +1217,7 @@ Each wrapper version pins its own binary version, so downgrading the wrapper aut
|
||||
### macOS: "App is damaged" or Gatekeeper blocks launch
|
||||
|
||||
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
||||
|
||||
```bash
|
||||
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
|
||||
```
|
||||
@@ -1065,6 +1227,7 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
|
||||
### "playwright install" vs CloakBrowser binary
|
||||
|
||||
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
|
||||
|
||||
```bash
|
||||
playwright install-deps chromium
|
||||
```
|
||||
@@ -1113,16 +1276,18 @@ await new Promise(r => setTimeout(r, 3000));
|
||||
```
|
||||
|
||||
Other tips for maximizing reCAPTCHA scores:
|
||||
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
|
||||
|
||||
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
|
||||
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
|
||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
|
||||
- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management))
|
||||
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
|
||||
|
||||
```python
|
||||
page.type("#email", "user@example.com", delay=50)
|
||||
```
|
||||
|
||||
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
|
||||
|
||||
## FAQ
|
||||
@@ -1130,6 +1295,15 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
**Q: Is this legal?**
|
||||
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
|
||||
|
||||
**Q: Is CloakBrowser free?**
|
||||
A: The wrapper (Python + JS) is MIT and free forever. The binary uses a delayed free-release model: the previous Chromium major version (currently v146) is free on GitHub Releases with unlimited sessions; the latest major version is for [Pro subscribers](https://cloakbrowser.dev). Each new major release rolls the prior major version down to free.
|
||||
|
||||
**Q: Do I need a license key for the free version?**
|
||||
A: No. The free binary downloads automatically with no key. A license key only unlocks the latest (Pro) binary.
|
||||
|
||||
**Q: What happens if I cancel Pro?**
|
||||
A: Your subscription stays active until the end of the current billing period — cancelling doesn't cut you off immediately. After it ends, the wrapper stops pulling new Pro versions and falls back to the free binary on its next license check (cached ~24h). You just stop getting new versions.
|
||||
|
||||
**Q: How is this different from Camoufox?**
|
||||
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
|
||||
|
||||
@@ -1143,9 +1317,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 146 (57 patches) | ✅ Released |
|
||||
| Linux x64 — Chromium 146 (58 patches) | ✅ Released |
|
||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| Windows x64 — Chromium 146 (57 patches) | ✅ Released |
|
||||
| Windows x64 — Chromium 146 (58 patches) | ✅ Released |
|
||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
@@ -1158,16 +1332,16 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@
|
||||
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
|
||||
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
|
||||
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
|
||||
- 📧 **Contact** — cloakhq@pm.me
|
||||
- 📧 **Contact** — <cloakhq@pm.me>
|
||||
|
||||
## Security
|
||||
|
||||
All releases are signed for supply chain verification.
|
||||
The wrapper automatically verifies every binary download against a pinned Ed25519 signature on the published checksums before extraction — a compromised mirror cannot serve a tampered or downgraded binary. Releases are additionally signed for manual 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.3
|
||||
git verify-tag chromium-v146.0.7680.177.5
|
||||
|
||||
# Verify GitHub binary attestation (Sigstore)
|
||||
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
||||
@@ -1182,7 +1356,10 @@ cosign verify \
|
||||
## License
|
||||
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
|
||||
- **CloakBrowser binary** (compiled Chromium):
|
||||
- **v146 and earlier** — free for personal and commercial use, no redistribution (OEM/SaaS license required to serve third parties).
|
||||
- **v148+ (latest)** — requires an active [CloakBrowser Pro](https://cloakbrowser.dev) subscription to download.
|
||||
- See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1193,7 +1370,16 @@ 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
|
||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
|
||||
- [@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
|
||||
- [@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)
|
||||
|
||||
+194
-5
@@ -18,6 +18,7 @@ Client:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -29,7 +30,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -64,6 +65,91 @@ 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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,6 +181,7 @@ 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
|
||||
@@ -103,12 +190,14 @@ 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:
|
||||
@@ -138,6 +227,7 @@ 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:
|
||||
@@ -145,9 +235,54 @@ 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,
|
||||
@@ -185,6 +320,8 @@ 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) — "
|
||||
@@ -274,6 +411,7 @@ 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
|
||||
@@ -291,6 +429,13 @@ 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")
|
||||
@@ -367,9 +512,21 @@ 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"]
|
||||
@@ -381,6 +538,7 @@ 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,
|
||||
@@ -388,6 +546,7 @@ 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,
|
||||
})
|
||||
|
||||
@@ -418,7 +577,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 = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||
host = _external_host(request)
|
||||
seed_key = params["seed"]
|
||||
if seed_key:
|
||||
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
||||
@@ -459,7 +618,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 = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||
host = _external_host(request)
|
||||
scheme = _ws_scheme(request)
|
||||
seed_key = params["seed"]
|
||||
|
||||
@@ -527,8 +686,12 @@ async def proxy_cdp_websocket(
|
||||
logger.error("%s error: %s", label, exc)
|
||||
|
||||
|
||||
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
|
||||
async def handle_ws_default(request: web.Request) -> web.StreamResponse:
|
||||
"""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", "")
|
||||
|
||||
@@ -546,8 +709,12 @@ async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
|
||||
return ws
|
||||
|
||||
|
||||
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
|
||||
async def handle_ws_seed(request: web.Request) -> web.StreamResponse:
|
||||
"""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", "")
|
||||
@@ -581,6 +748,23 @@ 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).
|
||||
|
||||
@@ -596,12 +780,14 @@ 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=",
|
||||
)
|
||||
@@ -611,6 +797,8 @@ 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)
|
||||
@@ -655,6 +843,7 @@ 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()
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
#!/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
|
||||
|
||||
@@ -14,6 +14,7 @@ Usage:
|
||||
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from .license import LicenseInfo, validate_license
|
||||
from ._version import __version__
|
||||
|
||||
# Human-like behavioral layer (optional)
|
||||
@@ -44,6 +45,8 @@ __all__ = [
|
||||
"build_args",
|
||||
"maybe_resolve_geoip",
|
||||
"ProxySettings",
|
||||
"validate_license",
|
||||
"LicenseInfo",
|
||||
"HumanConfig",
|
||||
"resolve_human_config",
|
||||
"__version__",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.28"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
+258
-101
@@ -22,6 +22,7 @@ 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")
|
||||
|
||||
@@ -30,6 +31,79 @@ logger = logging.getLogger("cloakbrowser")
|
||||
_VIEWPORT_UNSET = object()
|
||||
|
||||
|
||||
def _default_no_viewport(browser: Any) -> None:
|
||||
"""Default ``new_page()``/``new_context()`` to ``no_viewport=True``.
|
||||
|
||||
``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()``
|
||||
would otherwise inherit Playwright's emulated 1280x720 viewport, producing
|
||||
``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap
|
||||
the two factory methods so pages track the real OS window instead. ``setdefault``
|
||||
only: an explicit ``viewport`` or ``no_viewport`` from the caller is never
|
||||
overridden (Playwright rejects passing both). Applied for headed launches only.
|
||||
Composes under humanize's ``patch_browser`` (apply this first).
|
||||
"""
|
||||
orig_new_context = browser.new_context
|
||||
orig_new_page = browser.new_page
|
||||
|
||||
def _patched_new_context(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return orig_new_context(**kwargs)
|
||||
|
||||
def _patched_new_page(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return orig_new_page(**kwargs)
|
||||
|
||||
browser.new_context = _patched_new_context
|
||||
browser.new_page = _patched_new_page
|
||||
|
||||
|
||||
def _default_no_viewport_async(browser: Any) -> None:
|
||||
"""Async variant of :func:`_default_no_viewport`."""
|
||||
orig_new_context = browser.new_context
|
||||
orig_new_page = browser.new_page
|
||||
|
||||
async def _patched_new_context(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return await orig_new_context(**kwargs)
|
||||
|
||||
async def _patched_new_page(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return await orig_new_page(**kwargs)
|
||||
|
||||
browser.new_context = _patched_new_context
|
||||
browser.new_page = _patched_new_page
|
||||
|
||||
|
||||
def _resolve_context_viewport(viewport: Any, headless: bool) -> dict[str, Any]:
|
||||
"""Return the viewport kwarg for a context.
|
||||
|
||||
Headed: no emulated viewport so the page tracks the real window (CDP viewport
|
||||
emulation forces outerWidth < innerWidth = a physically impossible window =
|
||||
bot tell). Headless: a fixed ``DEFAULT_VIEWPORT`` stays coherent (outer == inner)
|
||||
and keeps dimensions deterministic. Explicit ``viewport`` / ``None`` honored.
|
||||
"""
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
return {"viewport": DEFAULT_VIEWPORT} if headless else {"no_viewport": True}
|
||||
if viewport is None:
|
||||
return {"no_viewport": True}
|
||||
return {"viewport": viewport}
|
||||
|
||||
|
||||
def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None:
|
||||
"""Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a
|
||||
named parameter (never in ``**kwargs``), so the only conflict is a caller passing
|
||||
``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit
|
||||
``no_viewport`` wins; drop the viewport so Playwright doesn't error.
|
||||
"""
|
||||
if "no_viewport" in kwargs and "viewport" in context_kwargs:
|
||||
logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins")
|
||||
context_kwargs.pop("viewport", None)
|
||||
|
||||
|
||||
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Accept both timezone and timezone_id — either works, no warning."""
|
||||
if "timezone_id" in kwargs:
|
||||
@@ -40,6 +114,15 @@ def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | Non
|
||||
return timezone
|
||||
|
||||
|
||||
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
|
||||
"""Raise a clear error for removed parameters that now fall into **kwargs."""
|
||||
if "backend" in kwargs:
|
||||
raise TypeError(
|
||||
"The 'backend' parameter has been removed — patchright is no longer "
|
||||
"supported and stock Playwright is the only backend. Remove the argument."
|
||||
)
|
||||
|
||||
|
||||
class _ProxySettingsRequired(TypedDict):
|
||||
server: str
|
||||
|
||||
@@ -60,10 +143,11 @@ def launch(
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||
@@ -75,6 +159,7 @@ 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.
|
||||
@@ -83,10 +168,6 @@ def launch(
|
||||
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
||||
GeoLite2-City database on first use. Explicit timezone/locale
|
||||
always override geoip results.
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
|
||||
but breaks proxy auth and add_init_script.
|
||||
Override globally with CLOAKBROWSER_BACKEND env var.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -103,16 +184,19 @@ def launch(
|
||||
>>> print(page.title())
|
||||
>>> browser.close()
|
||||
"""
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
|
||||
|
||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -137,6 +221,12 @@ def launch(
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Headed: default new_page()/new_context() to no_viewport so the page tracks the
|
||||
# real window (avoids the impossible-window tell). Headless keeps Playwright's
|
||||
# default viewport (coherent there). Apply before humanize so the wraps compose.
|
||||
if not headless:
|
||||
_default_no_viewport(browser)
|
||||
|
||||
# Human-like behavioral patching
|
||||
if humanize:
|
||||
from .human import patch_browser
|
||||
@@ -155,10 +245,11 @@ async def launch_async( # noqa: C901
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch(). Returns a Playwright Browser object.
|
||||
@@ -167,11 +258,11 @@ 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.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -193,16 +284,18 @@ async def launch_async( # noqa: C901
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
|
||||
|
||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -227,6 +320,10 @@ async def launch_async( # noqa: C901
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Headed: default new_page()/new_context() to no_viewport (see launch()).
|
||||
if not headless:
|
||||
_default_no_viewport_async(browser)
|
||||
|
||||
# Human-like behavioral patching (async variant)
|
||||
if humanize:
|
||||
from .human import patch_browser_async
|
||||
@@ -249,10 +346,11 @@ def launch_persistent_context(
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
@@ -268,6 +366,7 @@ 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}.
|
||||
@@ -278,7 +377,6 @@ def launch_persistent_context(
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``.
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -295,18 +393,20 @@ def launch_persistent_context(
|
||||
>>> page.goto("https://protected-site.com")
|
||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
||||
"""
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
@@ -319,15 +419,13 @@ def launch_persistent_context(
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
||||
elif viewport is None:
|
||||
context_kwargs["no_viewport"] = True
|
||||
else:
|
||||
context_kwargs["viewport"] = viewport
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
pw = sync_playwright().start()
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
@@ -373,10 +471,11 @@ async def launch_persistent_context_async(
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
@@ -391,6 +490,7 @@ 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}.
|
||||
@@ -399,7 +499,6 @@ async def launch_persistent_context_async(
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -421,18 +520,20 @@ async def launch_persistent_context_async(
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
@@ -445,15 +546,13 @@ async def launch_persistent_context_async(
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
||||
elif viewport is None:
|
||||
context_kwargs["no_viewport"] = True
|
||||
else:
|
||||
context_kwargs["viewport"] = viewport
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
pw = await async_playwright().start()
|
||||
context = await pw.chromium.launch_persistent_context(
|
||||
@@ -498,10 +597,11 @@ def launch_context(
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
@@ -513,6 +613,7 @@ 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}.
|
||||
@@ -522,7 +623,6 @@ def launch_context(
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -531,6 +631,8 @@ def launch_context(
|
||||
Returns:
|
||||
Playwright BrowserContext object.
|
||||
"""
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||
@@ -544,20 +646,17 @@ 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)
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths,
|
||||
license_key=license_key)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
||||
elif viewport is None:
|
||||
context_kwargs["no_viewport"] = True
|
||||
else:
|
||||
context_kwargs["viewport"] = viewport
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
try:
|
||||
context = browser.new_context(**context_kwargs)
|
||||
@@ -597,10 +696,11 @@ async def launch_context_async(
|
||||
timezone: str | None = None,
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_context().
|
||||
@@ -614,6 +714,7 @@ 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}.
|
||||
@@ -622,7 +723,6 @@ async def launch_context_async(
|
||||
timezone: IANA timezone (e.g. 'America/New_York').
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config mapping to override preset values.
|
||||
@@ -650,6 +750,8 @@ async def launch_context_async(
|
||||
>>>
|
||||
>>> asyncio.run(main())
|
||||
"""
|
||||
_check_removed_kwargs(kwargs)
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
|
||||
@@ -662,20 +764,17 @@ 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)
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths,
|
||||
license_key=license_key)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
||||
elif viewport is None:
|
||||
context_kwargs["no_viewport"] = True
|
||||
else:
|
||||
context_kwargs["viewport"] = viewport
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
||||
# triggers browser cleanup — otherwise the underlying Chromium process
|
||||
@@ -710,47 +809,6 @@ async def launch_context_async(
|
||||
return context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_backend(backend: str | None) -> str:
|
||||
"""Resolve backend: param > env var > default ('playwright')."""
|
||||
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
|
||||
if b not in ("playwright", "patchright"):
|
||||
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
|
||||
return b
|
||||
|
||||
|
||||
def _import_sync_playwright(backend: str):
|
||||
"""Import sync_playwright from the resolved backend."""
|
||||
if backend == "patchright":
|
||||
try:
|
||||
from patchright.sync_api import sync_playwright
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
|
||||
) from None
|
||||
return sync_playwright
|
||||
from playwright.sync_api import sync_playwright
|
||||
return sync_playwright
|
||||
|
||||
|
||||
def _import_async_playwright(backend: str):
|
||||
"""Import async_playwright from the resolved backend."""
|
||||
if backend == "patchright":
|
||||
try:
|
||||
from patchright.async_api import async_playwright
|
||||
except ModuleNotFoundError:
|
||||
raise ModuleNotFoundError(
|
||||
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
|
||||
) from None
|
||||
return async_playwright
|
||||
from playwright.async_api import async_playwright
|
||||
return async_playwright
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -761,7 +819,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||
|
||||
|
||||
def _assemble_socks_url(
|
||||
def _assemble_proxy_url(
|
||||
scheme: str,
|
||||
host: str,
|
||||
port: int | None,
|
||||
@@ -772,7 +830,7 @@ def _assemble_socks_url(
|
||||
query: str = "",
|
||||
fragment: str = "",
|
||||
) -> str:
|
||||
"""Build a SOCKS URL from already-percent-encoded credentials and host parts.
|
||||
"""Build a proxy 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
|
||||
@@ -803,7 +861,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_socks_url(
|
||||
return _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass, parsed.path,
|
||||
)
|
||||
@@ -843,7 +901,7 @@ def _normalize_socks_string_url(url: str) -> str:
|
||||
else:
|
||||
raw_pass = None
|
||||
enc_pass = None
|
||||
normalized = _assemble_socks_url(
|
||||
normalized = _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass,
|
||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||
@@ -957,6 +1015,7 @@ 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.
|
||||
|
||||
@@ -1000,6 +1059,15 @@ 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())
|
||||
|
||||
|
||||
@@ -1038,6 +1106,81 @@ 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:
|
||||
@@ -1051,8 +1194,9 @@ def _resolve_proxy_config(
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Resolve proxy into Playwright kwargs and Chrome args.
|
||||
|
||||
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
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).
|
||||
|
||||
Returns:
|
||||
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||
@@ -1073,7 +1217,20 @@ def _resolve_proxy_config(
|
||||
# passwords at '=' and other special chars (#157).
|
||||
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
|
||||
|
||||
# HTTP/HTTPS: use Playwright's proxy dict as before
|
||||
# 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
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}, []
|
||||
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||
|
||||
+47
-15
@@ -15,16 +15,29 @@ 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.3"
|
||||
CHROMIUM_VERSION = "146.0.7680.177.5"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "146.0.7680.177.3",
|
||||
"linux-x64": "146.0.7680.177.5",
|
||||
"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.4",
|
||||
"windows-x64": "146.0.7680.177.5",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 public keys for verifying downloaded binaries.
|
||||
#
|
||||
# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
||||
# The wrapper verifies that signature against the keys below before trusting
|
||||
# any hash in the manifest, so the download origin alone cannot certify a
|
||||
# tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
||||
# entries are accepted to allow key rotation.
|
||||
# ---------------------------------------------------------------------------
|
||||
BINARY_SIGNING_PUBKEYS: list[str] = [
|
||||
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playwright default args to suppress — these leak automation signals.
|
||||
# --enable-automation: exposes navigator.webdriver = true
|
||||
@@ -55,16 +68,19 @@ def get_default_stealth_args() -> list[str]:
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + ["--fingerprint-platform=macos"]
|
||||
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
# Linux/Windows: Windows fingerprint profile.
|
||||
# Screen and window size come from the real display, not this flag (verified:
|
||||
# identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
# headed mode — that would break outerWidth >= innerWidth coherence.
|
||||
return base + ["--fingerprint-platform=windows"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
||||
# the page tracks the real window). Headless has no window chrome, so a fixed
|
||||
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||
|
||||
@@ -118,15 +134,16 @@ def get_cache_dir() -> Path:
|
||||
return Path.home() / ".cloakbrowser"
|
||||
|
||||
|
||||
def get_binary_dir(version: str | None = None) -> Path:
|
||||
def get_binary_dir(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
v = version or get_chromium_version()
|
||||
return get_cache_dir() / f"chromium-{v}"
|
||||
suffix = "-pro" if pro else ""
|
||||
return get_cache_dir() / f"chromium-{v}{suffix}"
|
||||
|
||||
|
||||
def get_binary_path(version: str | None = None) -> Path:
|
||||
def get_binary_path(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the expected path to the chrome executable."""
|
||||
binary_dir = get_binary_dir(version)
|
||||
binary_dir = get_binary_dir(version, pro=pro)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
@@ -156,15 +173,30 @@ def check_platform_available() -> None:
|
||||
)
|
||||
|
||||
|
||||
def get_effective_version() -> str:
|
||||
def get_effective_version(pro: bool = False) -> str:
|
||||
"""Return the best available version: auto-updated if available, else platform default.
|
||||
|
||||
Reads a platform-scoped marker file from the cache directory.
|
||||
Returns the platform's hardcoded version if no update has been downloaded.
|
||||
When pro=True, reads from the Pro-specific marker files.
|
||||
"""
|
||||
base = get_chromium_version()
|
||||
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
cache = get_cache_dir()
|
||||
|
||||
if pro:
|
||||
marker = cache / f"latest_pro_version_{get_platform_tag()}"
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version:
|
||||
binary = get_binary_path(version, pro=True)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return base
|
||||
|
||||
# Free tier: try platform-scoped marker first, fall back to legacy marker
|
||||
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
||||
marker = cache / name
|
||||
if marker.exists():
|
||||
|
||||
+423
-26
@@ -23,6 +23,7 @@ import httpx
|
||||
|
||||
from ._version import __version__ as _wrapper_version
|
||||
from .config import (
|
||||
BINARY_SIGNING_PUBKEYS,
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
@@ -44,6 +45,17 @@ from .config import (
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
class BinaryVerificationError(RuntimeError):
|
||||
"""A downloaded binary could not be authenticated (bad/missing signature,
|
||||
version mismatch, or checksum failure).
|
||||
|
||||
Distinct from transient download/network errors: a verification failure is
|
||||
a tampering signal and MUST surface, never silently fall back to another
|
||||
binary. The Pro routing in ensure_binary re-raises this rather than
|
||||
downgrading to the free tier.
|
||||
"""
|
||||
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
||||
|
||||
@@ -70,11 +82,14 @@ def _show_welcome() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
def ensure_binary(license_key: str | None = None) -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
|
||||
Returns the path to the chrome executable as a string.
|
||||
|
||||
Args:
|
||||
license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var.
|
||||
|
||||
Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build.
|
||||
"""
|
||||
# Check for local override first
|
||||
@@ -88,6 +103,39 @@ def ensure_binary() -> str:
|
||||
logger.info("Using local binary override: %s", local_override)
|
||||
return str(path)
|
||||
|
||||
# Pro license key check (custom download URL overrides Pro path)
|
||||
from .license import resolve_license_key, validate_license
|
||||
|
||||
key = resolve_license_key(license_key)
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
key = None
|
||||
|
||||
if key:
|
||||
info = validate_license(key)
|
||||
if info and info.valid:
|
||||
# A valid license is entitled to Pro, so Pro failures surface loudly
|
||||
# rather than silently substituting the older free binary. (A blip
|
||||
# during a routine update never reaches here: _ensure_pro_binary
|
||||
# returns the cached Pro binary and updates in the background.)
|
||||
try:
|
||||
return _ensure_pro_binary(key)
|
||||
except BinaryVerificationError:
|
||||
# Authenticity could not be confirmed — surface verbatim.
|
||||
raise
|
||||
except Exception as e:
|
||||
# Transient failure with no cached Pro binary to use — surface a
|
||||
# clear error rather than silently downloading the free binary.
|
||||
raise RuntimeError(
|
||||
f"Pro binary unavailable: {e}. Your license is valid but the "
|
||||
f"Pro binary could not be downloaded right now. Retry in a "
|
||||
f"moment. To use the free binary instead, unset "
|
||||
f"CLOAKBROWSER_LICENSE_KEY."
|
||||
) from e
|
||||
elif info:
|
||||
logger.warning("License validation failed (plan=%s), using free tier", info.plan)
|
||||
else:
|
||||
logger.warning("License validation unavailable, using free tier")
|
||||
|
||||
# Fail fast if no binary available for this platform
|
||||
check_platform_available()
|
||||
|
||||
@@ -162,9 +210,11 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
)
|
||||
_download_file(fallback_url, tmp_path)
|
||||
|
||||
# Verify checksum before extraction
|
||||
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
|
||||
_verify_download_checksum(tmp_path, version)
|
||||
# Verify the download before extraction. On the official path this is a
|
||||
# mandatory, non-bypassable Ed25519 signature check (see
|
||||
# _verify_download_checksum); the skip flag only applies to custom
|
||||
# self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
|
||||
_verify_download_checksum(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
_show_welcome()
|
||||
@@ -173,23 +223,308 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _ensure_pro_binary(license_key: str) -> str:
|
||||
"""Ensure the Pro binary is downloaded and cached. Returns the binary path."""
|
||||
from .license import get_pro_latest_version
|
||||
|
||||
effective = get_effective_version(pro=True)
|
||||
binary_path = get_binary_path(effective, pro=True)
|
||||
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective)
|
||||
_show_welcome()
|
||||
_maybe_trigger_pro_update_check(license_key)
|
||||
return str(binary_path)
|
||||
|
||||
version = get_pro_latest_version()
|
||||
if not version:
|
||||
raise RuntimeError("Could not determine latest Pro version from server")
|
||||
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
|
||||
_show_welcome()
|
||||
return str(binary_path)
|
||||
|
||||
logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag())
|
||||
_download_pro_binary(version, license_key)
|
||||
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Pro download completed but binary not found at: {binary_path}"
|
||||
)
|
||||
|
||||
# Write Pro version marker (atomic)
|
||||
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
|
||||
try:
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
os.replace(str(tmp), str(marker))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_show_welcome()
|
||||
return str(binary_path)
|
||||
|
||||
|
||||
def _download_pro_binary(version: str, license_key: str) -> None:
|
||||
"""Download a Pro binary from cloakbrowser.dev with license key auth.
|
||||
|
||||
Requests the explicit version so the served archive matches the signed
|
||||
manifest verified in _verify_pro_download.
|
||||
"""
|
||||
download_url = f"{DOWNLOAD_BASE_URL}/api/download/{version}"
|
||||
binary_dir = get_binary_dir(version, pro=True)
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
platform_tag = get_platform_tag()
|
||||
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
_download_file(
|
||||
download_url,
|
||||
tmp_path,
|
||||
headers={
|
||||
"Authorization": f"Bearer {license_key}",
|
||||
"X-Platform": platform_tag,
|
||||
},
|
||||
)
|
||||
|
||||
# Pro binaries come from cloakbrowser.dev — the same origin as free
|
||||
# downloads — so the M1 attack the Ed25519 signature defends against
|
||||
# applies equally. Verify with the same non-bypassable signature check;
|
||||
# CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the
|
||||
# official free path).
|
||||
_verify_pro_download(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _verify_pro_download(file_path: Path, version: str) -> None:
|
||||
"""Verify a Pro archive with the same non-bypassable Ed25519 signature check
|
||||
as official free downloads.
|
||||
|
||||
Pro binaries are served from cloakbrowser.dev (same origin as the free
|
||||
tier), so a tampered same-origin SHA256SUMS could otherwise certify a
|
||||
tampered binary (M1, #308). Fetch the Pro SHA256SUMS + detached
|
||||
SHA256SUMS.sig, verify the signature against the pinned keys FIRST, bind the
|
||||
manifest to the requested version, then verify the archive's SHA-256.
|
||||
|
||||
An invalid signature, checksum, or version mismatch raises
|
||||
BinaryVerificationError (a tampering signal the router surfaces verbatim);
|
||||
CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
|
||||
transient — nothing was validated — and raises a plain RuntimeError. A
|
||||
valid-license user is never silently downgraded to the free binary.
|
||||
"""
|
||||
base = f"{DOWNLOAD_BASE_URL}/releases/pro/chromium-v{version}"
|
||||
try:
|
||||
manifest_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
manifest_resp.raise_for_status()
|
||||
sig_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
sig_resp.raise_for_status()
|
||||
except Exception as exc:
|
||||
# Fetch failure is transient, not tampering — raise a plain RuntimeError
|
||||
# (the router reports it as "unavailable, retry") rather than a
|
||||
# BinaryVerificationError (which it surfaces as a tampering signal).
|
||||
raise RuntimeError(
|
||||
f"Could not fetch the signed SHA256SUMS for Pro {version} ({exc})"
|
||||
)
|
||||
|
||||
manifest_bytes = manifest_resp.content
|
||||
# _verify_signature / _verify_checksum raise plain RuntimeError; convert to
|
||||
# BinaryVerificationError so the Pro router treats them as tampering signals
|
||||
# (re-raise) rather than transient failures (fall back to free).
|
||||
try:
|
||||
_verify_signature(manifest_bytes, sig_resp.content)
|
||||
except RuntimeError as exc:
|
||||
raise BinaryVerificationError(str(exc)) from exc
|
||||
manifest_text = manifest_bytes.decode("utf-8")
|
||||
|
||||
# Version binding: same forced-downgrade defense as the official path.
|
||||
declared = _parse_manifest_version(manifest_text)
|
||||
if declared != version:
|
||||
raise BinaryVerificationError(
|
||||
f"Version mismatch in signed Pro SHA256SUMS: requested {version}, "
|
||||
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
|
||||
)
|
||||
|
||||
tarball_name = get_archive_name()
|
||||
expected = _parse_checksums(manifest_text).get(tarball_name)
|
||||
if expected is None:
|
||||
raise BinaryVerificationError(
|
||||
f"Signature-verified Pro SHA256SUMS has no entry for {tarball_name} — "
|
||||
f"cannot confirm binary integrity."
|
||||
)
|
||||
try:
|
||||
_verify_checksum(file_path, expected)
|
||||
except RuntimeError as exc:
|
||||
raise BinaryVerificationError(str(exc)) from exc
|
||||
|
||||
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
|
||||
checksums = _fetch_checksums(version)
|
||||
"""Verify the downloaded archive's integrity and authenticity.
|
||||
|
||||
Official path (cloakbrowser.dev / GitHub Releases): fetch SHA256SUMS plus
|
||||
its detached Ed25519 signature SHA256SUMS.sig, verify the signature against
|
||||
the pinned public keys FIRST, then verify the archive's SHA-256 against the
|
||||
now-authenticated manifest. Mandatory and non-bypassable — a same-origin
|
||||
manifest can no longer certify a tampered binary (#308).
|
||||
|
||||
Custom self-hosted path (CLOAKBROWSER_DOWNLOAD_URL set): the pinned keys do
|
||||
not apply to a third-party server, so fall back to the plain same-origin
|
||||
SHA256SUMS check, which CLOAKBROWSER_SKIP_CHECKSUM may bypass.
|
||||
"""
|
||||
tarball_name = get_archive_name()
|
||||
|
||||
if checksums is None:
|
||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
# Self-hosted mirror: signature scheme does not apply. Preserve the
|
||||
# legacy same-origin checksum behavior, skippable as before.
|
||||
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() == "true":
|
||||
logger.warning(
|
||||
"CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
|
||||
)
|
||||
return
|
||||
checksums = _fetch_checksums(version)
|
||||
if checksums is None:
|
||||
logger.warning(
|
||||
"SHA256SUMS not available from custom URL — skipping checksum verification"
|
||||
)
|
||||
return
|
||||
expected = checksums.get(tarball_name)
|
||||
if expected is None:
|
||||
logger.warning(
|
||||
"SHA256SUMS found but no entry for %s — skipping verification", tarball_name
|
||||
)
|
||||
return
|
||||
_verify_checksum(file_path, expected)
|
||||
return
|
||||
|
||||
# Official path: signature is the trust root and is non-bypassable.
|
||||
manifest = _fetch_signed_manifest(version)
|
||||
if manifest is None:
|
||||
raise RuntimeError(
|
||||
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) "
|
||||
"for this release — refusing to use an unverified binary. "
|
||||
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
manifest_bytes, sig_bytes = manifest
|
||||
_verify_signature(manifest_bytes, sig_bytes)
|
||||
manifest_text = manifest_bytes.decode("utf-8")
|
||||
|
||||
# Version binding: the signed manifest must declare the version we asked for.
|
||||
# The signature proves "we made this manifest", not "this is the version you
|
||||
# requested" — without this check a mirror could serve a genuinely-signed
|
||||
# older release in place of the requested one (forced downgrade).
|
||||
requested = version or get_chromium_version()
|
||||
declared = _parse_manifest_version(manifest_text)
|
||||
if declared != requested:
|
||||
raise RuntimeError(
|
||||
f"Version mismatch in signed SHA256SUMS: requested {requested}, "
|
||||
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
|
||||
)
|
||||
|
||||
checksums = _parse_checksums(manifest_text)
|
||||
expected = checksums.get(tarball_name)
|
||||
if expected is None:
|
||||
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
|
||||
return
|
||||
|
||||
raise RuntimeError(
|
||||
f"Signature-verified SHA256SUMS has no entry for {tarball_name} — "
|
||||
f"cannot confirm binary integrity."
|
||||
)
|
||||
_verify_checksum(file_path, expected)
|
||||
|
||||
|
||||
def _parse_manifest_version(text: str) -> str | None:
|
||||
"""Read the 'version=<v>' line from a signed manifest. None if absent.
|
||||
|
||||
The line has no internal whitespace so older wrappers' SHA256SUMS parsers
|
||||
ignore it (they only accept '<hash> <filename>' lines).
|
||||
"""
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("version="):
|
||||
return line[len("version="):].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_signed_manifest(version: str | None = None) -> tuple[bytes, bytes] | None:
|
||||
"""Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or None.
|
||||
|
||||
Both files are fetched from the SAME origin so the signature always matches
|
||||
the exact manifest bytes it certifies. The primary origin is tried first,
|
||||
then the GitHub Releases mirror. follow_redirects mirrors _fetch_checksums:
|
||||
cloakbrowser.dev 301-redirects /chromium-v* to GitHub Releases.
|
||||
"""
|
||||
v = version or get_chromium_version()
|
||||
bases = [
|
||||
f"{DOWNLOAD_BASE_URL}/chromium-v{v}",
|
||||
f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}",
|
||||
]
|
||||
for base in bases:
|
||||
try:
|
||||
manifest_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
manifest_resp.raise_for_status()
|
||||
sig_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
sig_resp.raise_for_status()
|
||||
return manifest_resp.content, sig_resp.content
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _verify_signature(manifest_bytes: bytes, sig_b64: bytes) -> None:
|
||||
"""Verify a detached Ed25519 signature over the raw manifest bytes.
|
||||
|
||||
sig_b64 is the base64 of the 64-byte raw signature. Tries each pinned key
|
||||
in BINARY_SIGNING_PUBKEYS; succeeds if any validates. Raises RuntimeError
|
||||
if the signature is malformed or no pinned key validates it.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
try:
|
||||
signature = base64.b64decode(sig_b64.strip(), validate=True)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Malformed SHA256SUMS.sig (not valid base64): {exc}")
|
||||
|
||||
for pubkey_b64 in BINARY_SIGNING_PUBKEYS:
|
||||
try:
|
||||
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64))
|
||||
except Exception:
|
||||
# Skip an unparseable pinned key (e.g. the placeholder) rather than
|
||||
# aborting — another pinned key may still validate.
|
||||
continue
|
||||
try:
|
||||
pub.verify(signature, manifest_bytes)
|
||||
logger.info("SHA256SUMS signature verified: Ed25519 OK")
|
||||
return
|
||||
except Exception:
|
||||
# InvalidSignature, or a malformed/wrong-length signature that makes
|
||||
# verify raise something else — either way this key didn't match,
|
||||
# so try the next pinned key (and ultimately fail closed below).
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"SHA256SUMS signature verification failed — no pinned key validated the "
|
||||
"manifest. The binary's authenticity could not be confirmed. "
|
||||
"Report at https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
|
||||
|
||||
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
||||
v = version or get_chromium_version()
|
||||
@@ -211,17 +546,22 @@ def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||
|
||||
|
||||
def _parse_checksums(text: str) -> dict[str, str]:
|
||||
"""Parse SHA256SUMS format: 'hash filename' per line."""
|
||||
"""Parse SHA256SUMS format: '<64-hex sha256> filename' per line.
|
||||
|
||||
Only lines whose first token is a 64-character hex digest are accepted
|
||||
(matches the JS parser); blank lines, the version= line, and any other
|
||||
junk are ignored.
|
||||
"""
|
||||
result = {}
|
||||
for line in text.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
parts = line.strip().split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
hash_val, filename = parts
|
||||
filename = filename.lstrip("*")
|
||||
result[filename] = hash_val.lower()
|
||||
hash_val, filename = parts
|
||||
hash_val = hash_val.lower()
|
||||
if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val):
|
||||
continue
|
||||
result[filename.lstrip("*")] = hash_val
|
||||
return result
|
||||
|
||||
|
||||
@@ -243,11 +583,11 @@ def _verify_checksum(file_path: Path, expected_hash: str) -> None:
|
||||
logger.info("Checksum verified: SHA-256 OK")
|
||||
|
||||
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None) -> None:
|
||||
"""Download a file with progress logging."""
|
||||
logger.info("Downloading from %s", url)
|
||||
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT, headers=headers or {}) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
total = int(response.headers.get("content-length", 0))
|
||||
@@ -401,17 +741,34 @@ def clear_cache() -> None:
|
||||
|
||||
|
||||
def binary_info() -> dict:
|
||||
"""Return info about the current binary installation."""
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
"""Return info about the current binary installation.
|
||||
|
||||
tier reflects what is actually installed on disk, not merely whether a
|
||||
license is cached — a cached license with no Pro binary downloaded yet is
|
||||
still effectively running the free binary, and the active key may differ
|
||||
from the cached one.
|
||||
"""
|
||||
# Prefer Pro only if a Pro binary actually exists on disk.
|
||||
pro_version = get_effective_version(pro=True)
|
||||
pro_path = get_binary_path(pro_version, pro=True)
|
||||
pro = pro_path.exists() and _is_executable(pro_path)
|
||||
|
||||
if pro:
|
||||
effective = pro_version
|
||||
binary_path = pro_path
|
||||
else:
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
download_url = f"{DOWNLOAD_BASE_URL}/api/download/latest" if pro else get_download_url(effective)
|
||||
return {
|
||||
"version": effective,
|
||||
"tier": "pro" if pro else "free",
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"platform": get_platform_tag(),
|
||||
"binary_path": str(binary_path),
|
||||
"installed": binary_path.exists(),
|
||||
"cache_dir": str(get_binary_dir(effective)),
|
||||
"download_url": get_download_url(effective),
|
||||
"cache_dir": str(get_binary_dir(effective, pro=pro)),
|
||||
"download_url": download_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -576,3 +933,43 @@ def _maybe_trigger_update_check() -> None:
|
||||
return
|
||||
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
def _maybe_trigger_pro_update_check(license_key: str) -> None:
|
||||
"""Fire-and-forget Pro binary update check in a daemon thread."""
|
||||
check_file = get_cache_dir() / ".last_pro_update_check"
|
||||
if check_file.exists():
|
||||
try:
|
||||
last_check = float(check_file.read_text().strip())
|
||||
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
|
||||
return
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
def _check():
|
||||
try:
|
||||
from .license import get_pro_latest_version
|
||||
|
||||
check_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
check_file.write_text(str(time.time()))
|
||||
|
||||
latest = get_pro_latest_version()
|
||||
if not latest:
|
||||
return
|
||||
|
||||
if get_binary_path(latest, pro=True).exists():
|
||||
return
|
||||
|
||||
logger.info("Newer Pro binary available: %s. Downloading in background...", latest)
|
||||
_download_pro_binary(latest, license_key)
|
||||
|
||||
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(latest)
|
||||
os.replace(str(tmp), str(marker))
|
||||
logger.info("Pro background update complete: %s ready. Will use on next launch.", latest)
|
||||
except Exception:
|
||||
logger.debug("Pro background update failed", exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_check, daemon=True)
|
||||
t.start()
|
||||
|
||||
+452
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (sync).
|
||||
|
||||
Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error hierarchy — all subclass RuntimeError for backward compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActionabilityError(RuntimeError):
|
||||
"""Base for all actionability failures."""
|
||||
|
||||
def __init__(self, selector: str, check: str, message: str):
|
||||
self.selector = selector
|
||||
self.check = check
|
||||
super().__init__(f"Element {selector!r} failed {check} check: {message}")
|
||||
|
||||
|
||||
class ElementNotAttachedError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "attached", "element not found in DOM")
|
||||
|
||||
|
||||
class ElementNotVisibleError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "visible", "element is not visible")
|
||||
|
||||
|
||||
class ElementNotStableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "stable", "element position is still changing")
|
||||
|
||||
|
||||
class ElementNotEnabledError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "enabled", "element is disabled")
|
||||
|
||||
|
||||
class ElementNotEditableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "editable", "element is not editable")
|
||||
|
||||
|
||||
class ElementNotReceivingEventsError(ActionabilityError):
|
||||
def __init__(self, selector: str, covering_tag: str = "unknown"):
|
||||
super().__init__(
|
||||
selector,
|
||||
"pointer_events",
|
||||
f"element is covered by <{covering_tag}>",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-set constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHECKS_CLICK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
CHECKS_HOVER: FrozenSet[str] = frozenset({"attached", "visible", "pointer_events"})
|
||||
CHECKS_INPUT: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "editable", "pointer_events"})
|
||||
CHECKS_FOCUS: FrozenSet[str] = frozenset({"attached", "visible", "enabled"})
|
||||
CHECKS_CHECK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
|
||||
_BACKOFF_MS = [100, 250, 500, 1000]
|
||||
|
||||
|
||||
def _backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
time.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability: attached, visible, enabled, editable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Wait for element to pass actionability checks (pre-scroll).
|
||||
|
||||
Retries with backoff until *timeout* ms elapsed.
|
||||
Raises a specific ``ActionabilityError`` subclass on failure.
|
||||
If *force* is True, returns immediately.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
|
||||
if "attached" in checks:
|
||||
try:
|
||||
loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if "visible" in checks:
|
||||
if not loc.is_visible():
|
||||
raise ElementNotVisibleError(selector)
|
||||
|
||||
if "enabled" in checks:
|
||||
if not loc.is_enabled():
|
||||
raise ElementNotEnabledError(selector)
|
||||
|
||||
if "editable" in checks:
|
||||
if not loc.is_editable():
|
||||
raise ElementNotEditableError(selector)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _boxes_differ(a: dict, b: dict) -> bool:
|
||||
return (
|
||||
abs(a["x"] - b["x"]) > 1
|
||||
or abs(a["y"] - b["y"]) > 1
|
||||
or abs(a["width"] - b["width"]) > 1
|
||||
or abs(a["height"] - b["height"]) > 1
|
||||
)
|
||||
|
||||
|
||||
def ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Wait for element position to stabilize (two samples 100ms apart).
|
||||
|
||||
Only call after scroll — skip if element was already in viewport.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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);
|
||||
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; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
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);
|
||||
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; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
|
||||
def check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Check that elementFromPoint(x, y) hits the expected element.
|
||||
|
||||
Uses locator.evaluate() so all Playwright selector types work
|
||||
(text=, role=, XPath, CSS, etc.). Retries with backoff for transient overlays.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
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})
|
||||
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):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Actionability checks for ElementHandle (no selector needed).
|
||||
|
||||
Uses Playwright's wait_for_element_state where available.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
def check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Pointer-events check for ElementHandle."""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
box = el.bounding_box()
|
||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
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):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (async).
|
||||
|
||||
Async mirror of actionability.py — same logic, uses asyncio.sleep and await.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .actionability import (
|
||||
ActionabilityError,
|
||||
ElementNotAttachedError,
|
||||
ElementNotVisibleError,
|
||||
ElementNotStableError,
|
||||
ElementNotEnabledError,
|
||||
ElementNotEditableError,
|
||||
ElementNotReceivingEventsError,
|
||||
_BACKOFF_MS,
|
||||
_boxes_differ,
|
||||
_POINTER_EVENTS_LOCATOR_JS,
|
||||
_POINTER_EVENTS_HANDLE_JS,
|
||||
)
|
||||
|
||||
|
||||
async def _async_backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
await asyncio.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
|
||||
if "attached" in checks:
|
||||
try:
|
||||
await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if "visible" in checks:
|
||||
if not await loc.is_visible():
|
||||
raise ElementNotVisibleError(selector)
|
||||
|
||||
if "enabled" in checks:
|
||||
if not await loc.is_enabled():
|
||||
raise ElementNotEnabledError(selector)
|
||||
|
||||
if "editable" in checks:
|
||||
if not await loc.is_editable():
|
||||
raise ElementNotEditableError(selector)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pointer-events check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
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})
|
||||
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):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
async def async_check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
box = await el.bounding_box()
|
||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
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):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -26,7 +26,7 @@ def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Option
|
||||
"""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=timeout)
|
||||
return el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -50,7 +50,7 @@ def human_scroll_into_view(
|
||||
get_box: Callable[[], Optional[dict]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||
instead of a CSS selector.
|
||||
|
||||
@@ -58,6 +58,9 @@ def human_scroll_into_view(
|
||||
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||
behavior runs everywhere.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
@@ -71,7 +74,7 @@ def human_scroll_into_view(
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
@@ -139,7 +142,7 @@ def human_scroll_into_view(
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
@@ -149,12 +152,14 @@ def scroll_to_element(
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll.
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
return human_scroll_into_view(
|
||||
page, raw,
|
||||
|
||||
@@ -23,7 +23,7 @@ async def _get_element_box_async(
|
||||
elements (#172)."""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=timeout)
|
||||
return await el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -47,13 +47,16 @@ async def async_human_scroll_into_view(
|
||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||
|
||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||
\u2192 overshoot behavior.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
@@ -67,7 +70,7 @@ async def async_human_scroll_into_view(
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
@@ -135,7 +138,7 @@ async def async_human_scroll_into_view(
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
@@ -145,12 +148,14 @@ async def async_scroll_to_element(
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll (async).
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
async def _get():
|
||||
return await _get_element_box_async(page, selector, timeout)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""License validation and caching for CloakBrowser Pro.
|
||||
|
||||
Handles license key resolution, server validation with local caching,
|
||||
and Pro version checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_cache_dir
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"
|
||||
PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"
|
||||
|
||||
LICENSE_CACHE_TTL = 86400 # 24 hours
|
||||
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class LicenseInfo:
|
||||
valid: bool
|
||||
plan: str
|
||||
expires: str | None
|
||||
|
||||
|
||||
def resolve_license_key(license_key: str | None = None) -> str | None:
|
||||
"""Resolve the license key: explicit param > env var > file > None."""
|
||||
if license_key and license_key.strip():
|
||||
return license_key.strip()
|
||||
env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
key_file = get_cache_dir() / "license.key"
|
||||
try:
|
||||
content = key_file.read_text().strip()
|
||||
if content:
|
||||
return content
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def validate_license(license_key: str) -> LicenseInfo | None:
|
||||
"""Validate a license key with the CloakBrowser server.
|
||||
|
||||
Checks a local file cache first (24h TTL). Falls back to stale
|
||||
cache if the server is unreachable.
|
||||
|
||||
Returns LicenseInfo if validation succeeded, None on total failure.
|
||||
"""
|
||||
cache_path = get_cache_dir() / ".license_cache"
|
||||
key_sha = hashlib.sha256(license_key.encode()).hexdigest()
|
||||
|
||||
cached = _read_cache(cache_path, key_sha)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
VALIDATE_URL,
|
||||
json={"license_key": license_key},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
info = LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=data.get("expires"),
|
||||
)
|
||||
|
||||
if info.valid:
|
||||
_write_cache(cache_path, key_sha, info)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("License validation request failed: %s", e)
|
||||
|
||||
stale = _read_cache(cache_path, key_sha, ignore_ttl=True)
|
||||
if stale:
|
||||
logger.warning("Using cached license validation (server unreachable)")
|
||||
return stale
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_pro_latest_version() -> str | None:
|
||||
"""Get the latest Pro binary version from the server.
|
||||
|
||||
Rate-limited to 1 call per hour via a marker file.
|
||||
"""
|
||||
marker = get_cache_dir() / ".last_pro_version_check"
|
||||
|
||||
if marker.exists():
|
||||
try:
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
if age < PRO_VERSION_CHECK_INTERVAL:
|
||||
content = marker.read_text().strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = httpx.get(PRO_VERSION_URL, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
version = resp.json().get("version")
|
||||
if not version:
|
||||
return None
|
||||
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
os.replace(str(tmp), str(marker))
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Pro version check failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _read_cache(
|
||||
cache_path: Path, key_sha: str, ignore_ttl: bool = False
|
||||
) -> LicenseInfo | None:
|
||||
"""Read cached license validation if it exists and is fresh."""
|
||||
try:
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
data = json.loads(cache_path.read_text())
|
||||
|
||||
if data.get("key_sha256") != key_sha:
|
||||
return None
|
||||
|
||||
if not ignore_ttl:
|
||||
validated_at = data.get("validated_at", 0)
|
||||
if time.time() - validated_at > LICENSE_CACHE_TTL:
|
||||
return None
|
||||
|
||||
expires = data.get("expires")
|
||||
if expires:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
exp_dt = datetime.fromisoformat(expires)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
if exp_dt < datetime.now(timezone.utc):
|
||||
return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=expires,
|
||||
)
|
||||
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
||||
# TypeError: a corrupted cache with a non-numeric validated_at. Treat any
|
||||
# unreadable cache as absent rather than crashing the caller.
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None:
|
||||
"""Write license validation result to local cache (atomic via tmp+rename)."""
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = cache_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": info.valid,
|
||||
"plan": info.plan,
|
||||
"expires": info.expires,
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
os.replace(str(tmp_path), str(cache_path))
|
||||
except OSError as e:
|
||||
logger.debug("Failed to write license cache: %s", e)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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 |
|
||||
| `url` | str | required — `http://` and `https://` only |
|
||||
| `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,7 +79,6 @@ 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
|
||||
|
||||
@@ -102,8 +101,6 @@ 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
|
||||
@@ -118,7 +115,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 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 internal Chromium args / page-load budgets.
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
@@ -176,6 +173,22 @@ 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
|
||||
url str required, the page to scrape (http/https only)
|
||||
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,7 +14,6 @@ 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"
|
||||
@@ -35,8 +34,6 @@ 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:
|
||||
@@ -64,11 +61,14 @@ 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,6 +76,26 @@ 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
|
||||
@@ -118,7 +138,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("extra_args", []),
|
||||
*event.get("_strategy_args", []),
|
||||
],
|
||||
}
|
||||
for key in ("proxy", "humanize", "human_preset", "geoip",
|
||||
@@ -159,7 +179,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_for_function", "wait_ms",
|
||||
"wait_for_load_state", "wait_for_selector", "wait_ms",
|
||||
)
|
||||
|
||||
|
||||
@@ -184,11 +204,6 @@ 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"])
|
||||
|
||||
@@ -235,7 +250,7 @@ def _classify_error(err: Exception) -> dict | None:
|
||||
msg = str(err)
|
||||
if "ERR_CERT" in msg:
|
||||
return {
|
||||
"extra_args": ["--ignore-certificate-errors"],
|
||||
"_strategy_args": ["--ignore-certificate-errors"],
|
||||
"goto_timeout_ms": 60000,
|
||||
}
|
||||
if ("Timeout" in msg and "exceeded" in msg) or "ERR_CONNECTION_TIMED_OUT" in msg:
|
||||
@@ -263,8 +278,10 @@ 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(),
|
||||
@@ -306,6 +323,8 @@ 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
|
||||
@@ -326,8 +345,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("extra_args", [])) + list(strategy.get("extra_args", []))
|
||||
current_event = {**current_event, **strategy, "extra_args": merged_args}
|
||||
merged_args = list(current_event.get("_strategy_args", [])) + list(strategy.get("_strategy_args", []))
|
||||
current_event = {**current_event, **strategy, "_strategy_args": merged_args}
|
||||
retries_left -= 1
|
||||
# No backoff: strategy overrides change goto budget directly;
|
||||
# the prior failure was either fast (cert reject) or already
|
||||
|
||||
+12
-11
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
|
||||
Default Playwright typically scores 0.1-0.3.
|
||||
"""
|
||||
|
||||
import time
|
||||
import re
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
@@ -13,19 +13,20 @@ print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
# Google's official reCAPTCHA v3 demo
|
||||
# Google's official reCAPTCHA v3 demo — scores automatically on page load.
|
||||
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Click to trigger reCAPTCHA scoring
|
||||
button = page.query_selector("button")
|
||||
if button:
|
||||
button.click()
|
||||
time.sleep(3)
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Extract score from page
|
||||
content = page.content()
|
||||
print("Page loaded. Check the score in the response.")
|
||||
# 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'}")
|
||||
print(f"URL: {page.url}")
|
||||
|
||||
# Take screenshot as proof
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
{
|
||||
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";
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
+61
-10
@@ -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.**
|
||||
|
||||
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||
- **58 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,11 +39,24 @@ import { launch } from 'cloakbrowser';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.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.
|
||||
@@ -53,7 +66,7 @@ import { launch } from 'cloakbrowser/puppeteer';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.com');
|
||||
console.log(await page.title());
|
||||
await browser.close();
|
||||
```
|
||||
@@ -189,6 +202,18 @@ 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
|
||||
|
||||
@@ -206,11 +231,30 @@ const page = await browser.newPage();
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 48 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 48 | ✅ Latest |
|
||||
| Linux x86_64 | 146 | 58 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
|
||||
| Windows x86_64 | 146 | 58 | ✅ Latest |
|
||||
|
||||
## CloakBrowser Pro
|
||||
|
||||
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
|
||||
free-release model:
|
||||
|
||||
- **Free (v146)** — free forever on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Unlimited sessions. Works today, goes stale as detection evolves.
|
||||
- **Pro (latest, v148)** — the newest patches and Chromium upgrades first, so the [test results](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
|
||||
|
||||
Anti-bot detection updates constantly — an older binary degrades within weeks.
|
||||
Pro keeps you on the build that's actively maintained against it.
|
||||
|
||||
Activate with your license key (env var, `licenseKey` option, or `~/.cloakbrowser/license.key`):
|
||||
|
||||
```bash
|
||||
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
|
||||
```
|
||||
|
||||
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -230,6 +274,13 @@ 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.
|
||||
@@ -262,13 +313,13 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-146.0.7680.177.4/chrome
|
||||
|
||||
# macOS
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
|
||||
|
||||
# Windows
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-146.0.7680.177.4\chrome.exe
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
Generated
+222
-613
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.28",
|
||||
"version": "0.4.0",
|
||||
"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": "^20.10.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "1.60",
|
||||
"puppeteer-core": "^25.0.4",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.53.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
+12
-1
@@ -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,5 +55,16 @@ 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()];
|
||||
}
|
||||
|
||||
+51
-15
@@ -27,16 +27,29 @@ 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.3";
|
||||
export const CHROMIUM_VERSION = "146.0.7680.177.5";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "146.0.7680.177.3",
|
||||
"linux-x64": "146.0.7680.177.5",
|
||||
"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.4",
|
||||
"windows-x64": "146.0.7680.177.5",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 public keys for verifying downloaded binaries.
|
||||
//
|
||||
// Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
|
||||
// The wrapper verifies that signature against the keys below before trusting
|
||||
// any hash in the manifest, so the download origin alone cannot certify a
|
||||
// tampered binary. Values are base64 of the 32-byte raw public key. Multiple
|
||||
// entries are accepted to allow key rotation. Keep in parity with config.py.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const BINARY_SIGNING_PUBKEYS: string[] = [
|
||||
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,12 +99,13 @@ export function getCacheDir(): string {
|
||||
return path.join(os.homedir(), ".cloakbrowser");
|
||||
}
|
||||
|
||||
export function getBinaryDir(version?: string): string {
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
|
||||
export function getBinaryDir(version?: string, pro = false): string {
|
||||
const suffix = pro ? "-pro" : "";
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}${suffix}`);
|
||||
}
|
||||
|
||||
export function getBinaryPath(version?: string): string {
|
||||
const binaryDir = getBinaryDir(version);
|
||||
export function getBinaryPath(version?: string, pro = false): string {
|
||||
const binaryDir = getBinaryDir(version, pro);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
@@ -145,10 +159,29 @@ export function getFallbackDownloadUrl(version?: string): string {
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
export function getEffectiveVersion(pro = false): string {
|
||||
const base = getChromiumVersion();
|
||||
const cacheDir = getCacheDir();
|
||||
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
|
||||
if (pro) {
|
||||
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const version = fs.readFileSync(marker, "utf-8").trim();
|
||||
if (version) {
|
||||
const binary = getBinaryPath(version, true);
|
||||
if (fs.existsSync(binary)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Free tier: try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
|
||||
const marker = path.join(cacheDir, name);
|
||||
try {
|
||||
@@ -200,9 +233,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default stealth arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
// Default viewport — used for HEADLESS only (headed launches use no viewport so
|
||||
// the page tracks the real window). Headless has no window chrome, so a fixed
|
||||
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||
|
||||
export function getDefaultStealthArgs(): string[] {
|
||||
@@ -219,8 +254,9 @@ export function getDefaultStealthArgs(): string[] {
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
}
|
||||
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
// Linux/Windows: spoof as Windows desktop.
|
||||
// Screen and window size come from the real display, not this flag (verified:
|
||||
// identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
// headed mode — that would break outerWidth >= innerWidth coherence.
|
||||
return [...base, "--fingerprint-platform=windows"];
|
||||
}
|
||||
|
||||
+439
-21
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
@@ -14,6 +14,8 @@ import { extract as tarExtract } from "tar";
|
||||
|
||||
import type { BinaryInfo } from "./types.js";
|
||||
import {
|
||||
BINARY_SIGNING_PUBKEYS,
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
@@ -32,10 +34,25 @@ import {
|
||||
getPlatformTag,
|
||||
versionNewer,
|
||||
} from "./config.js";
|
||||
import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js";
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
|
||||
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/**
|
||||
* A downloaded binary could not be authenticated (bad/missing signature,
|
||||
* version mismatch, or checksum failure). Distinct from transient
|
||||
* download/network errors: a verification failure is a tampering signal and
|
||||
* MUST surface, never silently fall back to another binary. The Pro routing in
|
||||
* ensureBinary re-throws this rather than downgrading to the free tier.
|
||||
*/
|
||||
export class BinaryVerificationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BinaryVerificationError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,7 +61,7 @@ const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
* Ensure the stealth Chromium binary is available. Download if needed.
|
||||
* Returns the path to the chrome executable.
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
export async function ensureBinary(licenseKey?: string): Promise<string> {
|
||||
// Check for local override
|
||||
const localOverride = getLocalBinaryOverride();
|
||||
if (localOverride) {
|
||||
@@ -57,6 +74,37 @@ export async function ensureBinary(): Promise<string> {
|
||||
return localOverride;
|
||||
}
|
||||
|
||||
// Pro license key check (custom download URL overrides Pro path)
|
||||
const key = resolveLicenseKey(licenseKey);
|
||||
const effectiveKey = process.env.CLOAKBROWSER_DOWNLOAD_URL ? undefined : key;
|
||||
if (effectiveKey) {
|
||||
const info = await validateLicense(effectiveKey);
|
||||
if (info?.valid) {
|
||||
// A valid license is entitled to Pro, so Pro failures surface loudly
|
||||
// rather than silently substituting the older free binary. (A blip during
|
||||
// a routine update never reaches here: ensureProBinary returns the cached
|
||||
// Pro binary and updates in the background.)
|
||||
try {
|
||||
return await ensureProBinary(effectiveKey);
|
||||
} catch (e) {
|
||||
// Authenticity could not be confirmed — surface verbatim.
|
||||
if (e instanceof BinaryVerificationError) throw e;
|
||||
// Transient failure with no cached Pro binary to use — surface a clear
|
||||
// error rather than silently downloading the free binary.
|
||||
throw new Error(
|
||||
`Pro binary unavailable: ${e}. Your license is valid but the Pro ` +
|
||||
`binary could not be downloaded right now. Retry in a moment. To use ` +
|
||||
`the free binary instead, unset CLOAKBROWSER_LICENSE_KEY.`,
|
||||
{ cause: e }
|
||||
);
|
||||
}
|
||||
} else if (info) {
|
||||
console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`);
|
||||
} else {
|
||||
console.log("[cloakbrowser] License validation unavailable, using free tier");
|
||||
}
|
||||
}
|
||||
|
||||
// Fail fast if no binary available for this platform
|
||||
checkPlatformAvailable();
|
||||
|
||||
@@ -108,17 +156,31 @@ export function clearCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return info about the current binary installation. */
|
||||
/**
|
||||
* Return info about the current binary installation.
|
||||
*
|
||||
* tier reflects what is actually installed on disk, not merely whether a license
|
||||
* is cached — a cached license with no Pro binary downloaded yet is still
|
||||
* effectively running the free binary, and the active key may differ from the
|
||||
* cached one.
|
||||
*/
|
||||
export function binaryInfo(): BinaryInfo {
|
||||
const effective = getEffectiveVersion();
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
// Prefer Pro only if a Pro binary actually exists on disk.
|
||||
const proVersion = getEffectiveVersion(true);
|
||||
const proPath = getBinaryPath(proVersion, true);
|
||||
const isPro = fs.existsSync(proPath) && isExecutable(proPath);
|
||||
|
||||
const effective = isPro ? proVersion : getEffectiveVersion(false);
|
||||
const binaryPath = isPro ? proPath : getBinaryPath(effective, false);
|
||||
return {
|
||||
version: effective,
|
||||
bundledVersion: CHROMIUM_VERSION,
|
||||
tier: isPro ? "pro" : "free",
|
||||
platform: getPlatformTag(),
|
||||
binaryPath,
|
||||
installed: fs.existsSync(binaryPath),
|
||||
cacheDir: getBinaryDir(effective),
|
||||
downloadUrl: getDownloadUrl(effective),
|
||||
cacheDir: getBinaryDir(effective, isPro),
|
||||
downloadUrl: isPro ? `${DOWNLOAD_BASE_URL}/api/download/latest` : getDownloadUrl(effective),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,10 +257,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
await downloadFile(fallbackUrl, tmpPath);
|
||||
}
|
||||
|
||||
// Verify checksum before extraction
|
||||
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
|
||||
await verifyDownloadChecksum(tmpPath, version);
|
||||
}
|
||||
// Verify the download before extraction. On the official path this is a
|
||||
// mandatory, non-bypassable Ed25519 signature check (see
|
||||
// verifyDownloadChecksum); the skip flag only applies to custom
|
||||
// self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
|
||||
await verifyDownloadChecksum(tmpPath, version);
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
showWelcome();
|
||||
@@ -210,22 +273,176 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const checksums = await fetchChecksums(version);
|
||||
/** @internal Exported for testing only. */
|
||||
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const tarballName = getArchiveName();
|
||||
|
||||
if (!checksums) {
|
||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
||||
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
|
||||
// Self-hosted mirror: the pinned signature keys do not apply to a
|
||||
// third-party server. Preserve the legacy same-origin checksum behavior,
|
||||
// skippable via CLOAKBROWSER_SKIP_CHECKSUM.
|
||||
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() === "true") {
|
||||
console.warn(
|
||||
"[cloakbrowser] CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const checksums = await fetchChecksums(version);
|
||||
if (!checksums) {
|
||||
console.warn(
|
||||
"[cloakbrowser] SHA256SUMS not available from custom URL — skipping checksum verification"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const expectedCustom = checksums.get(tarballName);
|
||||
if (!expectedCustom) {
|
||||
console.warn(
|
||||
`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await verifyChecksum(filePath, expectedCustom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Official path: signature is the trust root and is non-bypassable.
|
||||
const manifest = await fetchSignedManifest(version);
|
||||
if (!manifest) {
|
||||
throw new Error(
|
||||
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) for " +
|
||||
"this release — refusing to use an unverified binary. " +
|
||||
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
);
|
||||
}
|
||||
const { manifestBytes, sigBytes } = manifest;
|
||||
verifySignature(manifestBytes, sigBytes);
|
||||
const manifestText = new TextDecoder().decode(manifestBytes);
|
||||
|
||||
// Version binding: the signed manifest must declare the version we asked for.
|
||||
// The signature proves "we made this manifest", not "this is the version you
|
||||
// requested" — without this check a mirror could serve a genuinely-signed
|
||||
// older release in place of the requested one (forced downgrade).
|
||||
const requested = version || getChromiumVersion();
|
||||
const declared = parseManifestVersion(manifestText);
|
||||
if (declared !== requested) {
|
||||
throw new Error(
|
||||
`Version mismatch in signed SHA256SUMS: requested ${requested}, ` +
|
||||
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
|
||||
);
|
||||
}
|
||||
|
||||
const checksums = parseChecksums(manifestText);
|
||||
const expected = checksums.get(tarballName);
|
||||
if (!expected) {
|
||||
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
|
||||
return;
|
||||
throw new Error(
|
||||
`Signature-verified SHA256SUMS has no entry for ${tarballName} — ` +
|
||||
`cannot confirm binary integrity.`
|
||||
);
|
||||
}
|
||||
await verifyChecksum(filePath, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the 'version=<v>' line from a signed manifest. null if absent.
|
||||
* The line has no internal whitespace so older wrappers' SHA256SUMS parsers
|
||||
* ignore it (they only accept '<hash> <filename>' lines).
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export function parseManifestVersion(text: string): string | null {
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith("version=")) {
|
||||
return line.slice("version=".length).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or null.
|
||||
* Both files come from the SAME origin so the signature always matches the
|
||||
* exact manifest bytes it certifies. Primary origin first, then GitHub mirror.
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export async function fetchSignedManifest(
|
||||
version?: string
|
||||
): Promise<{ manifestBytes: Uint8Array; sigBytes: Uint8Array } | null> {
|
||||
const v = version || getChromiumVersion();
|
||||
const bases = [
|
||||
`${DOWNLOAD_BASE_URL}/chromium-v${v}`,
|
||||
`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}`,
|
||||
];
|
||||
for (const base of bases) {
|
||||
try {
|
||||
const manifestResp = await fetch(`${base}/SHA256SUMS`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!manifestResp.ok) continue;
|
||||
const sigResp = await fetch(`${base}/SHA256SUMS.sig`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!sigResp.ok) continue;
|
||||
return {
|
||||
manifestBytes: new Uint8Array(await manifestResp.arrayBuffer()),
|
||||
sigBytes: new Uint8Array(await sigResp.arrayBuffer()),
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a detached Ed25519 signature over the raw manifest bytes.
|
||||
* sigB64Bytes is the (base64-text) content of SHA256SUMS.sig. Tries each pinned
|
||||
* key; succeeds if any validates. Throws if malformed or no key validates.
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export function verifySignature(manifestBytes: Uint8Array, sigB64Bytes: Uint8Array): void {
|
||||
// Node's Buffer.from(...,"base64") is lenient — it silently drops invalid
|
||||
// characters instead of throwing. Validate by canonical round-trip so a
|
||||
// malformed .sig is reported as such (parity with Python's
|
||||
// base64.b64decode(validate=True)).
|
||||
const sigText = new TextDecoder().decode(sigB64Bytes).trim();
|
||||
const signature = Buffer.from(sigText, "base64");
|
||||
if (signature.toString("base64") !== sigText) {
|
||||
throw new Error("Malformed SHA256SUMS.sig (not valid base64)");
|
||||
}
|
||||
|
||||
await verifyChecksum(filePath, expected);
|
||||
for (const pubkeyB64 of BINARY_SIGNING_PUBKEYS) {
|
||||
let keyObject;
|
||||
try {
|
||||
// Build an Ed25519 public key from raw 32 bytes via JWK import.
|
||||
const x = Buffer.from(pubkeyB64, "base64").toString("base64url");
|
||||
keyObject = createPublicKey({
|
||||
key: { kty: "OKP", crv: "Ed25519", x },
|
||||
format: "jwk",
|
||||
});
|
||||
} catch {
|
||||
// Skip an unparseable pinned key (e.g. the placeholder); another may validate.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (cryptoVerify(null, manifestBytes, keyObject, signature)) {
|
||||
console.log("[cloakbrowser] SHA256SUMS signature verified: Ed25519 OK");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// A malformed/wrong-length signature can make verify throw rather than
|
||||
// return false — treat it as a non-match and try the next pinned key
|
||||
// (parity with Python's try/except around pub.verify), failing closed below.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"SHA256SUMS signature verification failed — no pinned key validated the " +
|
||||
"manifest. The binary's authenticity could not be confirmed. " +
|
||||
"Report at https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
@@ -287,7 +504,7 @@ async function verifyChecksum(filePath: string, expectedHash: string): Promise<v
|
||||
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
async function downloadFile(url: string, dest: string, headers?: Record<string, string>): Promise<void> {
|
||||
console.log(`[cloakbrowser] Downloading from ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -300,6 +517,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -363,6 +581,168 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pro binary download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function ensureProBinary(licenseKey: string): Promise<string> {
|
||||
const effective = getEffectiveVersion(true);
|
||||
const effectivePath = getBinaryPath(effective, true);
|
||||
|
||||
if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) {
|
||||
showWelcome();
|
||||
maybeTriggerProUpdateCheck(licenseKey);
|
||||
return effectivePath;
|
||||
}
|
||||
|
||||
const version = await getProLatestVersion();
|
||||
if (!version) {
|
||||
throw new Error("Could not determine latest Pro version from server");
|
||||
}
|
||||
|
||||
const versionPath = getBinaryPath(version, true);
|
||||
if (fs.existsSync(versionPath) && isExecutable(versionPath)) {
|
||||
showWelcome();
|
||||
return versionPath;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...`
|
||||
);
|
||||
await downloadProBinary(version, licenseKey);
|
||||
|
||||
const downloadedPath = getBinaryPath(version, true);
|
||||
if (!fs.existsSync(downloadedPath)) {
|
||||
throw new Error(
|
||||
`Pro download completed but binary not found at: ${downloadedPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Write Pro version marker
|
||||
try {
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
|
||||
fs.writeFileSync(marker, version);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
return downloadedPath;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export async function downloadProBinary(version: string, licenseKey: string): Promise<void> {
|
||||
// Request the explicit version so the served archive matches the signed
|
||||
// manifest verified in verifyProDownload.
|
||||
const downloadUrl = `${DOWNLOAD_BASE_URL}/api/download/${version}`;
|
||||
const binaryDir = getBinaryDir(version, true);
|
||||
const binaryPath = getBinaryPath(version, true);
|
||||
const platformTag = getPlatformTag();
|
||||
|
||||
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
|
||||
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
await downloadFile(downloadUrl, tmpPath, {
|
||||
Authorization: `Bearer ${licenseKey}`,
|
||||
"X-Platform": platformTag,
|
||||
});
|
||||
|
||||
// Pro binaries come from cloakbrowser.dev — the same origin as free
|
||||
// downloads — so the M1 attack the Ed25519 signature defends against
|
||||
// applies equally. Verify with the same non-bypassable signature check;
|
||||
// CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the official
|
||||
// free path).
|
||||
await verifyProDownload(tmpPath, version);
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
} finally {
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Pro archive with the same non-bypassable Ed25519 signature check as
|
||||
* official free downloads. Pro binaries are served from cloakbrowser.dev (same
|
||||
* origin as the free tier), so a tampered same-origin SHA256SUMS could
|
||||
* otherwise certify a tampered binary (M1, #308). Fetch the Pro SHA256SUMS +
|
||||
* detached SHA256SUMS.sig, verify the signature against the pinned keys FIRST,
|
||||
* bind the manifest to the requested version, then verify the archive's
|
||||
* SHA-256.
|
||||
*
|
||||
* An invalid signature, checksum, or version mismatch throws
|
||||
* BinaryVerificationError (a tampering signal the router surfaces verbatim);
|
||||
* CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
|
||||
* transient — nothing was validated — and throws a plain Error. A valid-license
|
||||
* user is never silently downgraded to the free binary.
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export async function verifyProDownload(filePath: string, version: string): Promise<void> {
|
||||
const base = `${DOWNLOAD_BASE_URL}/releases/pro/chromium-v${version}`;
|
||||
let manifestBytes: Uint8Array;
|
||||
let sigBytes: Uint8Array;
|
||||
try {
|
||||
const manifestResp = await fetch(`${base}/SHA256SUMS`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!manifestResp.ok) throw new Error(`HTTP ${manifestResp.status} for SHA256SUMS`);
|
||||
const sigResp = await fetch(`${base}/SHA256SUMS.sig`, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status} for SHA256SUMS.sig`);
|
||||
manifestBytes = new Uint8Array(await manifestResp.arrayBuffer());
|
||||
sigBytes = new Uint8Array(await sigResp.arrayBuffer());
|
||||
} catch (e) {
|
||||
// Fetch failure is transient, not tampering — throw a plain Error (the
|
||||
// router reports it as "unavailable, retry") rather than a
|
||||
// BinaryVerificationError (which it surfaces as a tampering signal).
|
||||
throw new Error(`Could not fetch the signed SHA256SUMS for Pro ${version} (${e})`);
|
||||
}
|
||||
|
||||
// verifySignature / verifyChecksum throw a plain Error; convert to
|
||||
// BinaryVerificationError so the Pro router treats them as tampering signals
|
||||
// (re-throw) rather than transient failures (fall back to free).
|
||||
try {
|
||||
verifySignature(manifestBytes, sigBytes);
|
||||
} catch (e) {
|
||||
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
const manifestText = new TextDecoder().decode(manifestBytes);
|
||||
|
||||
// Version binding: same forced-downgrade defense as the official path.
|
||||
const declared = parseManifestVersion(manifestText);
|
||||
if (declared !== version) {
|
||||
throw new BinaryVerificationError(
|
||||
`Version mismatch in signed Pro SHA256SUMS: requested ${version}, ` +
|
||||
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
|
||||
);
|
||||
}
|
||||
|
||||
const tarballName = getArchiveName();
|
||||
const expected = parseChecksums(manifestText).get(tarballName);
|
||||
if (!expected) {
|
||||
throw new BinaryVerificationError(
|
||||
`Signature-verified Pro SHA256SUMS has no entry for ${tarballName} — ` +
|
||||
`cannot confirm binary integrity.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await verifyChecksum(filePath, expected);
|
||||
} catch (e) {
|
||||
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
@@ -425,11 +805,16 @@ 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('${archivePath}', '${destDir}')`,
|
||||
], { timeout: 120_000 });
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
|
||||
], {
|
||||
timeout: 120_000,
|
||||
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
|
||||
});
|
||||
} else {
|
||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||
}
|
||||
@@ -610,3 +995,36 @@ function maybeTriggerUpdateCheck(): void {
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
function maybeTriggerProUpdateCheck(licenseKey: string): void {
|
||||
const checkFile = path.join(getCacheDir(), ".last_pro_update_check");
|
||||
try {
|
||||
if (fs.existsSync(checkFile)) {
|
||||
const lastCheck = parseFloat(fs.readFileSync(checkFile, "utf-8").trim());
|
||||
if (Date.now() - lastCheck * 1000 < UPDATE_CHECK_INTERVAL_MS) return;
|
||||
}
|
||||
} catch {
|
||||
// unreadable — proceed
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(checkFile), { recursive: true });
|
||||
fs.writeFileSync(checkFile, String(Date.now() / 1000));
|
||||
|
||||
const latest = await getProLatestVersion();
|
||||
if (!latest) return;
|
||||
|
||||
if (fs.existsSync(getBinaryPath(latest, true))) return;
|
||||
|
||||
console.log(`[cloakbrowser] Newer Pro binary available: ${latest}. Downloading in background...`);
|
||||
await downloadProBinary(latest, licenseKey);
|
||||
|
||||
const marker = path.join(getCacheDir(), `latest_pro_version_${getPlatformTag()}`);
|
||||
fs.writeFileSync(marker, latest);
|
||||
console.log(`[cloakbrowser] Pro background update complete: ${latest} ready. Will use on next launch.`);
|
||||
} catch (err) {
|
||||
// non-fatal
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Playwright-style actionability checks for the humanize layer.
|
||||
*
|
||||
* Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
* Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
*/
|
||||
|
||||
import type { Page, Frame, ElementHandle } from 'playwright-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ActionabilityError extends Error {
|
||||
selector: string;
|
||||
check: string;
|
||||
|
||||
constructor(selector: string, check: string, message: string) {
|
||||
super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`);
|
||||
this.name = 'ActionabilityError';
|
||||
this.selector = selector;
|
||||
this.check = check;
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotAttachedError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'attached', 'element not found in DOM');
|
||||
this.name = 'ElementNotAttachedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotVisibleError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'visible', 'element is not visible');
|
||||
this.name = 'ElementNotVisibleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotStableError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'stable', 'element position is still changing');
|
||||
this.name = 'ElementNotStableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotEnabledError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'enabled', 'element is disabled');
|
||||
this.name = 'ElementNotEnabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotEditableError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'editable', 'element is not editable');
|
||||
this.name = 'ElementNotEditableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotReceivingEventsError extends ActionabilityError {
|
||||
coveringTag: string;
|
||||
constructor(selector: string, coveringTag: string = 'unknown') {
|
||||
super(selector, 'pointer_events', `element is covered by <${coveringTag}>`);
|
||||
this.name = 'ElementNotReceivingEventsError';
|
||||
this.coveringTag = coveringTag;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check-set constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events';
|
||||
|
||||
export const CHECKS_CLICK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
|
||||
export const CHECKS_HOVER: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'pointer_events']);
|
||||
export const CHECKS_INPUT: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']);
|
||||
export const CHECKS_FOCUS: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled']);
|
||||
export const CHECKS_CHECK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
|
||||
|
||||
const BACKOFF_MS = [100, 250, 500, 1000];
|
||||
|
||||
function backoffSleep(attempt: number): Promise<void> {
|
||||
const idx = Math.min(attempt, BACKOFF_MS.length - 1);
|
||||
return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-scroll actionability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureActionable(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
checks: ReadonlySet<CheckName>,
|
||||
timeout: number = 30000,
|
||||
force: boolean = false,
|
||||
): Promise<void> {
|
||||
if (force) return;
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
let lastError: ActionabilityError | null = null;
|
||||
|
||||
while (true) {
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
if (lastError) throw lastError;
|
||||
throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check');
|
||||
}
|
||||
|
||||
try {
|
||||
const loc = pageOrFrame.locator(selector).first();
|
||||
|
||||
if (checks.has('attached')) {
|
||||
try {
|
||||
await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotAttachedError(selector);
|
||||
}
|
||||
}
|
||||
|
||||
if (checks.has('visible')) {
|
||||
if (!await loc.isVisible()) throw new ElementNotVisibleError(selector);
|
||||
}
|
||||
|
||||
if (checks.has('enabled')) {
|
||||
if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector);
|
||||
}
|
||||
|
||||
if (checks.has('editable')) {
|
||||
if (!await loc.isEditable()) throw new ElementNotEditableError(selector);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ActionabilityError) {
|
||||
lastError = e;
|
||||
if (Date.now() >= deadline) throw lastError;
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-scroll stability check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function boxesDiffer(
|
||||
a: { x: number; y: number; width: number; height: number },
|
||||
b: { x: number; y: number; width: number; height: number },
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(a.x - b.x) > 1 ||
|
||||
Math.abs(a.y - b.y) > 1 ||
|
||||
Math.abs(a.width - b.width) > 1 ||
|
||||
Math.abs(a.height - b.height) > 1
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureStable(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
if (remainingMs <= 0) throw new ElementNotStableError(selector);
|
||||
|
||||
const loc = pageOrFrame.locator(selector).first();
|
||||
const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
|
||||
if (!box1) throw new ElementNotAttachedError(selector);
|
||||
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
|
||||
if (!box2) throw new ElementNotAttachedError(selector);
|
||||
|
||||
if (!boxesDiffer(box1, box2)) return;
|
||||
|
||||
if (Date.now() >= deadline) throw new ElementNotStableError(selector);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
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; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
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);
|
||||
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; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}`;
|
||||
|
||||
export async function checkPointerEvents(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
x: number,
|
||||
y: number,
|
||||
stealth?: { evaluate(expression: string): Promise<any> } | null,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
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 });
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (!result || result.hit) return;
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementHandle variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureActionableHandle(
|
||||
el: ElementHandle,
|
||||
checks: ReadonlySet<CheckName>,
|
||||
timeout: number = 30000,
|
||||
force: boolean = false,
|
||||
): Promise<void> {
|
||||
if (force) return;
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
let lastError: ActionabilityError | null = null;
|
||||
const label = '<ElementHandle>';
|
||||
|
||||
while (true) {
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
if (lastError) throw lastError;
|
||||
throw new ActionabilityError(label, 'timeout', 'timeout expired before first check');
|
||||
}
|
||||
|
||||
try {
|
||||
if (checks.has('visible')) {
|
||||
try {
|
||||
await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotVisibleError(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (checks.has('enabled')) {
|
||||
try {
|
||||
await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotEnabledError(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (checks.has('editable')) {
|
||||
try {
|
||||
await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotEditableError(label);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ActionabilityError) {
|
||||
lastError = e;
|
||||
if (Date.now() >= deadline) throw lastError;
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPointerEventsHandle(
|
||||
el: ElementHandle,
|
||||
x: number,
|
||||
y: number,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
let result: any;
|
||||
try {
|
||||
const box = await el.boundingBox();
|
||||
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (!result || result.hit) return;
|
||||
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export type HumanPreset = 'default' | 'careful';
|
||||
|
||||
export type HumanActionOptions = Partial<HumanConfig> & {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
human_config?: Partial<HumanConfig>;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import { rand, randRange, sleep, mergeConfig } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { humanScrollIntoView } from './scroll.js';
|
||||
import {
|
||||
ensureActionableHandle, checkPointerEventsHandle,
|
||||
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
|
||||
} from './actionability.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
@@ -190,8 +194,14 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
};
|
||||
|
||||
@@ -206,8 +216,14 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElDblclick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
@@ -221,9 +237,13 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
@@ -232,8 +252,14 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
@@ -247,11 +273,16 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElFill(value, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
@@ -275,6 +306,11 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
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);
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
@@ -290,12 +326,18 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return; // Already checked
|
||||
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));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -307,12 +349,18 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return; // Already unchecked
|
||||
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));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -325,12 +373,18 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
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));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
+144
-29
@@ -28,6 +28,11 @@ import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle }
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
|
||||
import {
|
||||
ensureActionable, ensureStable, checkPointerEvents,
|
||||
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
|
||||
type CheckName,
|
||||
} from './actionability.js';
|
||||
|
||||
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
@@ -307,17 +312,34 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const skipChecks = (options as any)?._skipChecks ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force && !skipChecks) {
|
||||
await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
|
||||
}
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, isInput, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -328,15 +350,28 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, isInput, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -346,16 +381,31 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- hover ---
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const skipChecks = (options as any)?._skipChecks ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, false, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -364,8 +414,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- type ---
|
||||
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
@@ -374,8 +430,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
@@ -387,8 +449,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -399,38 +467,62 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- check ---
|
||||
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => false);
|
||||
if (!checked) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
};
|
||||
|
||||
// --- uncheck ---
|
||||
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => true);
|
||||
if (checked) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
};
|
||||
|
||||
// --- selectOption ---
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
|
||||
await humanHoverFn(selector, options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 300));
|
||||
return originals.selectOption(selector, values, options);
|
||||
};
|
||||
|
||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
@@ -439,8 +531,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
@@ -593,7 +691,12 @@ function patchSingleFrame(
|
||||
const origFrameTap = (frame as any).tap?.bind(frame);
|
||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||
|
||||
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
|
||||
const moveToFrameSelector = async (
|
||||
selector: string,
|
||||
options: HumanActionOptions | undefined,
|
||||
inputBias: boolean,
|
||||
remainingMs: () => number,
|
||||
) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
@@ -601,9 +704,9 @@ function patchSingleFrame(
|
||||
|
||||
const locator = firstFrameLocator(frame, selector);
|
||||
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
|
||||
}
|
||||
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
|
||||
if (!box) return null;
|
||||
|
||||
const isInput = inputBias || await isFrameInputElement(frame, selector);
|
||||
@@ -615,23 +718,32 @@ function patchSingleFrame(
|
||||
};
|
||||
|
||||
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameClick(selector, options);
|
||||
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()) });
|
||||
await humanClick(raw, moved.isInput, moved.callCfg);
|
||||
};
|
||||
|
||||
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
||||
|
||||
const frameHover = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options, false);
|
||||
if (!moved) return origFrameHover(selector, options);
|
||||
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()) });
|
||||
};
|
||||
|
||||
(frame as any).click = frameClick;
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameDblclick(selector, options);
|
||||
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()) });
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
@@ -722,8 +834,11 @@ function patchSingleFrame(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
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);
|
||||
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);
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
@@ -739,7 +854,7 @@ function patchSingleFrame(
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origFrameDragAndDrop(source, target, options);
|
||||
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function humanScrollIntoView(
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function humanScrollIntoView(
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
return { box, cursorX, cursorY, didScroll: false };
|
||||
}
|
||||
|
||||
// Move cursor into scroll area
|
||||
@@ -139,7 +139,7 @@ export async function humanScrollIntoView(
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
return { box, cursorX, cursorY, didScroll: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +148,8 @@ export async function humanScrollIntoView(
|
||||
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
|
||||
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
||||
*
|
||||
* Returns `{ box, cursorX, cursorY, didScroll }`.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
@@ -157,7 +159,7 @@ export async function scrollToElement(
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
@@ -172,7 +174,7 @@ async function getElementBox(
|
||||
): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout });
|
||||
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+5
-1
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
|
||||
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
@@ -24,5 +24,9 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
|
||||
// Config
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// License
|
||||
export { validateLicense } from "./license.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
export type { LicenseInfo } from "./license.js";
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* License validation and caching for CloakBrowser Pro.
|
||||
* Mirrors Python cloakbrowser/license.py.
|
||||
*
|
||||
* Handles license key resolution, server validation with local caching,
|
||||
* and Pro version checks.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { getCacheDir } from "./config.js";
|
||||
|
||||
const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate";
|
||||
const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version";
|
||||
|
||||
const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours
|
||||
const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
|
||||
export interface LicenseInfo {
|
||||
valid: boolean;
|
||||
plan: string;
|
||||
expires: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the license key: explicit param > env var > file > undefined.
|
||||
*/
|
||||
export function resolveLicenseKey(licenseKey?: string): string | undefined {
|
||||
const trimmed = licenseKey?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim();
|
||||
if (envKey) return envKey;
|
||||
try {
|
||||
const keyFile = path.join(getCacheDir(), "license.key");
|
||||
const content = fs.readFileSync(keyFile, "utf-8").trim();
|
||||
if (content) return content;
|
||||
} catch {
|
||||
// File doesn't exist or unreadable
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a license key with the CloakBrowser server.
|
||||
*
|
||||
* Checks a local file cache first (24h TTL). Falls back to stale
|
||||
* cache if the server is unreachable.
|
||||
*
|
||||
* Returns LicenseInfo if validation succeeded, null on total failure.
|
||||
*/
|
||||
export async function validateLicense(licenseKey: string): Promise<LicenseInfo | null> {
|
||||
const cachePath = path.join(getCacheDir(), ".license_cache");
|
||||
const keySha = createHash("sha256").update(licenseKey).digest("hex");
|
||||
|
||||
const cached = readCache(cachePath, keySha);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const resp = await fetch(VALIDATE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ license_key: licenseKey }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as Record<string, unknown>;
|
||||
|
||||
const info: LicenseInfo = {
|
||||
valid: Boolean(data.valid ?? false),
|
||||
plan: String(data.plan ?? "solo"),
|
||||
expires: data.expires != null ? String(data.expires) : null,
|
||||
};
|
||||
|
||||
if (info.valid) {
|
||||
writeCache(cachePath, keySha, info);
|
||||
}
|
||||
return info;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}`
|
||||
);
|
||||
|
||||
// Fall back to stale cache
|
||||
const stale = readCache(cachePath, keySha, true);
|
||||
if (stale) {
|
||||
console.warn("[cloakbrowser] Using cached license validation (server unreachable)");
|
||||
return stale;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest Pro binary version from the server.
|
||||
* Rate-limited to 1 call per hour via a marker file.
|
||||
*/
|
||||
export async function getProLatestVersion(): Promise<string | null> {
|
||||
const marker = path.join(getCacheDir(), ".last_pro_version_check");
|
||||
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const stats = fs.statSync(marker);
|
||||
const age = Date.now() - stats.mtimeMs;
|
||||
if (age < PRO_VERSION_CHECK_INTERVAL_MS) {
|
||||
const content = fs.readFileSync(marker, "utf-8").trim();
|
||||
return content || null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable — proceed with fetch
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(PRO_VERSION_URL, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as Record<string, unknown>;
|
||||
const version = data.version != null ? String(data.version) : null;
|
||||
if (!version) return null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
||||
fs.writeFileSync(marker, version);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
return version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CacheData {
|
||||
key_sha256: string;
|
||||
valid: boolean;
|
||||
plan: string;
|
||||
expires: string | null;
|
||||
validated_at: number;
|
||||
}
|
||||
|
||||
function readCache(
|
||||
cachePath: string,
|
||||
keySha: string,
|
||||
ignoreTtl = false,
|
||||
): LicenseInfo | null {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData;
|
||||
|
||||
if (data.key_sha256 !== keySha) return null;
|
||||
|
||||
if (!ignoreTtl) {
|
||||
const validatedAt = data.validated_at ?? 0;
|
||||
// A non-numeric validated_at (corrupted cache) is treated as absent rather
|
||||
// than coercing to NaN and silently trusting the entry.
|
||||
if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.expires) {
|
||||
try {
|
||||
if (new Date(data.expires).getTime() < Date.now()) {
|
||||
return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires };
|
||||
}
|
||||
} catch {
|
||||
// unparseable date — skip check
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: Boolean(data.valid ?? false),
|
||||
plan: String(data.plan ?? "solo"),
|
||||
expires: data.expires ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void {
|
||||
try {
|
||||
const dir = path.dirname(cachePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmpPath = cachePath + ".tmp";
|
||||
fs.writeFileSync(
|
||||
tmpPath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: info.valid,
|
||||
plan: info.plan,
|
||||
expires: info.expires,
|
||||
validated_at: Date.now() / 1000,
|
||||
}),
|
||||
);
|
||||
fs.renameSync(tmpPath, cachePath);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
+128
-46
@@ -3,13 +3,14 @@
|
||||
* Mirrors Python cloakbrowser/browser.py.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
|
||||
import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } 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 {
|
||||
@@ -44,6 +45,100 @@ 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()`.
|
||||
*/
|
||||
/**
|
||||
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
|
||||
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
|
||||
* top-level field at the actual chromium.launch() call. Viewport logic must read
|
||||
* the same effective value — otherwise a headed browser gets a fixed viewport
|
||||
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
|
||||
* resolves headless the opposite way).
|
||||
*/
|
||||
function effectiveHeadless(options: LaunchOptions): boolean {
|
||||
return (
|
||||
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
|
||||
options.headless ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function buildContextOptions(
|
||||
options: LaunchContextOptions = {}
|
||||
): BrowserContextOptions {
|
||||
// Headed: viewport=null (no emulation) so the page tracks the real window and
|
||||
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
|
||||
// inner > outer = a physically impossible window = bot tell. Headless has no
|
||||
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
|
||||
const headless = effectiveHeadless(options);
|
||||
const viewport =
|
||||
options.viewport !== undefined
|
||||
? options.viewport
|
||||
: headless
|
||||
? DEFAULT_VIEWPORT
|
||||
: null;
|
||||
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.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(options.licenseKey));
|
||||
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.
|
||||
*
|
||||
@@ -59,39 +154,34 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
|
||||
*/
|
||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
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 browser = await chromium.launch(await buildLaunchOptions(options));
|
||||
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
|
||||
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
|
||||
// newPage()/newContext() to viewport:null so the page tracks the real window.
|
||||
// Headless keeps Playwright's default viewport (coherent there).
|
||||
if (!effectiveHeadless(options)) {
|
||||
applyDefaultNoViewport(browser);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
await humanizeBrowser(browser, options);
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
|
||||
* emulation) when the caller didn't specify a viewport. setdefault-style: an
|
||||
* explicit viewport (including null) is always honored. Apply before humanize's
|
||||
* patchBrowser so the wraps compose.
|
||||
*/
|
||||
function applyDefaultNoViewport(browser: Browser): void {
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
|
||||
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
|
||||
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
* Closing the context also closes the browser.
|
||||
@@ -122,18 +212,13 @@ export async function launchContext(
|
||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
// so it applies to ALL contexts, not just the default one.
|
||||
// locale and timezone are set via binary flags only — no CDP emulation.
|
||||
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
|
||||
// humanize:false on the inner launch — patchContext below applies humanize
|
||||
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
|
||||
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
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 } : {}),
|
||||
});
|
||||
context = await browser.newContext(buildContextOptions(options));
|
||||
} catch (err) {
|
||||
await browser.close();
|
||||
throw err;
|
||||
@@ -187,7 +272,7 @@ export async function launchPersistentContext(
|
||||
options = resolveTimezone(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
@@ -196,6 +281,8 @@ 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, {
|
||||
@@ -204,12 +291,7 @@ export async function launchPersistentContext(
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
// 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 } : {}),
|
||||
...buildContextOptions(options),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
|
||||
+112
-3
@@ -2,6 +2,8 @@
|
||||
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
|
||||
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
|
||||
|
||||
export interface ParsedProxy {
|
||||
server: string;
|
||||
username?: string;
|
||||
@@ -155,11 +157,106 @@ 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.
|
||||
*
|
||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
* 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).
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
@@ -177,7 +274,19 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
// 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
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
|
||||
+146
-58
@@ -6,77 +6,97 @@
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* ```
|
||||
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
|
||||
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
|
||||
* emulated viewport = a physically impossible window = bot tell). Headless has no
|
||||
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
|
||||
*/
|
||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
|
||||
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
|
||||
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
|
||||
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
|
||||
// launchOptions spread, so the top-level field wins at launch — match it here.
|
||||
if (launchOpts.defaultViewport !== undefined) {
|
||||
return launchOpts.defaultViewport as { width: number; height: number } | null;
|
||||
}
|
||||
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
|
||||
}
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
|
||||
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
|
||||
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||
|
||||
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
return { binaryPath, args: buildArgs({ ...options, ...resolved, args: resolvedArgs }) };
|
||||
}
|
||||
|
||||
// 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 ?? "" };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const browser = await puppeteer.default.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...options.launchOptions,
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Monkey-patch newPage() to auto-authenticate proxy credentials
|
||||
// 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 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> {
|
||||
if (proxyAuth) {
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
const auth = proxyAuth;
|
||||
@@ -87,9 +107,6 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -99,6 +116,77 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
);
|
||||
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,
|
||||
defaultViewport: resolveDefaultViewport(options),
|
||||
});
|
||||
|
||||
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,
|
||||
defaultViewport: resolveDefaultViewport(options),
|
||||
});
|
||||
|
||||
await applyPostLaunch(browser, options, proxyAuth);
|
||||
return browser;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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. */
|
||||
@@ -25,6 +27,8 @@ export interface LaunchOptions {
|
||||
locale?: string;
|
||||
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
|
||||
geoip?: boolean;
|
||||
/** Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. */
|
||||
licenseKey?: string;
|
||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||
launchOptions?: Record<string, unknown>;
|
||||
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||
@@ -64,7 +68,10 @@ export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
/** The wrapper's bundled baseline Chromium version (CHROMIUM_VERSION). */
|
||||
bundledVersion: string;
|
||||
platform: string;
|
||||
tier: "pro" | "free";
|
||||
binaryPath: string;
|
||||
installed: boolean;
|
||||
cacheDir: string;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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}`
|
||||
);
|
||||
});
|
||||
+193
-40
@@ -258,14 +258,13 @@ describe("patchPage fill", () => {
|
||||
const pressedKeys: string[] = [];
|
||||
const page = buildMockPage({
|
||||
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
||||
evaluate: async () => false,
|
||||
});
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).fill("input#name", "hello"); } catch (_) { }
|
||||
try { await (page as any).fill("input#name", "hello", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||
@@ -273,7 +272,7 @@ describe("patchPage fill", () => {
|
||||
expect(pressedKeys).toContain(expected);
|
||||
expect(pressedKeys).not.toContain(wrong);
|
||||
}
|
||||
}, 30000);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
|
||||
@@ -287,18 +286,18 @@ describe("patchPage check/uncheck idle", () => {
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => false,
|
||||
evaluate: async () => false,
|
||||
evaluate: async () => ({ hit: true }),
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
idle_between_duration: [0.01, 0.02],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).check("input#cb"); } catch (_) { }
|
||||
try { await (page as any).check("input#cb", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||
expect(downCalled).toBe(true);
|
||||
@@ -310,18 +309,20 @@ describe("patchPage check/uncheck idle", () => {
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => true,
|
||||
evaluate: async () => false,
|
||||
evaluate: async () => ({ hit: true }),
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
idle_between_duration: [0.01, 0.02],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).uncheck("input#cb"); } catch (_) { }
|
||||
try { await (page as any).uncheck("input#cb", { timeout: 2000 }); } catch (e: any) {
|
||||
console.error("UNCHECK ERROR:", e?.message?.slice(0, 200));
|
||||
}
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -345,7 +346,10 @@ describe("patchPage press focus", () => {
|
||||
|
||||
let downCount = 0;
|
||||
const page = buildMockPage({
|
||||
evaluate: async () => false,
|
||||
evaluate: async (expr: string) => {
|
||||
if (typeof expr === 'string' && expr.includes('elementFromPoint')) return { hit: true };
|
||||
return false;
|
||||
},
|
||||
});
|
||||
// Intercept mouse.down before patching so raw captures it
|
||||
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||
@@ -354,7 +358,7 @@ describe("patchPage press focus", () => {
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCount).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -372,7 +376,7 @@ describe("patchPage press focus", () => {
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCount).toBe(0);
|
||||
});
|
||||
@@ -568,7 +572,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
patchBrowser(browser, resolveConfig("default"));
|
||||
|
||||
// Click through the patched method — should go through humanize path
|
||||
try { await (page as any).click("button"); } catch (_) { }
|
||||
try { await (page as any).click("button", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -616,24 +620,37 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
press: vi.fn(async () => { }),
|
||||
clear: vi.fn(async () => { }),
|
||||
dragAndDrop: vi.fn(async () => { }),
|
||||
locator: vi.fn(() => ({
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
first: vi.fn(function (this: any) { return this; }),
|
||||
})),
|
||||
locator: vi.fn(() => {
|
||||
const frameLoc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
frameLoc.first = vi.fn(() => frameLoc);
|
||||
return frameLoc;
|
||||
}),
|
||||
};
|
||||
|
||||
const makeLocator = () => {
|
||||
const loc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => { }),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
return loc;
|
||||
};
|
||||
|
||||
const page: any = {
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
mouse: {
|
||||
move: vi.fn(async () => { }),
|
||||
@@ -670,6 +687,7 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
context: vi.fn(() => ({
|
||||
pages: vi.fn(() => []),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
newCDPSession: vi.fn(async () => { throw new Error('no cdp'); }),
|
||||
})),
|
||||
url: vi.fn(() => "about:blank"),
|
||||
waitForTimeout: vi.fn(async () => { }),
|
||||
@@ -772,8 +790,9 @@ function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||
tap: vi.fn(async () => { }),
|
||||
focus: vi.fn(async () => { }),
|
||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitForElementState: vi.fn(async () => {}),
|
||||
$: vi.fn(async () => null),
|
||||
$$: vi.fn(async () => []),
|
||||
waitForSelector: vi.fn(async () => null),
|
||||
@@ -903,7 +922,7 @@ describe("patchSingleElementHandle", () => {
|
||||
};
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
@@ -940,7 +959,7 @@ describe("patchSingleElementHandle", () => {
|
||||
keyboardUp: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
@@ -1100,7 +1119,7 @@ function buildMockFrame(): any {
|
||||
const locator: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
evaluate: vi.fn(async () => false),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
isChecked: vi.fn(async () => false),
|
||||
};
|
||||
locator.first = vi.fn(() => locator);
|
||||
@@ -1208,16 +1227,21 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
||||
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
||||
captured = timeout ?? -1;
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy, didScroll: false };
|
||||
},
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
await (page as any).click("#slow", { timeout: 5000 });
|
||||
try {
|
||||
await (page as any).click("#slow", { timeout: 2000 });
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured).toBe(5000);
|
||||
if (captured > 0) {
|
||||
expect(captured).toBeGreaterThan(1500);
|
||||
expect(captured).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1246,7 +1270,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
cursorX: cx, cursorY: cy, didScroll: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1254,18 +1278,22 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).type("#email", "hi", {
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
try {
|
||||
await (page as any).type("#email", "hi", {
|
||||
timeout: 2000,
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
// Global cfg untouched
|
||||
if (captured) {
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
}
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
}, 5000);
|
||||
|
||||
it("page.fill forwards flat config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
@@ -1284,7 +1312,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
cursorX: cx, cursorY: cy, didScroll: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1292,11 +1320,16 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).fill("#password", "secret", {
|
||||
typing_delay: 150,
|
||||
});
|
||||
try {
|
||||
await (page as any).fill("#password", "secret", {
|
||||
timeout: 2000,
|
||||
typing_delay: 150,
|
||||
});
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
if (captured) {
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
}
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
@@ -1317,7 +1350,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
@@ -1446,3 +1479,123 @@ 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+188
-10
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js";
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
it("returns correct structure", () => {
|
||||
@@ -10,6 +13,7 @@ describe("binaryInfo", () => {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.bundledVersion).toBeTruthy();
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
@@ -19,6 +23,174 @@ describe("binaryInfo", () => {
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports tier from the installed binary, not a cached license", () => {
|
||||
// A valid, fresh license is cached but NO Pro binary is on disk → free.
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
const dir = `/tmp/cloakbrowser-test-${Date.now()}-tier`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = dir;
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, ".license_cache"),
|
||||
JSON.stringify({
|
||||
key_sha256: "abc",
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
expect(binaryInfo().tier).toBe("free");
|
||||
|
||||
// Now drop a Pro binary on disk → pro.
|
||||
fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "147.0.5555.1");
|
||||
const bp = getBinaryPath("147.0.5555.1", true);
|
||||
fs.mkdirSync(path.dirname(bp), { recursive: true });
|
||||
fs.writeFileSync(bp, "fake");
|
||||
fs.chmodSync(bp, 0o755);
|
||||
const info = binaryInfo();
|
||||
expect(info.tier).toBe("pro");
|
||||
expect(info.version).toBe("147.0.5555.1");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
|
||||
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
|
||||
// Headless keeps the deterministic default.
|
||||
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
// Explicit viewport always honored, even headed.
|
||||
const custom = { width: 800, height: 600 };
|
||||
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
|
||||
// at the actual launch. Viewport must follow it — a raw headless:false (browser
|
||||
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
|
||||
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
|
||||
// And launchOptions.headless:true forces the deterministic viewport even if the
|
||||
// top-level field said headed.
|
||||
expect(
|
||||
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
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:
|
||||
@@ -243,16 +415,22 @@ describe("launchPersistentContext (unit)", () => {
|
||||
});
|
||||
|
||||
it("forwards proxy string", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
proxy: "http://user:pass@proxy:8080",
|
||||
});
|
||||
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 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");
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards userAgent and colorScheme", async () => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import {
|
||||
resolveLicenseKey,
|
||||
validateLicense,
|
||||
getProLatestVersion,
|
||||
} from "../src/license.js";
|
||||
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join("/tmp", `cloakbrowser-test-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
vi.spyOn(config, "getCacheDir").mockReturnValue(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ── resolveLicenseKey ─────────────────────────────────
|
||||
|
||||
describe("resolveLicenseKey", () => {
|
||||
it("explicit param wins over env", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
expect(resolveLicenseKey("explicit")).toBe("explicit");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("env var fallback", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
expect(resolveLicenseKey()).toBe("env-key");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("returns undefined when absent", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
expect(resolveLicenseKey()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("file fallback when no param or env", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
const keyFile = path.join(tmpDir, "license.key");
|
||||
fs.writeFileSync(keyFile, "file-key-123\n");
|
||||
expect(resolveLicenseKey()).toBe("file-key-123");
|
||||
});
|
||||
|
||||
it("env takes precedence over file", () => {
|
||||
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
|
||||
const keyFile = path.join(tmpDir, "license.key");
|
||||
fs.writeFileSync(keyFile, "file-key");
|
||||
expect(resolveLicenseKey()).toBe("env-key");
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
});
|
||||
|
||||
it("returns undefined when file missing", () => {
|
||||
delete process.env.CLOAKBROWSER_LICENSE_KEY;
|
||||
expect(resolveLicenseKey()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateLicense ───────────────────────────────────
|
||||
|
||||
describe("validateLicense", () => {
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
|
||||
it("fresh cache skips server call", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "team",
|
||||
expires: "2026-12-01",
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const result = await validateLicense("test-key");
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.plan).toBe("team");
|
||||
});
|
||||
|
||||
it("stale cache triggers server call", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000 - 90000, // 25 hours ago
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("server success returns LicenseInfo", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "business", expires: "2026-07-13" }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("pro-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
expect(result!.plan).toBe("business");
|
||||
expect(result!.expires).toBe("2026-07-13");
|
||||
});
|
||||
|
||||
it("server rejection returns invalid", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: false, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("bad-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("server unreachable uses stale cache", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: "2026-12-01",
|
||||
validated_at: Date.now() / 1000 - 90000,
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("server unreachable no cache returns null", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("cache stores hash not raw key", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("secret-key-123");
|
||||
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
const content = fs.readFileSync(cachePath, "utf-8");
|
||||
expect(content).not.toContain("secret-key-123");
|
||||
const expectedSha = crypto
|
||||
.createHash("sha256")
|
||||
.update("secret-key-123")
|
||||
.digest("hex");
|
||||
expect(content).toContain(expectedSha);
|
||||
});
|
||||
|
||||
it("wrong key cache ignored", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: "other-hash",
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("different-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("expired license rejected from cache", async () => {
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: "2020-01-01T00:00:00+00:00",
|
||||
validated_at: Date.now() / 1000,
|
||||
})
|
||||
);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cache invalid responses", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: false, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
await validateLicense("bad-key");
|
||||
|
||||
const cachePath = path.join(tmpDir, ".license_cache");
|
||||
expect(fs.existsSync(cachePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("corrupted validated_at is treated as absent cache, not trusted", async () => {
|
||||
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, ".license_cache"),
|
||||
JSON.stringify({
|
||||
key_sha256: keySha,
|
||||
valid: true,
|
||||
plan: "solo",
|
||||
expires: null,
|
||||
validated_at: "not-a-number",
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ valid: true, plan: "solo", expires: null }),
|
||||
} as Response);
|
||||
|
||||
const result = await validateLicense("test-key");
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce(); // corrupted cache ignored → server hit
|
||||
expect(result!.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProLatestVersion ───────────────────────────────
|
||||
|
||||
describe("getProLatestVersion", () => {
|
||||
it("fetches version from server", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "147.0.1234.5" }),
|
||||
} as Response);
|
||||
|
||||
const version = await getProLatestVersion();
|
||||
expect(version).toBe("147.0.1234.5");
|
||||
});
|
||||
|
||||
it("rate limited by marker file", async () => {
|
||||
const marker = path.join(tmpDir, ".last_pro_version_check");
|
||||
fs.writeFileSync(marker, "147.0.1234.5");
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const version = await getProLatestVersion();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(version).toBe("147.0.1234.5");
|
||||
});
|
||||
|
||||
it("network error returns null", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
|
||||
const version = await getProLatestVersion();
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Config pro parameter ──────────────────────────────
|
||||
|
||||
describe("config pro parameter", () => {
|
||||
it("getBinaryDir adds -pro suffix", () => {
|
||||
const normal = config.getBinaryDir("147.0.0.0");
|
||||
const pro = config.getBinaryDir("147.0.0.0", true);
|
||||
expect(normal).toMatch(/chromium-147\.0\.0\.0$/);
|
||||
expect(pro).toMatch(/chromium-147\.0\.0\.0-pro$/);
|
||||
});
|
||||
|
||||
it("getBinaryDir default has no suffix", () => {
|
||||
const normal = config.getBinaryDir("147.0.0.0");
|
||||
expect(normal).not.toMatch(/-pro$/);
|
||||
});
|
||||
});
|
||||
+103
-5
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
|
||||
import * as config from "../src/config.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
@@ -153,10 +154,15 @@ describe("resolveProxyConfig", () => {
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
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 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 dict", () => {
|
||||
@@ -321,4 +327,96 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+201
-9
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
// Headless (default): deterministic viewport.
|
||||
await launch();
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
|
||||
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launch({ headless: false });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
const custom = { width: 640, height: 480 };
|
||||
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(custom);
|
||||
|
||||
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launch({ headless: true, launchOptions: { defaultViewport: null } });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
|
||||
// launch — the viewport decision must follow the same (top-level) value.
|
||||
await launch({ headless: true, launchOptions: { headless: false } });
|
||||
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(opts.headless).toBe(true);
|
||||
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("adds --proxy-server for string proxy", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ proxy: "http://proxy:8080" });
|
||||
@@ -84,16 +131,39 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
||||
});
|
||||
|
||||
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" });
|
||||
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" });
|
||||
|
||||
// newPage should auto-authenticate
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).toHaveBeenCalledWith({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects timezone and locale as binary flags", async () => {
|
||||
@@ -126,6 +196,14 @@ 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({
|
||||
@@ -139,3 +217,117 @@ 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("headed persistent context uses null defaultViewport (tracks real window)", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||
|
||||
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { sign as cryptoSign, createPrivateKey, createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Generate a throwaway signing keypair BEFORE the config mock is hoisted, then
|
||||
// pin its public key so verifySignature accepts signatures we produce here.
|
||||
const h = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const crypto = require("node:crypto");
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const otherPub = crypto.generateKeyPairSync("ed25519").publicKey;
|
||||
const rawB64 = (pk: any) =>
|
||||
Buffer.from(pk.export({ format: "jwk" }).x, "base64url").toString("base64");
|
||||
return {
|
||||
pinnedPubB64: rawB64(publicKey),
|
||||
otherPubB64: rawB64(otherPub),
|
||||
privPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/config.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/config.js")>();
|
||||
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.pinnedPubB64] };
|
||||
});
|
||||
|
||||
import {
|
||||
BinaryVerificationError,
|
||||
downloadProBinary,
|
||||
fetchSignedManifest,
|
||||
parseChecksums,
|
||||
parseManifestVersion,
|
||||
verifyDownloadChecksum,
|
||||
verifyProDownload,
|
||||
verifySignature,
|
||||
} from "../src/download.js";
|
||||
import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
|
||||
function sign(manifest: Uint8Array): Uint8Array {
|
||||
const priv = createPrivateKey(h.privPem);
|
||||
const sig = cryptoSign(null, manifest, priv); // raw 64-byte Ed25519 signature
|
||||
return new TextEncoder().encode(sig.toString("base64"));
|
||||
}
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
describe("verifySignature", () => {
|
||||
it("accepts a valid signature", () => {
|
||||
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
expect(() => verifySignature(manifest, sign(manifest))).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a tampered manifest", () => {
|
||||
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
|
||||
const sig = sign(manifest);
|
||||
const tampered = enc("xyz cloakbrowser-linux-x64.tar.gz\n");
|
||||
expect(() => verifySignature(tampered, sig)).toThrow(/signature verification failed/);
|
||||
});
|
||||
|
||||
it("rejects malformed base64 in the .sig", () => {
|
||||
expect(() => verifySignature(enc("data\n"), enc("!!!not base64!!!")))
|
||||
.toThrow(/Malformed/);
|
||||
});
|
||||
|
||||
it("rejects a signature from a non-pinned key", async () => {
|
||||
// Re-mock config so ONLY the other key is pinned, then the signature
|
||||
// (made with the real key) must fail.
|
||||
vi.resetModules();
|
||||
vi.doMock("../src/config.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/config.js")>();
|
||||
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64] };
|
||||
});
|
||||
const { verifySignature: vs } = await import("../src/download.js");
|
||||
const manifest = enc("data\n");
|
||||
expect(() => vs(manifest, sign(manifest))).toThrow(/signature verification failed/);
|
||||
vi.doUnmock("../src/config.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("accepts a signature under the new key during rotation", async () => {
|
||||
// Pin BOTH keys (old + new) and sign with the real (new) key — must pass.
|
||||
vi.resetModules();
|
||||
vi.doMock("../src/config.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/config.js")>();
|
||||
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64, h.pinnedPubB64] };
|
||||
});
|
||||
const { verifySignature: vs } = await import("../src/download.js");
|
||||
const manifest = enc("rotated\n");
|
||||
expect(() => vs(manifest, sign(manifest))).not.toThrow();
|
||||
vi.doUnmock("../src/config.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyDownloadChecksum (official path, fail-closed)", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
|
||||
});
|
||||
|
||||
function tmpFile(bytes: Buffer): string {
|
||||
const p = path.join(os.tmpdir(), `cloak-sig-${process.pid}-${bytes.length}-${bytes[0]}`);
|
||||
fs.writeFileSync(p, bytes);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Mock fetch to serve a signed manifest for the official URLs. */
|
||||
function mockManifest(manifestBytes: Uint8Array) {
|
||||
const sig = sign(manifestBytes);
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).toString();
|
||||
const body = url.endsWith(".sig") ? sig : manifestBytes;
|
||||
return { ok: true, arrayBuffer: async () => body.buffer } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
/** Manifest body with the bound version line prepended (defaults to current). */
|
||||
const body = (lines: string, version = getChromiumVersion()) =>
|
||||
enc(`version=${version}\n${lines}`);
|
||||
|
||||
it("passes when signature is valid and hash matches", async () => {
|
||||
const data = Buffer.from("the real binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`));
|
||||
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails when the binary is tampered (hash mismatch)", async () => {
|
||||
const file = tmpFile(Buffer.from("a malicious binary"));
|
||||
const goodHash = createHash("sha256").update(Buffer.from("the real binary")).digest("hex");
|
||||
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
|
||||
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Checksum verification failed/);
|
||||
});
|
||||
|
||||
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
|
||||
const data = Buffer.from("the real binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
// Genuinely signed, but declares an old version we did not request.
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
|
||||
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
|
||||
});
|
||||
|
||||
it("fails when the version line is missing (binding required)", async () => {
|
||||
const data = Buffer.from("the real binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(enc(`${hash} ${getArchiveName()}\n`)); // no version= line
|
||||
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
|
||||
});
|
||||
|
||||
it("fails closed when no signed manifest can be fetched", async () => {
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
|
||||
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/signed SHA256SUMS/);
|
||||
});
|
||||
|
||||
it("fails when the signed manifest has no entry for this platform", async () => {
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
const someHash = "0".repeat(64);
|
||||
mockManifest(body(`${someHash} some-other-file.tar.gz\n`));
|
||||
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/no entry for/);
|
||||
});
|
||||
|
||||
it("custom download URL keeps the legacy skippable path (no signature fetch)", async () => {
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://my-mirror.test";
|
||||
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadProBinary (version-pinned URL)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("requests the explicit version, not /latest", async () => {
|
||||
let capturedUrl = "";
|
||||
// First fetch is the binary download; capture its URL then abort the flow
|
||||
// before verify/extract by returning a non-ok response.
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
capturedUrl = typeof input === "string" ? input : (input as URL).toString();
|
||||
return { ok: false, status: 500, statusText: "stop" } as Response;
|
||||
});
|
||||
|
||||
await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});
|
||||
|
||||
expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
|
||||
expect(capturedUrl.endsWith("/latest")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyProDownload (Pro path, fail-closed parity)", () => {
|
||||
const PRO_VERSION = "147.0.1.0";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
|
||||
});
|
||||
|
||||
function tmpFile(bytes: Buffer): string {
|
||||
const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
|
||||
fs.writeFileSync(p, bytes);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
|
||||
function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).toString();
|
||||
const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
|
||||
return { ok: true, arrayBuffer: async () => out.buffer } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
const body = (lines: string, version = PRO_VERSION) =>
|
||||
enc(`version=${version}\n${lines}`);
|
||||
|
||||
it("passes when signature is valid and hash matches", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`));
|
||||
await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
|
||||
const file = tmpFile(Buffer.from("a malicious pro binary"));
|
||||
const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
|
||||
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
|
||||
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
// The error TYPE is the contract the ensureBinary router branches on:
|
||||
// BinaryVerificationError => re-throw (never downgrade to free).
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Checksum verification failed/);
|
||||
});
|
||||
|
||||
it("treats a failed manifest fetch as transient, not tampering", async () => {
|
||||
// A failed manifest FETCH must be a plain Error (router falls back to free),
|
||||
// NOT a BinaryVerificationError (which the router re-throws as a hard fail).
|
||||
const file = tmpFile(Buffer.from("x"));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(BinaryVerificationError);
|
||||
});
|
||||
|
||||
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/Version mismatch/);
|
||||
});
|
||||
|
||||
it("rejects a manifest tampered after signing", async () => {
|
||||
const data = Buffer.from("the real pro binary");
|
||||
const file = tmpFile(data);
|
||||
const hash = createHash("sha256").update(data).digest("hex");
|
||||
const good = body(`${hash} ${getArchiveName()}\n`);
|
||||
const sig = sign(good);
|
||||
const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
|
||||
mockManifest(tampered, sig);
|
||||
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BinaryVerificationError);
|
||||
expect(err.message).toMatch(/signature verification failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version binding", () => {
|
||||
it("reads the version= line", () => {
|
||||
expect(
|
||||
parseManifestVersion("version=146.0.7680.177.5\nabc file.tar.gz\n")
|
||||
).toBe("146.0.7680.177.5");
|
||||
});
|
||||
|
||||
it("returns null when absent", () => {
|
||||
expect(parseManifestVersion("abc file.tar.gz\n")).toBeNull();
|
||||
});
|
||||
|
||||
it("old parseChecksums ignores the version line", () => {
|
||||
const result = parseChecksums(
|
||||
`version=146.0.7680.177.5\n${"a".repeat(64)} cloakbrowser-linux-x64.tar.gz\n`
|
||||
);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchSignedManifest", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mockPair = (manifest: string, sig: string, failPrimarySig = false) =>
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as URL).toString();
|
||||
const isSig = url.endsWith(".sig");
|
||||
if (url.includes("cloakbrowser.dev") && isSig && failPrimarySig) {
|
||||
return { ok: false, status: 404 } as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: async () =>
|
||||
new TextEncoder().encode(isSig ? sig : manifest).buffer,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
it("returns manifest + sig from the primary origin", async () => {
|
||||
mockPair("MANIFEST", "U0lH");
|
||||
const result = await fetchSignedManifest("1.2.3.4");
|
||||
expect(new TextDecoder().decode(result!.manifestBytes)).toBe("MANIFEST");
|
||||
expect(new TextDecoder().decode(result!.sigBytes)).toBe("U0lH");
|
||||
});
|
||||
|
||||
it("falls back to GitHub when the primary .sig is missing", async () => {
|
||||
const spy = mockPair("MANIFEST", "U0lH", true);
|
||||
const result = await fetchSignedManifest("1.2.3.4");
|
||||
expect(result).not.toBeNull();
|
||||
// primary SHA256SUMS + primary .sig (404) + github SHA256SUMS + github .sig
|
||||
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns null when everything fails", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
|
||||
expect(await fetchSignedManifest("1.2.3.4")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,14 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
|
||||
const makeLocator = () => {
|
||||
const loc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
return loc;
|
||||
@@ -413,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 });
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0, typing_delay: 0 });
|
||||
const { raw } = buildRawKeyboard();
|
||||
const page = buildMockPage();
|
||||
const cdpCalls: Array<[string, any]> = [];
|
||||
@@ -687,6 +692,9 @@ describe("isInputElement stealth integration via patchPage", () => {
|
||||
}
|
||||
if (method === "Runtime.evaluate") {
|
||||
stealthEvaluateCalls.push(params.expression);
|
||||
if (params.expression.includes("elementFromPoint")) {
|
||||
return { result: { value: { hit: true } } };
|
||||
}
|
||||
return { result: { value: false } }; // not an input
|
||||
}
|
||||
return {};
|
||||
@@ -696,7 +704,7 @@ describe("isInputElement stealth integration via patchPage", () => {
|
||||
const page = buildMockPage({
|
||||
evaluate: vi.fn(async (...args: any[]) => {
|
||||
evaluateCalls.push(args);
|
||||
return false;
|
||||
return { hit: true };
|
||||
}),
|
||||
});
|
||||
page.context = vi.fn(() => ({
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -51,11 +51,11 @@ classifiers = [
|
||||
dependencies = [
|
||||
"playwright>=1.40",
|
||||
"httpx>=0.24",
|
||||
"cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||
patchright = ["patchright>=1.40"]
|
||||
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_backend_env(monkeypatch):
|
||||
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
|
||||
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Unit tests for backend resolution (_resolve_backend)."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.browser import _resolve_backend
|
||||
|
||||
|
||||
def test_resolve_backend_default():
|
||||
"""No param, no env var → 'playwright'."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _resolve_backend(None) == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_playwright():
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_patchright():
|
||||
assert _resolve_backend("patchright") == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_env_var():
|
||||
"""CLOAKBROWSER_BACKEND env var used when no param."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend(None) == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_param_beats_env():
|
||||
"""Explicit param overrides env var."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_raises():
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend("bogus")
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_env_raises():
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend(None)
|
||||
+341
-3
@@ -1,9 +1,12 @@
|
||||
"""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
|
||||
@@ -22,6 +25,8 @@ 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
|
||||
|
||||
@@ -88,6 +93,7 @@ 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):
|
||||
@@ -126,6 +132,31 @@ 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"
|
||||
@@ -136,13 +167,234 @@ 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 TestURLRewriting:
|
||||
"""Test the URL rewriting logic used by /json/version and /json/list."""
|
||||
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."""
|
||||
|
||||
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."""
|
||||
@@ -205,12 +457,21 @@ class TestURLRewriting:
|
||||
class TestConnectionTracking:
|
||||
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
||||
|
||||
def _make_pool(self):
|
||||
def _make_pool(self, idle_timeout: float = 0.0):
|
||||
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):
|
||||
@@ -247,6 +508,83 @@ 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)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary")
|
||||
@patch("playwright.sync_api.sync_playwright")
|
||||
def test_extension_loading(mock_sync_playwright, 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_sync_playwright.return_value.start.return_value = mock_pw
|
||||
|
||||
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
|
||||
+221
-30
@@ -14,6 +14,26 @@ import time
|
||||
import sys
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _mock_el_evaluate(is_input=False):
|
||||
"""Mock evaluate that returns is_input for tagName checks and {hit: True} for pointer events."""
|
||||
def _eval(js, *args, **kwargs):
|
||||
if isinstance(js, str) and "elementFromPoint" in js:
|
||||
return {"hit": True}
|
||||
return is_input
|
||||
return MagicMock(side_effect=_eval)
|
||||
|
||||
|
||||
def _async_mock_el_evaluate(is_input=False):
|
||||
"""Async version of _mock_el_evaluate."""
|
||||
from unittest.mock import AsyncMock
|
||||
async def _eval(js, *args, **kwargs):
|
||||
if isinstance(js, str) and "elementFromPoint" in js:
|
||||
return {"hit": True}
|
||||
return is_input
|
||||
return AsyncMock(side_effect=_eval)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -192,6 +212,59 @@ class TestAsyncCompat:
|
||||
import asyncio
|
||||
assert asyncio.iscoroutinefunction(async_sleep_ms)
|
||||
|
||||
def test_patch_page_async_does_not_crash(self):
|
||||
"""patch_page_async must not raise NameError for missing definitions."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.click = AsyncMock()
|
||||
page.dblclick = AsyncMock()
|
||||
page.hover = AsyncMock()
|
||||
page.type = AsyncMock()
|
||||
page.fill = AsyncMock()
|
||||
page.goto = AsyncMock()
|
||||
page.check = AsyncMock()
|
||||
page.uncheck = AsyncMock()
|
||||
page.select_option = AsyncMock()
|
||||
page.press = AsyncMock()
|
||||
page.is_checked = AsyncMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = AsyncMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = AsyncMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.mouse.move = AsyncMock()
|
||||
page.mouse.click = AsyncMock()
|
||||
page.mouse.wheel = AsyncMock()
|
||||
page.mouse.down = AsyncMock()
|
||||
page.mouse.up = AsyncMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.keyboard.type = AsyncMock()
|
||||
page.keyboard.down = AsyncMock()
|
||||
page.keyboard.up = AsyncMock()
|
||||
page.keyboard.press = AsyncMock()
|
||||
page.keyboard.insert_text = AsyncMock()
|
||||
page.query_selector = AsyncMock(return_value=None)
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
page.wait_for_selector = AsyncMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.return_value = MagicMock()
|
||||
page.main_frame.return_value.child_frames = MagicMock(return_value=[])
|
||||
page.main_frame.child_frames = MagicMock(return_value=[])
|
||||
|
||||
h.patch_page_async(page, cfg, cursor)
|
||||
|
||||
assert hasattr(page, '_original')
|
||||
assert page.select_option != AsyncMock
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Focus check — press / clear / pressSequentially
|
||||
@@ -635,7 +708,7 @@ class TestBrowserBotDetection:
|
||||
time.sleep(0.3)
|
||||
page.locator('#password').fill('SecurePass!123')
|
||||
time.sleep(0.5)
|
||||
page.locator('button[type="submit"]').click()
|
||||
page.locator('#loginForm button[type="submit"]').click()
|
||||
time.sleep(5)
|
||||
body = page.locator('body').text_content()
|
||||
assert '"superHumanSpeed": true' not in body
|
||||
@@ -652,7 +725,7 @@ class TestBrowserBotDetection:
|
||||
t0 = time.time()
|
||||
page.locator('#email').fill('test@example.com')
|
||||
page.locator('#password').fill('MyPassword!99')
|
||||
page.locator('button[type="submit"]').click()
|
||||
page.locator('#loginForm button[type="submit"]').click()
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
time.sleep(3)
|
||||
assert elapsed_ms > 3000
|
||||
@@ -707,7 +780,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True) # is_input
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -738,7 +811,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -779,7 +852,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -819,7 +892,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True) # is input
|
||||
el.evaluate = _mock_el_evaluate(is_input=True) # is input
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -867,7 +940,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -947,7 +1020,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=child)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -971,7 +1044,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1036,7 +1109,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1069,7 +1142,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1114,8 +1187,9 @@ class TestElementHandlePatchingAsync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.evaluate = _async_mock_el_evaluate(is_input=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.wait_for_element_state = AsyncMock()
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
@@ -1155,8 +1229,9 @@ class TestElementHandlePatchingAsync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.evaluate = _async_mock_el_evaluate(is_input=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.wait_for_element_state = AsyncMock()
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
@@ -1376,7 +1451,7 @@ class TestPerCallTimeoutForwarding:
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1389,13 +1464,14 @@ class TestPerCallTimeoutForwarding:
|
||||
captured = {}
|
||||
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
|
||||
captured["timeout"] = timeout
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False)
|
||||
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.click("#slow-button", timeout=5000)
|
||||
|
||||
assert captured.get("timeout") == 5000, f"expected 5000, got {captured}"
|
||||
assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1462,7 +1538,7 @@ class TestPerCallHumanConfigOverride:
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1478,10 +1554,12 @@ class TestPerCallHumanConfigOverride:
|
||||
captured["mistype_chance"] = cfg_arg.mistype_chance
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"), \
|
||||
patch.object(h, "check_pointer_events"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.type(
|
||||
"#email", "hi",
|
||||
@@ -1490,7 +1568,6 @@ class TestPerCallHumanConfigOverride:
|
||||
|
||||
assert captured["typing_delay"] == 30
|
||||
assert captured["mistype_chance"] == 0
|
||||
# Global cfg untouched — per-call override doesn't leak
|
||||
assert cfg.typing_delay == 70
|
||||
|
||||
def test_page_fill_uses_per_call_typing_delay(self):
|
||||
@@ -1512,7 +1589,7 @@ class TestPerCallHumanConfigOverride:
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1527,10 +1604,12 @@ class TestPerCallHumanConfigOverride:
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"), \
|
||||
patch.object(h, "check_pointer_events"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
||||
|
||||
@@ -1562,7 +1641,7 @@ class TestPerCallHumanConfigOverride:
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1608,9 +1687,10 @@ class TestScrollIntoViewIfNeeded:
|
||||
# Box is dead-center of viewport — squarely in scroll_target_zone
|
||||
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
|
||||
|
||||
box, cx, cy = human_scroll_into_view(
|
||||
box, cx, cy, did_scroll = human_scroll_into_view(
|
||||
page, raw, lambda: in_view_box, 0, 0, cfg,
|
||||
)
|
||||
assert not did_scroll, "In-viewport elements shouldn't report scrolling"
|
||||
assert box == in_view_box
|
||||
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
||||
|
||||
@@ -1672,7 +1752,7 @@ class TestScrollIntoViewIfNeeded:
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1683,14 +1763,13 @@ class TestScrollIntoViewIfNeeded:
|
||||
called = {"count": 0}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
||||
page._original, None, None,
|
||||
)
|
||||
# Patched method should now invoke our humanized helper
|
||||
el.scroll_into_view_if_needed()
|
||||
|
||||
assert called["count"] >= 1, "humanized scroll helper was never called"
|
||||
@@ -1735,7 +1814,7 @@ class TestScrollIntoViewIfNeeded:
|
||||
called["count"] += 1
|
||||
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
|
||||
called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200, False)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
Locator.scroll_into_view_if_needed(
|
||||
@@ -1749,6 +1828,118 @@ 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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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")
|
||||
+24
-1
@@ -1,10 +1,33 @@
|
||||
"""Basic launch tests for cloakbrowser."""
|
||||
|
||||
import pytest
|
||||
from cloakbrowser import launch, launch_async, binary_info
|
||||
from cloakbrowser import (
|
||||
launch,
|
||||
launch_async,
|
||||
launch_context,
|
||||
launch_persistent_context,
|
||||
binary_info,
|
||||
)
|
||||
from cloakbrowser.config import get_chromium_version
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env", [None, "patchright"])
|
||||
def test_removed_backend_kwarg_raises(env, monkeypatch):
|
||||
"""The removed `backend` parameter raises a clear TypeError before any
|
||||
launch side effects, regardless of the (also removed) CLOAKBROWSER_BACKEND
|
||||
env var. Guards the patchright removal."""
|
||||
if env is None:
|
||||
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CLOAKBROWSER_BACKEND", env)
|
||||
with pytest.raises(TypeError, match="backend"):
|
||||
launch(backend="patchright")
|
||||
with pytest.raises(TypeError, match="backend"):
|
||||
launch_context(backend="patchright")
|
||||
with pytest.raises(TypeError, match="backend"):
|
||||
launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright")
|
||||
|
||||
|
||||
def test_binary_info():
|
||||
"""binary_info() returns expected structure."""
|
||||
info = binary_info()
|
||||
|
||||
@@ -33,6 +33,82 @@ def test_default_viewport(mock_launch, _mock_bin):
|
||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_headed_no_viewport(mock_launch, _mock_bin):
|
||||
"""Headed (headless=False): no emulated viewport — no_viewport=True so the page
|
||||
tracks the real window (CDP viewport emulation would force outerWidth < innerWidth)."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(headless=False)
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args[1]
|
||||
assert ctx_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in ctx_kwargs
|
||||
|
||||
|
||||
def test_default_no_viewport_helper():
|
||||
"""_default_no_viewport defaults new_page()/new_context() to no_viewport=True,
|
||||
but never overrides an explicit viewport (Playwright rejects passing both)."""
|
||||
from cloakbrowser.browser import _default_no_viewport
|
||||
|
||||
browser = MagicMock()
|
||||
orig_new_page = browser.new_page
|
||||
orig_new_context = browser.new_context
|
||||
_default_no_viewport(browser)
|
||||
|
||||
browser.new_page()
|
||||
orig_new_page.assert_called_once_with(no_viewport=True)
|
||||
browser.new_context()
|
||||
orig_new_context.assert_called_once_with(no_viewport=True)
|
||||
|
||||
# Explicit viewport respected — no_viewport NOT injected.
|
||||
orig_new_page.reset_mock()
|
||||
browser.new_page(viewport={"width": 800, "height": 600})
|
||||
orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_no_viewport_helper_async():
|
||||
"""_default_no_viewport_async mirrors the sync helper for async new_page/new_context."""
|
||||
from cloakbrowser.browser import _default_no_viewport_async
|
||||
|
||||
browser = MagicMock()
|
||||
browser.new_page = AsyncMock()
|
||||
browser.new_context = AsyncMock()
|
||||
orig_new_page = browser.new_page
|
||||
orig_new_context = browser.new_context
|
||||
_default_no_viewport_async(browser)
|
||||
|
||||
await browser.new_page()
|
||||
orig_new_page.assert_awaited_once_with(no_viewport=True)
|
||||
await browser.new_context()
|
||||
orig_new_context.assert_awaited_once_with(no_viewport=True)
|
||||
|
||||
# Explicit viewport respected — no_viewport NOT injected.
|
||||
orig_new_page.reset_mock()
|
||||
await browser.new_page(viewport={"width": 800, "height": 600})
|
||||
orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600})
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin):
|
||||
"""If a caller forces no_viewport via **kwargs alongside viewport=, only one
|
||||
reaches Playwright (which rejects both). The explicit kwargs value wins."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True)
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args[1]
|
||||
assert ctx_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in ctx_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_custom_viewport(mock_launch, _mock_bin):
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Tests for the CloakBrowser Pro license module."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.download import BinaryVerificationError, ensure_binary
|
||||
from cloakbrowser.license import (
|
||||
LicenseInfo,
|
||||
get_pro_latest_version,
|
||||
resolve_license_key,
|
||||
validate_license,
|
||||
)
|
||||
|
||||
|
||||
# ── resolve_license_key ───────────────────────────────
|
||||
|
||||
|
||||
class TestResolveLicenseKey:
|
||||
def test_explicit_param_wins(self):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}):
|
||||
assert resolve_license_key("explicit") == "explicit"
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}):
|
||||
assert resolve_license_key() == "env-key"
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None)
|
||||
assert resolve_license_key() is None
|
||||
|
||||
def test_empty_string_param_uses_env(self):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}):
|
||||
assert resolve_license_key("") == "env-key"
|
||||
|
||||
def test_file_fallback(self, tmp_path):
|
||||
key_file = tmp_path / "license.key"
|
||||
key_file.write_text("file-key-123\n")
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None)
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
assert resolve_license_key() == "file-key-123"
|
||||
|
||||
def test_env_takes_precedence_over_file(self, tmp_path):
|
||||
key_file = tmp_path / "license.key"
|
||||
key_file.write_text("file-key")
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}):
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
assert resolve_license_key() == "env-key"
|
||||
|
||||
def test_no_file_returns_none(self, tmp_path):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None)
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
assert resolve_license_key() is None
|
||||
|
||||
|
||||
# ── validate_license ──────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateLicense:
|
||||
def test_fresh_cache_skips_server(self, tmp_path):
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "team",
|
||||
"expires": "2026-12-01",
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post") as mock_post:
|
||||
result = validate_license("test-key")
|
||||
|
||||
mock_post.assert_not_called()
|
||||
assert result is not None
|
||||
assert result.valid is True
|
||||
assert result.plan == "team"
|
||||
|
||||
def test_stale_cache_calls_server(self, tmp_path):
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": None,
|
||||
"validated_at": time.time() - 90000, # 25 hours ago
|
||||
}))
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post:
|
||||
result = validate_license("test-key")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
assert result is not None
|
||||
assert result.valid is True
|
||||
|
||||
def test_server_success(self, tmp_path):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": True, "plan": "business", "expires": "2026-07-13"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp):
|
||||
result = validate_license("pro-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.valid is True
|
||||
assert result.plan == "business"
|
||||
assert result.expires == "2026-07-13"
|
||||
|
||||
def test_server_rejection(self, tmp_path):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": False, "plan": "solo", "expires": None}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp):
|
||||
result = validate_license("bad-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.valid is False
|
||||
|
||||
def test_server_unreachable_uses_stale_cache(self, tmp_path):
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": "2026-12-01",
|
||||
"validated_at": time.time() - 90000,
|
||||
}))
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")):
|
||||
result = validate_license("test-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.valid is True
|
||||
|
||||
def test_server_unreachable_no_cache_returns_none(self, tmp_path):
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")):
|
||||
result = validate_license("test-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_cache_stores_hash_not_raw_key(self, tmp_path):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp):
|
||||
validate_license("secret-key-123")
|
||||
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
content = cache_path.read_text()
|
||||
assert "secret-key-123" not in content
|
||||
expected_sha = hashlib.sha256(b"secret-key-123").hexdigest()
|
||||
assert expected_sha in content
|
||||
|
||||
def test_expired_license_rejected_from_cache(self, tmp_path):
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": "2020-01-01T00:00:00+00:00",
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
result = validate_license("test-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.valid is False
|
||||
|
||||
def test_expired_license_naive_date_rejected(self, tmp_path):
|
||||
"""Date-only string (naive datetime) should also be detected as expired."""
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": "2020-01-01",
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
result = validate_license("test-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.valid is False
|
||||
|
||||
def test_wrong_key_cache_ignored(self, tmp_path):
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": "other-hash",
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": None,
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post:
|
||||
validate_license("different-key")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_corrupted_validated_at_does_not_crash(self, tmp_path):
|
||||
"""A non-numeric validated_at must be treated as an absent cache, not crash."""
|
||||
cache_path = tmp_path / ".license_cache"
|
||||
key_sha = hashlib.sha256(b"test-key").hexdigest()
|
||||
cache_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": True,
|
||||
"plan": "solo",
|
||||
"expires": None,
|
||||
"validated_at": "not-a-number",
|
||||
}))
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post:
|
||||
result = validate_license("test-key")
|
||||
|
||||
mock_post.assert_called_once() # corrupted cache ignored → server hit
|
||||
assert result is not None
|
||||
assert result.valid is True
|
||||
|
||||
|
||||
# ── get_pro_latest_version ────────────────────────────
|
||||
|
||||
|
||||
class TestGetProLatestVersion:
|
||||
def test_fetches_version(self, tmp_path):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"version": "147.0.1234.5"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.get", return_value=mock_resp):
|
||||
version = get_pro_latest_version()
|
||||
|
||||
assert version == "147.0.1234.5"
|
||||
|
||||
def test_rate_limited(self, tmp_path):
|
||||
marker = tmp_path / ".last_pro_version_check"
|
||||
marker.write_text("147.0.1234.5")
|
||||
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.get") as mock_get:
|
||||
version = get_pro_latest_version()
|
||||
|
||||
mock_get.assert_not_called()
|
||||
assert version == "147.0.1234.5"
|
||||
|
||||
def test_network_error_returns_none(self, tmp_path):
|
||||
with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path):
|
||||
with patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")):
|
||||
version = get_pro_latest_version()
|
||||
|
||||
assert version is None
|
||||
|
||||
|
||||
# ── Config pro parameter ──────────────────────────────
|
||||
|
||||
|
||||
class TestConfigPro:
|
||||
def test_binary_dir_pro_suffix(self, tmp_path):
|
||||
from cloakbrowser.config import get_binary_dir
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
normal = get_binary_dir("147.0.0.0")
|
||||
pro = get_binary_dir("147.0.0.0", pro=True)
|
||||
|
||||
assert str(normal).endswith("chromium-147.0.0.0")
|
||||
assert str(pro).endswith("chromium-147.0.0.0-pro")
|
||||
|
||||
def test_binary_dir_default_no_suffix(self, tmp_path):
|
||||
from cloakbrowser.config import get_binary_dir
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
normal = get_binary_dir("147.0.0.0")
|
||||
|
||||
assert not str(normal).endswith("-pro")
|
||||
|
||||
def test_effective_version_pro_marker(self, tmp_path):
|
||||
from cloakbrowser.config import get_effective_version, get_platform_tag
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
tag = get_platform_tag()
|
||||
marker = tmp_path / f"latest_pro_version_{tag}"
|
||||
marker.write_text("147.0.5555.1")
|
||||
|
||||
# Create the binary so effective version returns it
|
||||
from cloakbrowser.config import get_binary_path
|
||||
bp = get_binary_path("147.0.5555.1", pro=True)
|
||||
bp.parent.mkdir(parents=True, exist_ok=True)
|
||||
bp.write_text("fake")
|
||||
|
||||
version = get_effective_version(pro=True)
|
||||
|
||||
assert version == "147.0.5555.1"
|
||||
|
||||
|
||||
# ── binary_info tier reporting ────────────────────────
|
||||
|
||||
|
||||
class TestBinaryInfoTier:
|
||||
"""binary_info() reports tier from the binary actually on disk — NOT from a
|
||||
cached license, which can disagree with what's installed or the active key."""
|
||||
|
||||
def test_free_when_no_pro_binary_even_if_license_cached(self, tmp_path):
|
||||
from cloakbrowser.download import binary_info
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False):
|
||||
# A valid, fresh license is cached...
|
||||
(tmp_path / ".license_cache").write_text(json.dumps({
|
||||
"key_sha256": hashlib.sha256(b"cb_x").hexdigest(),
|
||||
"valid": True, "plan": "solo", "expires": None,
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
# ...but no Pro binary is on disk → must report free, not pro.
|
||||
info = binary_info()
|
||||
|
||||
assert info["tier"] == "free"
|
||||
|
||||
def test_pro_when_pro_binary_installed(self, tmp_path):
|
||||
from cloakbrowser.config import get_binary_path, get_platform_tag
|
||||
from cloakbrowser.download import binary_info
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False):
|
||||
tag = get_platform_tag()
|
||||
(tmp_path / f"latest_pro_version_{tag}").write_text("147.0.5555.1")
|
||||
bp = get_binary_path("147.0.5555.1", pro=True)
|
||||
bp.parent.mkdir(parents=True, exist_ok=True)
|
||||
bp.write_text("fake")
|
||||
bp.chmod(0o755)
|
||||
info = binary_info()
|
||||
|
||||
assert info["tier"] == "pro"
|
||||
assert info["version"] == "147.0.5555.1"
|
||||
|
||||
|
||||
# ── ensure_binary Pro routing (fail-closed vs fall-back) ──────────────────────
|
||||
|
||||
|
||||
class TestEnsureBinaryProRouting:
|
||||
"""A valid-license user is NEVER silently downgraded to the free binary. Both
|
||||
a tampering signal (verification failure) and a transient failure
|
||||
(network/server) surface a clear error — they differ only in the message:
|
||||
tampering is re-raised verbatim (security, no 'retry'); transient is rewrapped
|
||||
as an actionable 'Pro binary unavailable, retry' error carrying the cause."""
|
||||
|
||||
def test_verification_failure_propagates_verbatim(self):
|
||||
"""A BinaryVerificationError must surface verbatim — never reach free."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \
|
||||
patch("cloakbrowser.download.get_local_binary_override", return_value=None), \
|
||||
patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \
|
||||
patch("cloakbrowser.license.validate_license",
|
||||
return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \
|
||||
patch("cloakbrowser.download._ensure_pro_binary",
|
||||
side_effect=BinaryVerificationError("bad signature")), \
|
||||
patch("cloakbrowser.download.check_platform_available",
|
||||
side_effect=AssertionError("MUST NOT reach the free-tier path")):
|
||||
with pytest.raises(BinaryVerificationError, match="bad signature"):
|
||||
ensure_binary("cb_x")
|
||||
|
||||
def test_transient_failure_hard_errors_not_free(self):
|
||||
"""A transient Pro failure must surface a clear, actionable error carrying
|
||||
the underlying cause — NOT silently download the free binary."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \
|
||||
patch("cloakbrowser.download.get_local_binary_override", return_value=None), \
|
||||
patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \
|
||||
patch("cloakbrowser.license.validate_license",
|
||||
return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \
|
||||
patch("cloakbrowser.download._ensure_pro_binary",
|
||||
side_effect=RuntimeError("network blip")), \
|
||||
patch("cloakbrowser.download.check_platform_available",
|
||||
side_effect=AssertionError("MUST NOT reach the free-tier path")):
|
||||
with pytest.raises(RuntimeError, match="Pro binary unavailable: network blip"):
|
||||
ensure_binary("cb_x")
|
||||
@@ -55,6 +55,22 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
||||
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
def test_persistent_context_headed_no_viewport(_mock_geoip, _mock_bin):
|
||||
"""Headed (headless=False): no_viewport=True instead of DEFAULT_VIEWPORT so the
|
||||
page tracks the real window (avoids the outerWidth < innerWidth tell)."""
|
||||
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", headless=False)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in call_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||
@@ -165,10 +181,11 @@ 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):
|
||||
"""Proxy string parsed and passed."""
|
||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform):
|
||||
"""Proxy string parsed and passed (unsupported platform → Playwright dict)."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
@@ -257,3 +274,32 @@ 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")
|
||||
|
||||
+118
-14
@@ -55,12 +55,12 @@ class TestBuildProxyKwargs:
|
||||
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_with_auth(self):
|
||||
@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, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
@@ -68,7 +68,9 @@ class TestBuildProxyKwargs:
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
@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, *_):
|
||||
proxy_dict = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
@@ -76,8 +78,11 @@ class TestBuildProxyKwargs:
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
assert args == [
|
||||
"--proxy-server=http://user:pass@proxy:8080",
|
||||
"--proxy-bypass-list=.example.com",
|
||||
]
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@@ -183,11 +188,12 @@ class TestBareProxyFormat:
|
||||
r = _parse_proxy_url("proxy:8080")
|
||||
assert r == {"server": "proxy:8080"}
|
||||
|
||||
def test_resolve_proxy_config_bare(self):
|
||||
@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, *_):
|
||||
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert kwargs["proxy"]["password"] == "pass"
|
||||
assert "user" not in kwargs["proxy"]["server"]
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
|
||||
class TestIsSocksProxy:
|
||||
@@ -219,11 +225,17 @@ class TestResolveProxyConfig:
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_http_string_returns_playwright_dict(self):
|
||||
@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, *_):
|
||||
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):
|
||||
@@ -374,3 +386,95 @@ 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 == []
|
||||
|
||||
@@ -259,9 +259,8 @@ class TestIssueRegressions:
|
||||
def test_add_init_script_with_proxy(self, browser):
|
||||
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
|
||||
|
||||
Patchright bug: add_init_script breaks proxy auth. This test guards
|
||||
against regression if/when the upstream fix lands. Uses context-level
|
||||
proxy to avoid launching a separate browser (event loop conflict).
|
||||
Uses context-level proxy to avoid launching a separate browser
|
||||
(event loop conflict).
|
||||
"""
|
||||
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
|
||||
if not proxy:
|
||||
@@ -276,11 +275,6 @@ class TestIssueRegressions:
|
||||
val = page.evaluate("window.__cloaktest")
|
||||
assert val == 99, f"init_script value wrong: {val}"
|
||||
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
|
||||
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
|
||||
raise
|
||||
finally:
|
||||
page.close()
|
||||
ctx.close()
|
||||
|
||||
@@ -1010,7 +1010,11 @@ class TestPatchPageStealthWiring:
|
||||
fake_box = {"x": 100, "y": 200, "width": 200, "height": 30}
|
||||
with mock_patch(
|
||||
"cloakbrowser.human.scroll_to_element",
|
||||
return_value=(fake_box, 200.0, 215.0),
|
||||
return_value=(fake_box, 200.0, 215.0, False),
|
||||
), mock_patch(
|
||||
"cloakbrowser.human.ensure_actionable",
|
||||
), mock_patch(
|
||||
"cloakbrowser.human.check_pointer_events",
|
||||
):
|
||||
try:
|
||||
page.click("#btn")
|
||||
|
||||
+356
-1
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from cloakbrowser.config import (
|
||||
CHROMIUM_VERSION,
|
||||
@@ -19,13 +21,20 @@ from cloakbrowser.config import (
|
||||
get_platform_tag,
|
||||
)
|
||||
from cloakbrowser.download import (
|
||||
BinaryVerificationError,
|
||||
_check_wrapper_update,
|
||||
_download_and_extract,
|
||||
_download_pro_binary,
|
||||
_fetch_checksums,
|
||||
_fetch_signed_manifest,
|
||||
_get_latest_chromium_version,
|
||||
_parse_checksums,
|
||||
_parse_manifest_version,
|
||||
_should_check_for_update,
|
||||
_verify_checksum,
|
||||
_verify_download_checksum,
|
||||
_verify_pro_download,
|
||||
_verify_signature,
|
||||
_write_version_marker,
|
||||
check_for_update,
|
||||
clear_cache,
|
||||
@@ -486,7 +495,6 @@ class TestDownloadFallback:
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
||||
}):
|
||||
urls_called = []
|
||||
|
||||
@@ -497,7 +505,10 @@ class TestDownloadFallback:
|
||||
# GitHub fallback succeeds
|
||||
dest.write_bytes(b"fake")
|
||||
|
||||
# This test exercises URL fallback, not verification — stub the
|
||||
# (now signature-based, non-bypassable) verify step.
|
||||
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
|
||||
patch("cloakbrowser.download._verify_download_checksum"), \
|
||||
patch("cloakbrowser.download._extract_archive"), \
|
||||
patch("cloakbrowser.download._show_welcome"):
|
||||
_download_and_extract()
|
||||
@@ -548,3 +559,347 @@ class TestDownloadFallback:
|
||||
result = _fetch_checksums()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signed-manifest verification (Ed25519). Trust root is the pinned public key,
|
||||
# not the same-origin SHA256SUMS — this is what closes M1 (#308).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_key():
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
raw = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return priv, base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
def _sign(priv, manifest_bytes: bytes) -> bytes:
|
||||
"""Return SHA256SUMS.sig content (base64 of the raw signature), as served."""
|
||||
return base64.b64encode(priv.sign(manifest_bytes))
|
||||
|
||||
|
||||
class TestSignatureVerification:
|
||||
"""_verify_signature: the cryptographic gate over the raw manifest bytes."""
|
||||
|
||||
def test_valid_signature_passes(self):
|
||||
priv, pub_b64 = _make_key()
|
||||
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
|
||||
sig = _sign(priv, manifest)
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
||||
_verify_signature(manifest, sig) # no raise
|
||||
|
||||
def test_tampered_manifest_fails(self):
|
||||
priv, pub_b64 = _make_key()
|
||||
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
|
||||
sig = _sign(priv, manifest)
|
||||
tampered = manifest.replace(b"abc", b"xyz")
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
||||
with pytest.raises(RuntimeError, match="signature verification failed"):
|
||||
_verify_signature(tampered, sig)
|
||||
|
||||
def test_wrong_key_fails(self):
|
||||
priv, _ = _make_key()
|
||||
_, other_pub = _make_key()
|
||||
manifest = b"data\n"
|
||||
sig = _sign(priv, manifest)
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [other_pub]):
|
||||
with pytest.raises(RuntimeError, match="signature verification failed"):
|
||||
_verify_signature(manifest, sig)
|
||||
|
||||
def test_malformed_signature_fails(self):
|
||||
_, pub_b64 = _make_key()
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
|
||||
with pytest.raises(RuntimeError, match="Malformed"):
|
||||
_verify_signature(b"data\n", b"!!!not base64!!!")
|
||||
|
||||
def test_placeholder_key_is_skipped_not_crashing(self):
|
||||
"""An unparseable pinned key (placeholder) must not abort — a real key still validates."""
|
||||
priv, pub_b64 = _make_key()
|
||||
manifest = b"data\n"
|
||||
sig = _sign(priv, manifest)
|
||||
with patch(
|
||||
"cloakbrowser.download.BINARY_SIGNING_PUBKEYS",
|
||||
["REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub_b64],
|
||||
):
|
||||
_verify_signature(manifest, sig) # no raise
|
||||
|
||||
def test_key_rotation_second_key_accepts(self):
|
||||
"""A manifest signed with the new key validates while the old key stays pinned."""
|
||||
old_priv, old_pub = _make_key()
|
||||
new_priv, new_pub = _make_key()
|
||||
manifest = b"rotated\n"
|
||||
sig = _sign(new_priv, manifest)
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [old_pub, new_pub]):
|
||||
_verify_signature(manifest, sig) # no raise
|
||||
|
||||
|
||||
class TestVerifyDownloadChecksumSigned:
|
||||
"""_verify_download_checksum on the official path: signature + version + hash, fail-closed."""
|
||||
|
||||
def _hash(self, data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
def _manifest(self, body: str, version: str | None = None) -> bytes:
|
||||
"""Build a signed-manifest body with the bound version line prepended."""
|
||||
v = version if version is not None else get_chromium_version()
|
||||
return f"version={v}\n{body}".encode()
|
||||
|
||||
def test_valid_manifest_and_hash_passes(self, tmp_path):
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real binary")
|
||||
tarball = get_download_url().rsplit("/", 1)[-1]
|
||||
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
|
||||
sig = _sign(priv, manifest)
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
|
||||
_verify_download_checksum(archive) # no raise
|
||||
|
||||
def test_tampered_binary_fails_hash(self, tmp_path):
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"a malicious binary") # different bytes
|
||||
tarball = get_download_url().rsplit("/", 1)[-1]
|
||||
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
|
||||
sig = _sign(priv, manifest)
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
||||
_verify_download_checksum(archive)
|
||||
|
||||
def test_wrong_version_fails_downgrade(self, tmp_path):
|
||||
"""A genuinely-signed manifest for a DIFFERENT version is rejected (downgrade)."""
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real binary")
|
||||
tarball = get_download_url().rsplit("/", 1)[-1]
|
||||
# Manifest declares an old version, but we ask for get_chromium_version().
|
||||
manifest = self._manifest(
|
||||
f"{self._hash(b'the real binary')} {tarball}\n", version="1.0.0.0"
|
||||
)
|
||||
sig = _sign(priv, manifest)
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="Version mismatch"):
|
||||
_verify_download_checksum(archive)
|
||||
|
||||
def test_missing_version_line_fails(self, tmp_path):
|
||||
"""A signed manifest without a version line is rejected (binding required)."""
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real binary")
|
||||
tarball = get_download_url().rsplit("/", 1)[-1]
|
||||
manifest = f"{self._hash(b'the real binary')} {tarball}\n".encode() # no version=
|
||||
sig = _sign(priv, manifest)
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="Version mismatch"):
|
||||
_verify_download_checksum(archive)
|
||||
|
||||
def test_missing_signed_manifest_fails_closed(self, tmp_path):
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"x")
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="signed SHA256SUMS"):
|
||||
_verify_download_checksum(archive)
|
||||
|
||||
def test_manifest_without_entry_fails(self, tmp_path):
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"x")
|
||||
manifest = self._manifest("deadbeef some-other-file.tar.gz\n") # no entry for our tarball
|
||||
sig = _sign(priv, manifest)
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="no entry for"):
|
||||
_verify_download_checksum(archive)
|
||||
|
||||
def test_custom_url_uses_plain_checksum_and_skip(self, tmp_path):
|
||||
"""Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path."""
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"x")
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.test",
|
||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
||||
}):
|
||||
# Signature path must NOT be consulted for a custom mirror.
|
||||
with patch("cloakbrowser.download._fetch_signed_manifest") as mocked:
|
||||
_verify_download_checksum(archive) # skip honored, no raise
|
||||
mocked.assert_not_called()
|
||||
|
||||
|
||||
class TestVerifyProDownloadSigned:
|
||||
"""_verify_pro_download: Pro binaries get the SAME non-bypassable signature
|
||||
check as the free official path (parity — closes the Pro M1 gap)."""
|
||||
|
||||
PRO_VERSION = "147.0.1.0"
|
||||
|
||||
def _hash(self, data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
def _tarball(self) -> str:
|
||||
return get_download_url().rsplit("/", 1)[-1]
|
||||
|
||||
def _mock_fetch(self, manifest: bytes, sig: bytes):
|
||||
"""httpx.get stub: returns the .sig for *.sig URLs, manifest otherwise."""
|
||||
def mock_get(url, **kwargs):
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.content = sig if url.endswith(".sig") else manifest
|
||||
return resp
|
||||
return mock_get
|
||||
|
||||
def test_valid_pro_manifest_passes(self, tmp_path):
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real pro binary")
|
||||
manifest = (
|
||||
f"version={self.PRO_VERSION}\n"
|
||||
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
||||
).encode()
|
||||
sig = _sign(priv, manifest)
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)):
|
||||
_verify_pro_download(archive, self.PRO_VERSION) # no raise
|
||||
|
||||
def test_skip_checksum_does_not_bypass(self, tmp_path):
|
||||
"""CLOAKBROWSER_SKIP_CHECKSUM must NOT weaken Pro verification (the point)."""
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"a malicious pro binary") # bytes differ from manifest
|
||||
manifest = (
|
||||
f"version={self.PRO_VERSION}\n"
|
||||
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
||||
).encode()
|
||||
sig = _sign(priv, manifest)
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_SKIP_CHECKSUM": "true"}), \
|
||||
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="Checksum verification failed"):
|
||||
_verify_pro_download(archive, self.PRO_VERSION)
|
||||
|
||||
def test_missing_manifest_is_transient_not_tampering(self, tmp_path):
|
||||
"""A failed manifest FETCH is transient (router falls back to free), so it
|
||||
must be a plain RuntimeError — NOT a BinaryVerificationError, which the
|
||||
router re-raises as a hard failure."""
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"x")
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("404")):
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
_verify_pro_download(archive, self.PRO_VERSION)
|
||||
assert not isinstance(ei.value, BinaryVerificationError)
|
||||
|
||||
def test_wrong_version_fails_downgrade(self, tmp_path):
|
||||
"""A genuinely-signed Pro manifest for a DIFFERENT version is rejected."""
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real pro binary")
|
||||
manifest = (
|
||||
f"version=1.0.0.0\n" # declares old version, we ask for PRO_VERSION
|
||||
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
||||
).encode()
|
||||
sig = _sign(priv, manifest)
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(manifest, sig)):
|
||||
with pytest.raises(RuntimeError, match="Version mismatch"):
|
||||
_verify_pro_download(archive, self.PRO_VERSION)
|
||||
|
||||
def test_tampered_manifest_fails_signature(self, tmp_path):
|
||||
"""A manifest tampered after signing fails the signature gate (not the hash)."""
|
||||
priv, pub_b64 = _make_key()
|
||||
archive = tmp_path / "binary"
|
||||
archive.write_bytes(b"the real pro binary")
|
||||
manifest = (
|
||||
f"version={self.PRO_VERSION}\n"
|
||||
f"{self._hash(b'the real pro binary')} {self._tarball()}\n"
|
||||
).encode()
|
||||
sig = _sign(priv, manifest)
|
||||
tampered = manifest.replace(self._tarball().encode(), b"evil.tar.gz")
|
||||
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
|
||||
patch("cloakbrowser.download.httpx.get", side_effect=self._mock_fetch(tampered, sig)):
|
||||
with pytest.raises(RuntimeError, match="signature verification failed"):
|
||||
_verify_pro_download(archive, self.PRO_VERSION)
|
||||
|
||||
|
||||
class TestProDownloadVersionPinned:
|
||||
"""The Pro download must request the explicit version, NOT /latest, so the
|
||||
served artifact matches the version-pinned signed manifest it's verified
|
||||
against (no latest-advances TOCTOU)."""
|
||||
|
||||
def test_download_url_is_version_pinned(self):
|
||||
from cloakbrowser.config import DOWNLOAD_BASE_URL
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_download_file(url, dest, headers=None):
|
||||
captured["url"] = url
|
||||
|
||||
with patch("cloakbrowser.download._download_file", side_effect=fake_download_file), \
|
||||
patch("cloakbrowser.download._verify_pro_download"), \
|
||||
patch("cloakbrowser.download._extract_archive"):
|
||||
_download_pro_binary("147.0.1.0", "cb_key")
|
||||
|
||||
assert captured["url"] == f"{DOWNLOAD_BASE_URL}/api/download/147.0.1.0"
|
||||
assert not captured["url"].endswith("/latest")
|
||||
|
||||
|
||||
class TestVersionBinding:
|
||||
"""The 'version=<v>' line: read by new wrappers, ignored by old parsers."""
|
||||
|
||||
def test_parse_manifest_version(self):
|
||||
manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n"
|
||||
assert _parse_manifest_version(manifest) == "146.0.7680.177.5"
|
||||
|
||||
def test_parse_manifest_version_absent(self):
|
||||
assert _parse_manifest_version("abc cloakbrowser-linux-x64.tar.gz\n") is None
|
||||
|
||||
def test_old_checksum_parser_ignores_version_line(self):
|
||||
"""Regression: the version line must not pollute the old hash map."""
|
||||
h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
manifest = f"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n"
|
||||
result = _parse_checksums(manifest)
|
||||
assert result == {"cloakbrowser-linux-x64.tar.gz": h}
|
||||
|
||||
|
||||
class TestFetchSignedManifest:
|
||||
"""_fetch_signed_manifest pairs SHA256SUMS + .sig from the same origin."""
|
||||
|
||||
def test_fetches_both_from_primary(self):
|
||||
def mock_get(url, **kwargs):
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
|
||||
return resp
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
||||
result = _fetch_signed_manifest("1.2.3.4")
|
||||
assert result == (b"MANIFEST", b"SIG")
|
||||
|
||||
def test_falls_back_to_github_when_primary_missing_sig(self):
|
||||
def mock_get(url, **kwargs):
|
||||
resp = MagicMock()
|
||||
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
|
||||
if "cloakbrowser.dev" in url and url.endswith(".sig"):
|
||||
resp.raise_for_status.side_effect = Exception("404")
|
||||
else:
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
||||
result = _fetch_signed_manifest("1.2.3.4")
|
||||
assert result == (b"MANIFEST", b"SIG")
|
||||
|
||||
def test_returns_none_when_all_fail(self):
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network")):
|
||||
assert _fetch_signed_manifest("1.2.3.4") is None
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user