Compare commits

...
22 Commits
Author SHA1 Message Date
CloakHQ 4e95822446 release: v0.3.3 — Windows x64, macOS v145, auto-spoof docs
Bump wrapper to 0.3.3. Update README fingerprint section to
document auto-spoof behavior (zero-config stealth). Improve
reCAPTCHA test with wait_for_selector instead of blind sleep.
2026-03-03 20:05:16 +01:00
CloakHQandDurafen 408a582117 feat: add Windows x64 support, update macOS to v145 2026-03-03 08:57:07 +01:00
CloakHQandDurafen fe567d7a5a feat: macOS v145 wrapper prep — GPU flags, version bump, README update
- Add explicit Mac GPU flags (Apple M3 Metal renderer) to stealth args
- Bump macOS platform versions to 145.0.7632.109
- Update README fingerprint table to reflect actual Mac GPU defaults
- Add warning about binary requiring explicit flags without wrapper
- Fix stealth_test.py wait_until for reCAPTCHA page
2026-03-03 08:57:07 +01:00
CloakHQ 53c9eb581d docs: add binary license, update license sections in READMEs 2026-03-03 08:48:05 +01:00
CloakHQ d8960447a0 feat: add wrapper version update checks for PyPI and npm
Check for newer wrapper versions on startup (once per process).
Python queries PyPI, JS queries npm registry. Respects
CLOAKBROWSER_AUTO_UPDATE=false and CLOAKBROWSER_DOWNLOAD_URL
(custom mirror mode skips external registry calls).

Includes unit tests for both languages covering: update detection,
env var gating, network error handling, and once-per-process guard.
2026-03-03 01:21:41 +01:00
CloakHQ 1ad2b8d3a9 docs: release v0.3.0 changelog, expand PyPI keywords 2026-03-02 23:42:23 +01:00
CloakHQ 3afe20cda2 fix: sync wrapper with v11-v13 binary changes
- Rename --timezone to --fingerprint-timezone (breaking binary change in v13)
- Remove --fingerprint-taskbar-height from defaults (binary auto-applies per platform)
- Update viewport height 955→947 (48px Win taskbar default)
- Update patch count 26→25 (patch 003 deleted in v11)
- Document new flags: --fingerprint-fonts-dir, --enable-blink-features=FakeShadowRoot, --fingerprint-taskbar-height (optional override)
- Fix README: remove incorrect "defaults to windows" claim
2026-03-02 23:13:04 +01:00
CloakHQ 3b256c413b docs: overhaul README — hero GIF, comparison table, streamlined structure
- Move Turnstile GIF and comparison table to hero section
- Add migration diff, Docker proxy example, geoip install note
- Consolidate duplicate sections (timezone/locale, Playwright migration)
- Update Camoufox status, Chromium 145 to released, test date to Mar 2026
- Add missing fingerprint flags to default args table
- Remove redundant Puppeteer code block, simplify to inline reference
- Add SHA-256 checksum verification mention
- Add AI browser agent compatibility note
2026-03-02 17:47:41 +01:00
CloakHQ a4b6caff47 fix: code review — breaking change docs, version marker migration, checksum tests
- Document playwright→patchright breaking change in CHANGELOG
- Document default viewport change (1920x955) in CHANGELOG
- Add legacy latest_version marker fallback for <0.3.0 upgrades
- Add checksum parsing/verification tests (Python + JS)
- Document CLOAKBROWSER_SKIP_CHECKSUM env var in README
- Fix case-insensitive SHA256SUMS regex in JS
- Replace page.wait_for_timeout() with time.sleep() in example
2026-03-02 09:03:57 +01:00
CloakHQ fc10bdf13e feat: per-platform Chromium versioning and build number support
- Add PLATFORM_CHROMIUM_VERSIONS map (Linux=v145, macOS=v142)
- Add get_chromium_version()/getChromiumVersion() for platform-specific version
- Make auto-update check release assets before offering updates
- Scope version markers per-platform (latest_version_linux-x64)
- Support 5th version segment for hotfix builds (e.g. 145.0.7632.109.2)
- Derive AVAILABLE_PLATFORMS from version map
2026-03-02 08:23:44 +01:00
CloakHQ 4c2e06682b docs: add CHANGELOG.md and bump version to 0.3.0
- Add CHANGELOG.md with v0.3.0 release notes (Chromium 145, 26 patches)
- Bump Python and JS package versions from 0.2.2 to 0.3.0
- Update README with v0.3.0 highlights section and changelog link
- Correct roadmap status for Chromium 145 build
2026-03-02 03:07:27 +01:00
CloakHQ cb08a602b0 feat: add SHA-256 checksum verification for binary downloads
Fetches SHA256SUMS sidecar file from download server before extraction.
Mismatch = hard error, unavailable = warn and proceed (graceful for old releases).
Respects CLOAKBROWSER_DOWNLOAD_URL contract (no GitHub fallback for custom mirrors).
Skip with CLOAKBROWSER_SKIP_CHECKSUM=true. Both Python and JS wrappers.
2026-03-02 02:59:25 +01:00
CloakHQ 8eb666885f fix: review fixes — bump CHROMIUM_VERSION to v145, add colorScheme to JS, guard download fallback 2026-03-02 02:59:23 +01:00
CloakHQ f417fe2530 feat: add --fingerprint-device-memory=8 to default stealth args
v10 binary requires explicit --fingerprint-device-memory flag (no longer
hardcoded). Without it, navigator.deviceMemory passes through real value.
2026-03-02 02:59:21 +01:00
CloakHQ c65939af14 feat: wire timezone/locale params to Chromium binary flags
launch() and launch_context() now inject --timezone and --lang binary
flags when timezone/locale params are set. Previously only the Playwright
context layer was configured, leaving a detectable mismatch that CreepJS
flagged as a bot signal.

Closes: WM5
2026-03-02 02:59:18 +01:00
CloakHQ 2f1f592b3a feat: add GitHub Releases fallback for binary downloads
If cloakbrowser.dev is unreachable, automatically retry download
from GitHub Releases. Applies to both Python and JS wrappers.
2026-03-02 02:59:17 +01:00
CloakHQ 0bbc170747 feat: upgrade to Chromium v145 with Patchright CDP stealth
- Migrate from Playwright to Patchright driver for CDP leak prevention
- Add screen dimension spoofing args (--fingerprint-screen-width/height, --fingerprint-taskbar-height)
- Add realistic default viewport (1920x955) to avoid Playwright detection
- Add color_scheme param to launch_context, add fingerprint scan test
- Update READMEs for v145
2026-03-02 02:59:15 +01:00
CloakHQ 6506b5fe44 fix: switch download badges to total counts with consistent green styling
Use shields.io/pepy for PyPI and shields.io/npm for npm — both show
total downloads in green, matching the rest of the badge row.
2026-03-02 01:15:26 +01:00
CloakHQ 1082c810af fix: replace page.wait_for_timeout with time.sleep to avoid CDP leak
page.wait_for_timeout() sends CDP protocol commands that reCAPTCHA and
other antibot systems detect. Replaced with time.sleep() (Python) which
is invisible to the browser.

- examples/stealth_test.py: 7 replacements
- examples/recaptcha_score.py: 1 replacement
- tests/test_stealth.py: 7 replacements
- README.md + js/README.md: added reCAPTCHA troubleshooting section
- Bump version to 0.2.2
2026-03-01 19:37:41 +01:00
CloakHQ 67efadef26 feat: auto-detect timezone/locale from proxy IP via geoip
Adds geoip=True parameter to launch(), launch_async(), and
launch_context(). Resolves proxy exit IP → MaxMind GeoLite2-City
lookup → timezone + locale. Downloads ~70MB DB on first use from
P3TERX mirror, caches in ~/.cloakbrowser/geoip/.

Optional deps: pip install cloakbrowser[geoip] / npm install mmdb-lib
Explicit timezone/locale always override auto-detected values.
2026-03-01 01:53:50 +01:00
CloakHQ 59b9d71684 docs: add fixed fingerprint seed tip for site revisiting 2026-02-28 04:38:27 +01:00
CloakHQ f480958ba7 test: add real bot detection assertions to stealth tests
Replace hollow page-load checks with actual verdict parsing for 6
detection sites: sannysoft, incolumitas, browserscan, deviceandbrowserinfo,
fingerprintjs, and recaptcha v3. Add proxy support via CLOAKBROWSER_TEST_PROXY
env var. All 12 stealth tests pass.
2026-02-27 09:20:56 +01:00
32 changed files with 2742 additions and 276 deletions
+1 -1
View File
@@ -40,6 +40,6 @@ jobs:
# Binary auto-downloads on first launch # Binary auto-downloads on first launch
``` ```
> Checksums and platform list will be added after binary uploads. > Binary integrity is verified automatically via SHA-256 checksums on download.
> >
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD` > Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
+95
View File
@@ -0,0 +1,95 @@
# CloakBrowser Binary License
**Version 1.0 — February 2026**
Copyright (c) 2026 CloakHQ. All rights reserved.
This license applies to the compiled CloakBrowser Chromium binary ("Binary") distributed via GitHub Releases and cloakbrowser.dev. It does **not** apply to the wrapper source code in this repository, which is licensed under the [MIT License](LICENSE).
By downloading, installing, or using the Binary, you agree to be bound by the terms of this license.
## Intellectual Property
The Binary is built on Chromium, which is open-source software by The Chromium Authors under the BSD 3-Clause License, and incorporates components from the open-source ungoogled-chromium project. CloakHQ's build configuration, patches, and the Binary as a combined work are the proprietary property of CloakHQ. This license governs the Binary as distributed by CloakHQ — it does not restrict rights granted by upstream open-source licenses to their respective components.
## 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.
## Restrictions
You may NOT:
1. **Redistribute** the Binary, in whole or in part, whether modified or unmodified
2. **Resell, sublicense, or repackage** the Binary, or include it in any product or service distributed to third parties
3. **Reverse engineer, decompile, or disassemble** the Binary, or attempt to derive source code from it, except to the extent permitted by applicable law
4. **Modify** the Binary or create derivative works based on it
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
Listing CloakBrowser as a dependency in your project (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution — end users download the Binary directly from official CloakHQ channels.
Internal caching or mirroring (including via artifact repositories such as Artifactory or Nexus) of unmodified Binaries that were originally obtained from official CloakHQ distribution channels is permitted solely for internal operational purposes within your organization. This permission does not allow public redistribution or distribution to third parties.
## Official Distribution
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Restrictions section are not considered unauthorized sources.
## Trademark Notice
This license does not grant you any right to use the CloakHQ or CloakBrowser name, logo, or trademarks, except for nominative use reasonably necessary to refer to CloakHQ or CloakBrowser.
## Attribution
Attribution is appreciated but not required. If you'd like to credit CloakBrowser, a "Powered by CloakBrowser" notice with a link to https://github.com/CloakHQ/CloakBrowser in your documentation, README, or about page is welcome.
## Acceptable Use
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
## Indemnification
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
## Disclaimer
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
## Data Collection
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
## Updates
CloakHQ is under no obligation to provide updates, patches, new versions, or support for the Binary. Updates, when provided, are subject to the terms of this license.
## Termination
This license terminates automatically if you violate any of its terms. Upon termination, you must destroy all copies of the Binary in your possession. The Intellectual Property, Restrictions, Trademark Notice, Indemnification, Disclaimer, Governing Law, Reservation of Rights, Entire Agreement, No Waiver, Assignment, and Severability sections survive termination.
## Governing Law
This license is governed by the laws of the jurisdiction in which CloakHQ is established. Any disputes arising under this license shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.
## Reservation of Rights
All rights not expressly granted under this license are reserved by CloakHQ.
## Entire Agreement
This license constitutes the entire agreement between you and CloakHQ regarding the Binary and supersedes any prior or contemporaneous understandings relating to the Binary.
## No Waiver
Failure by CloakHQ to enforce any provision of this license does not constitute a waiver of that provision or any other provision.
## Assignment
You may not assign or transfer this license or any rights under it without prior written consent from CloakHQ.
## Severability
If any provision of this license is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and all remaining provisions shall continue in full force and effect.
## Contact
For licensing inquiries, including redistribution or OEM licensing, contact cloakhq@pm.me.
+149
View File
@@ -0,0 +1,149 @@
# Changelog
All notable changes to CloakBrowser — wrapper and binary — are documented here.
Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromium patches.
---
## [0.3.3] — 2026-03-03
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
- **[binary]** Expanded GPU model database for realistic per-session diversity
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
- **[binary]** First Windows x64 v145 build — 25 patches
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
## [0.3.1] — 2026-03-03
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
---
## [0.3.0] — 2026-03-02
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending.
### Breaking
- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically).
- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior.
### 2026-03-02
- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency
- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform
- **[binary]** Stability and performance improvements across fingerprint patches
- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height`
- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults
- **[wrapper]** Per-platform Chromium versioning — Linux and macOS can track different binary versions independently
- **[wrapper]** Improved SHA-256 checksum verification and version marker migration
### 2026-03-01
- **[wrapper]** Upgrade wrapper to Chromium v145.0.7632.109
- **[wrapper]** Add GitHub Releases fallback when primary download mirror is unavailable
- **[wrapper]** Add SHA-256 checksum verification for binary downloads
- **[wrapper]** Wire timezone and locale params to Chromium binary flags
- **[wrapper]** Add device memory to default stealth args
- **[wrapper]** JS: add `colorScheme` support, guard download fallback against partial failures
### 2026-02-28
- **[binary]** Enforce strict flag discipline — patches only activate when explicitly configured via command-line flags
- **[binary]** Improved fingerprint consistency across multiple browser APIs
- **[binary]** 3 new fingerprint patches + bug fixes in existing patches
- **[binary]** New command-line flag for device memory spoofing
- **[infra]** Automated test matrix: 8 groups, 41+ tests across core stealth, fingerprint noise, bot detection, reCAPTCHA, TLS, Turnstile, residential proxy, and enterprise reCAPTCHA
- **[infra]** Docker-based test runner with subprocess isolation per test group
### 2026-02-25
- **[binary]** Reduced automation markers visible to detection scripts
- **[binary]** Added browser API support at build time
- **[binary]** Improved screen property consistency
### 2026-02-24
- **[binary]** Comprehensive fingerprint audit and hardening pass
- **[binary]** Fixed font rendering edge case on cross-platform spoofing
- **[binary]** 4 new fingerprint patches
### 2026-02-22
- **[binary]** Start Chromium v145 build (v145.0.7632.109)
- **[binary]** 24 fingerprint patches ported and adapted
---
## [0.2.2] — 2026-03-01
### 2026-03-01
- **[wrapper]** Fix: replace `page.wait_for_timeout()` with `time.sleep()` to avoid timing leak
- **[wrapper]** Add auto-detect timezone and locale from proxy IP via GeoIP lookup
- **[binary]** CDP detection vector audit and hardening
---
## [0.2.0] — 2026-02-27
macOS platform release. JavaScript/TypeScript wrapper. Self-hosted binary mirror.
### 2026-02-27
- **[wrapper]** Add macOS support: Apple Silicon (arm64) and Intel (x64) binary downloads
- **[wrapper]** Add GPG-signed release workflow via GitHub Actions
- **[wrapper]** Fix macOS binary download: preserve `.app` symlinks, remove quarantine xattrs
- **[wrapper]** Add real bot detection assertions to stealth tests
- **[wrapper]** Bump version to 0.2.0
### 2026-02-26
- **[wrapper]** Switch binary downloads to self-hosted mirror (`cloakbrowser.dev`) as GitHub backup
- **[wrapper]** Set up GitLab mirror at `gitlab.com/CloakHQ/cloakbrowser`
### 2026-02-25
- **[wrapper]** Move binary releases from separate repo to wrapper repo
- **[wrapper]** Add auto-update check on launch
- **[infra]** Initial Docker test infrastructure + matrix test runner
### 2026-02-24
- **[wrapper]** Add JavaScript/TypeScript wrapper with Playwright + Puppeteer support (`npm install cloakbrowser`)
- **[wrapper]** Fix proxy authentication credentials support in URL (closes #4)
---
## [0.1.4] — 2026-02-23
### 2026-02-23
- **[wrapper]** Stealth hardening: additional launch args and detection evasion improvements
- **[wrapper]** Full test suite rewrite with real detection site assertions
- **[wrapper]** Add Docker support with Dockerfile and compose config
- **[wrapper]** Add headed mode documentation
---
## [0.1.0] — 2026-02-22
Initial release. Chromium v142 with 16 fingerprint patches.
### 2026-02-22
- **[binary]** Chromium v142.0.7444.175 with 16 source-level fingerprint patches
- **[binary]** Fix browser brand string to match Chrome 142 format
- **[wrapper]** `launch()` and `launch_async()` — drop-in Playwright replacements
- **[wrapper]** Auto-download binary from GitHub Releases, cached in `~/.cloakbrowser/`
- **[wrapper]** Linux x64 platform support
- **[wrapper]** Passes 14/14 bot detection tests
- **[wrapper]** reCAPTCHA v3: 0.9 (server-verified), Cloudflare Turnstile: pass
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 cloakbrowser Copyright (c) 2026 CloakHQ
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+214 -103
View File
@@ -2,30 +2,46 @@
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser"> <img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
</p> </p>
# CloakBrowser
<p align="center"> <p align="center">
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a> <a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/v/cloakbrowser" alt="PyPI"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a> <a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/v/cloakbrowser" alt="npm"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/pyversions/cloakbrowser" alt="Python"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/CloakHQ/CloakBrowser" alt="License"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
<br> <br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a> <a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/CloakHQ/CloakBrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pypi/dm/cloakbrowser" alt="PyPI Downloads"></a> <a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dm/cloakbrowser" alt="npm Downloads"></a> <a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/CloakHQ/CloakBrowser" alt="Last Commit"></a>
</p> </p>
**Stealth Chromium that passes every bot detection test.** <br>
Drop-in Playwright/Puppeteer replacement for Python and JavaScript. Same API, same code — just swap the import. Your browser now scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30 out of 30** stealth detection tests. <h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
- 🔒 **16 source-level C++ patches** — not JS injection, not config flags <table><tr><td>
Not a patched config. Not a JS injection. A real Chromium binary with fingerprints modified at the C++ source level. Antibot systems score it as a normal browser — because it <em>is</em> a normal browser.
</td></tr></table>
<br>
<p align="center">
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing">
<br><em>Cloudflare Turnstile — 3 live tests passing (headed mode, macOS)</em>
</p>
<br>
<p align="center">
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>
- 🔒 **25 source-level C++ patches** — not JS injection, not config flags
- 🛡️ **CDP stealth built-in** — uses [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) to reduce Playwright's automation footprint
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS) - 🔄 **Drop-in replacement** — works with Playwright (Python & JS) and Puppeteer (JS)
- 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config - 📦 **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
- 🦊 **Fills the Camoufox vacuum** — Chromium-based, actively maintained - 💸 **Enterprise results, zero cost** — anti-detect browsers charge $49299/month for the same results. CloakBrowser is free
**Python:** **Python:**
```python ```python
@@ -47,15 +63,7 @@ await page.goto('https://protected-site.com');
await browser.close(); await browser.close();
``` ```
**JavaScript (Puppeteer):** Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
```javascript
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await browser.close();
```
## Install ## Install
@@ -75,22 +83,59 @@ npm install cloakbrowser puppeteer-core
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally). 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]
```
**Migrating from Playwright?** One-line change:
```diff
- from playwright.sync_api import sync_playwright
- pw = sync_playwright().start()
- browser = pw.chromium.launch()
+ from cloakbrowser import launch
+ browser = launch()
page = browser.new_page()
page.goto("https://example.com")
# ... rest of your code works unchanged
```
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## What's New in v0.3.0
- **Chromium 145** — latest stable, 25 fingerprint patches (up from 16). All platforms
- **9 new patches** — screen dimensions, device memory, audio, WebGL, and more
- **SHA-256 checksum verification** — binary downloads are verified for integrity
- **CDP hardening** — audited and patched known automation detection vectors
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
See the full [CHANGELOG.md](CHANGELOG.md) for details.
## Why CloakBrowser? ## Why CloakBrowser?
- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves. - **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. - **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver defers Playwright's binding registration and randomizes internal world names. Most stealth tools only do one or the other.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with AI browser agents** — drop-in stealth binary for [browser-use](https://github.com/browser-use/browser-use), [agent-browser](https://github.com/nichochar/agent-browser), Claude computer use, and OpenAI Operator
- **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services. - **One line to switch** — same Playwright API, no new abstractions, no CAPTCHA-solving services.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. Antibot systems score it as a normal browser because it *is* a normal browser, just with your fingerprints instead of theirs. No CAPTCHA services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
## Test Results ## Test Results
All tests verified against live detection services. Last tested: Feb 2026 (Chromium 142). All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
| Detection Service | Stock Playwright | CloakBrowser | Notes | | Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---| |---|---|---|---|
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | Server-side verified | | **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | Server-side verified |
| **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | Auto-resolve | | **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | Auto-resolve |
| **Cloudflare Turnstile** (managed) | FAIL | **PASS** | Single click | | **Cloudflare Turnstile** (managed) | FAIL | **PASS** | Single click |
| **ShieldSquare** (yad2.co.il) | BLOCKED | **PASS** | Production site | | **ShieldSquare** | BLOCKED | **PASS** | Production site |
| **FingerprintJS** bot detection | DETECTED | **PASS** | demo.fingerprint.com | | **FingerprintJS** bot detection | DETECTED | **PASS** | demo.fingerprint.com |
| **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | browserscan.net | | **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | browserscan.net |
| **bot.incolumitas.com** | 13 fails | **1 fail** | WEBDRIVER spec only | | **bot.incolumitas.com** | 13 fails | **1 fail** | WEBDRIVER spec only |
@@ -98,19 +143,13 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
| `navigator.webdriver` | `true` | **`false`** | Source-level patch | | `navigator.webdriver` | `true` | **`false`** | Source-level patch |
| `navigator.plugins.length` | 0 | **5** | Real plugin list | | `navigator.plugins.length` | 0 | **5** | Real plugin list |
| `window.chrome` | `undefined` | **`object`** | Present like real Chrome | | `window.chrome` | `undefined` | **`object`** | Present like real Chrome |
| UA string | `HeadlessChrome` | **`Chrome/142.0.0.0`** | No headless leak | | UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` | | CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match | | TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
| | | **30/30 passed** | |
**30/30 tests passed.**
### Proof ### Proof
<p align="center">
<img src="https://i.imgur.com/IvB0It7.gif" width="600" alt="Cloudflare Turnstile — 3 Tests Passing (Headed Mode)">
<br><em>Cloudflare Turnstile — 3 live tests passing in headed mode (macOS)</em>
</p>
<p align="center"> <p align="center">
<img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9"> <img src="https://i.imgur.com/hvIQyMv.png" width="600" alt="reCAPTCHA v3 — Score 0.9">
<br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em> <br><em>reCAPTCHA v3 score 0.9 — server-side verified (human-level)</em>
@@ -136,24 +175,16 @@ All tests verified against live detection services. Last tested: Feb 2026 (Chrom
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary: CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser` 1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Linux x64, macOS arm64/x64) 2. **First launch** → binary auto-downloads for your platform (Chromium 145)
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 16 source-level patches that modify: The binary includes 25 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
- Canvas fingerprint generation
- WebGL renderer output
- Audio processing fingerprint
- Font enumeration results
- Hardware concurrency reporting
- Client rect measurements
- GPU vendor/renderer strings
- WebDriver flag
- Headless detection signals
- And more...
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. 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.
## API ## API
### `launch()` ### `launch()`
@@ -173,6 +204,15 @@ browser = launch(proxy="http://user:pass@proxy:8080")
# With extra Chrome args # With extra Chrome args
browser = launch(args=["--disable-gpu", "--window-size=1920,1080"]) browser = launch(args=["--disable-gpu", "--window-size=1920,1080"])
# With timezone and locale (sets both binary flags and Playwright context)
browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# Without default stealth args (bring your own fingerprint flags) # Without default stealth args (bring your own fingerprint flags)
browser = launch(stealth_args=False, args=["--fingerprint=12345"]) browser = launch(stealth_args=False, args=["--fingerprint=12345"])
``` ```
@@ -218,7 +258,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status # Check binary installation status
print(binary_info()) print(binary_info())
# {'version': '142.0.7444.175', 'platform': 'linux-x64', 'installed': True, ...} # {'version': '145.0.7632.109', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download # Force re-download
clear_cache() clear_cache()
@@ -244,6 +284,8 @@ const browser = await launch({
headless: false, headless: false,
proxy: 'http://user:pass@proxy:8080', proxy: 'http://user:pass@proxy:8080',
args: ['--window-size=1920,1080'], args: ['--window-size=1920,1080'],
timezone: 'America/New_York',
locale: 'en-US',
}); });
// Convenience: browser + context in one call // Convenience: browser + context in one call
@@ -258,6 +300,8 @@ const page = await context.newPage();
> **Note:** Each example above is standalone — not meant to run as one block. > **Note:** Each example above is standalone — not meant to run as one block.
All Python options work in JS: `stealthArgs: false` to disable defaults, `geoip: true` to auto-detect timezone/locale from proxy IP.
### Puppeteer ### Puppeteer
> **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results. > **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results.
@@ -294,24 +338,49 @@ clearCache();
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary | | `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks | | `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 |
## Fingerprint Management ## Fingerprint Management
Every launch automatically generates a **unique fingerprint**. A random seed (1000099999) drives all seed-based patches — canvas, WebGL, audio, fonts, and client rects all produce consistent, correlated values derived from that single seed. The binary is **stealthy by default** — no flags needed. It auto-generates a random fingerprint seed at startup and spoofs all detectable values (GPU, hardware specs, screen dimensions, canvas, WebGL, audio, fonts). Every launch produces a fresh, coherent identity.
**How fingerprinting works:**
| Scenario | What happens |
|----------|-------------|
| **No flags** | Random seed auto-generated at startup. GPU, screen, hardware specs, and all noise patches are spoofed automatically. Fresh identity each launch. |
| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). |
| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. |
The binary detects its platform at compile time — a macOS binary reports Apple GPU and macOS screen (1440x900), a Linux binary reports NVIDIA GPU and 1080p screen. Override with `--fingerprint-platform` for cross-platform spoofing (e.g. Linux binary appearing as Windows).
> **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'] });
> ```
### Default Fingerprint ### Default Fingerprint
Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux spoofs Windows: Every `launch()` call sets these automatically. Defaults are **platform-aware** — macOS runs as a native Mac browser, Linux and Windows use the Windows fingerprint profile:
| Flag | Linux Default | macOS Default | Controls | | Flag | Linux/Windows Default | macOS Default | Controls |
|------|--------------|---------------|----------| |------|--------------|---------------|----------|
| `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects | | `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects |
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection | | `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
| `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` | | `--fingerprint-hardware-concurrency` | `8` | *(not set — uses real value)* | `navigator.hardwareConcurrency` |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | *(not set — native Apple GPU)* | WebGL `UNMASKED_VENDOR_WEBGL` | | `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | *(not set — native Metal renderer)* | WebGL `UNMASKED_RENDERER_WEBGL` | | `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
| `--fingerprint-device-memory` | `8` | *(not set)* | `navigator.deviceMemory` |
| `--fingerprint-screen-width` | `1920` | *(not set)* | Screen width reporting |
| `--fingerprint-screen-height` | `1080` | *(not set)* | Screen height reporting |
| `--window-size` | `1920,1080` | *(not set)* | Browser window dimensions |
> **Important:** `--fingerprint-platform` must always be set. The binary defaults to `windows` internally when this flag is missing, which causes GPU/UA mismatches on non-Windows systems. The wrapper handles this automatically. > **Using the binary directly?** It works out of the box with zero flags — the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
### Additional Flags ### Additional Flags
@@ -323,16 +392,16 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-brand-version` | Brand version (UA + Client Hints) | | `--fingerprint-brand-version` | Brand version (UA + Client Hints) |
| `--fingerprint-platform-version` | Client Hints platform version | | `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates | | `--fingerprint-location` | Geolocation coordinates |
| `--timezone` | Timezone (e.g. `America/New_York`) | | `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production. > **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
### Examples ### Examples
```python ```python
# Default — unique fingerprint every launch
browser = launch()
# Pin a seed for a persistent identity # Pin a seed for a persistent identity
browser = launch(args=["--fingerprint=42069"]) browser = launch(args=["--fingerprint=42069"])
@@ -345,12 +414,6 @@ browser = launch(stealth_args=False, args=[
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
]) ])
# Add timezone and location on top of defaults
browser = launch(args=[
"--timezone=America/New_York",
"--fingerprint-location=40.7128,-74.0060",
])
# Override GPU to look like a different machine # Override GPU to look like a different machine
browser = launch(args=[ browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.", "--fingerprint-gpu-vendor=Intel Inc.",
@@ -358,30 +421,6 @@ browser = launch(args=[
]) ])
``` ```
```javascript
// JavaScript — same flags
const browser = await launch({
args: ['--fingerprint=42069', '--timezone=Europe/London'],
});
```
## Use With Existing Playwright Code
If you have existing Playwright scripts, migration is one line:
```diff
- from playwright.sync_api import sync_playwright
- pw = sync_playwright().start()
- browser = pw.chromium.launch()
+ from cloakbrowser import launch
+ browser = launch()
page = browser.new_page()
page.goto("https://example.com")
# ... rest of your code works unchanged
```
## Comparison ## Comparison
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser | | Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
@@ -390,28 +429,30 @@ page.goto("https://example.com")
| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** | | Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** |
| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** | | Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** |
| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** | | Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** |
| Maintained | Yes | Stale | Stale | Dead (2025) | **Active** | | Maintained | Yes | Stale | Stale | Unstable (2026 beta) | **Active** |
| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** | | Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** |
| Playwright API | Native | Native | No (Selenium) | No | **Native** | | Playwright API | Native | Native | No (Selenium) | No | **Native** |
## Platforms ## Platforms
| Platform | Status | | Platform | Chromium | Patches | Status |
|---|---| |---|---|---|---|
| Linux x86_64 | ✅ Available | | Linux x86_64 | 145 | 25 | ✅ Latest |
| macOS arm64 (Apple Silicon) | ✅ Available | | macOS arm64 (Apple Silicon) | 145 | 25 | ✅ Latest |
| macOS x86_64 (Intel) | ✅ Available | | macOS x86_64 (Intel) | 145 | 25 | ✅ Latest |
| Windows | Planned | | Windows x86_64 | 145 | 25 | ✅ Latest |
The wrapper auto-downloads the correct binary for your platform.
**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once. **macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once.
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
## Examples ## Examples
**Python** — see [`examples/`](examples/): **Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page - [`basic.py`](examples/basic.py) — Launch and load a page
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score - [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
- [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services - [`stealth_test.py`](examples/stealth_test.py) — Run against all detection services
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/): **JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load - [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
@@ -422,16 +463,12 @@ page.goto("https://example.com")
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Linux x64 binary | ✅ Released | | Linux x64 — Chromium 145 (25 patches) | ✅ Released |
| macOS arm64 (Apple Silicon) | ✅ Released | | macOS arm64/x64 — Chromium 145 (25 patches) | ✅ Released |
| macOS x64 (Intel) | ✅ Released | | Windows x64 — Chromium 145 (25 patches) | ✅ Released |
| Chromium 145 build | 🔜 In progress |
| JavaScript/Puppeteer + Playwright support | ✅ Released | | JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released | | Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned | | Built-in proxy rotation | 📋 Planned |
| Windows support | 📋 Planned |
> ⭐ **Star this repo** to get notified when Chromium 145 and Windows builds drop.
## Docker ## Docker
@@ -455,6 +492,21 @@ COPY your_script.py /app/
CMD ["python", "your_script.py"] CMD ["python", "your_script.py"]
``` ```
**With a proxy** (the most common production setup):
```bash
docker run --rm cloakbrowser python -c "
from cloakbrowser import launch
browser = launch(proxy='http://user:pass@proxy:8080')
page = browser.new_page()
page.goto('https://example.com')
print(page.title())
browser.close()
"
```
CloakBrowser works identically local, in Docker, and on VPS. No environment-specific config needed.
**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs. **Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs.
## Headed Mode (for aggressive bot detection) ## Headed Mode (for aggressive bot detection)
@@ -486,25 +538,85 @@ This runs a real headed browser rendered on a virtual display — no physical mo
## Troubleshooting ## Troubleshooting
**Reddit or similar sites show CAPTCHA / "Prove your humanity"**
Some sites (notably Reddit homepage) use HTTP/2 fingerprinting that detects Playwright's connection layer. Pass `--disable-http2` to fall back to HTTP/1.1:
```python
browser = launch(args=["--disable-http2"])
```
```javascript
const browser = await launch({ args: ['--disable-http2'] });
```
Only use this flag for sites that require it — most sites work fine with HTTP/2.
**Something not working? Make sure you're on the latest wrapper**
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
npm install cloakbrowser@latest # JavaScript
```
**Binary download fails / timeout** **Binary download fails / timeout**
Set a custom download URL or use a local binary: Set a custom download URL or use a local binary:
```bash ```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
``` ```
**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
```
**"playwright install" vs CloakBrowser binary** **"playwright install" vs CloakBrowser binary**
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash ```bash
playwright install-deps chromium patchright install-deps chromium
``` ```
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
```python
# Bad — sends CDP commands, reCAPTCHA detects this
page.wait_for_timeout(3000)
# Good — invisible to the browser
import time
time.sleep(3)
```
```javascript
// Bad — sends CDP commands
await page.waitForTimeout(3000);
// Good — invisible to the browser
await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **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 ## FAQ
**Q: Is this legal?** **Q: Is this legal?**
A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service. A: CloakBrowser is a browser. Using it is legal. What you do with it is your responsibility, just like with Chrome, Firefox, or any browser. We do not endorse violating website terms of service.
**Q: How is this different from Camoufox?** **Q: How is this different from Camoufox?**
A: Camoufox patched Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Also, Camoufox is no longer maintained (since March 2025). 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.
**Q: Will detection sites eventually catch this?** **Q: Will detection sites eventually catch this?**
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves. A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
@@ -512,11 +624,9 @@ A: Possibly. Bot detection is an arms race. Source-level patches are harder to d
**Q: Can I use my own proxy?** **Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`. A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
**Q: Can I use this with Docker?**
A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) section above.
## Links ## Links
- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md)
- 🌐 **Website** — [cloakbrowser.dev](https://cloakbrowser.dev) - 🌐 **Website** — [cloakbrowser.dev](https://cloakbrowser.dev)
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues) - 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/) - 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
@@ -525,7 +635,8 @@ A: Yes. A ready-to-use Dockerfile is included — see the [Docker](#docker) sect
## License ## License
MIT — see [LICENSE](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).
## Contributing ## Contributing
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.2.0" __version__ = "0.3.3"
+69 -11
View File
@@ -15,10 +15,10 @@ Usage:
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, Literal
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from .config import get_default_stealth_args from .config import DEFAULT_VIEWPORT, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
@@ -29,6 +29,9 @@ def launch(
proxy: str | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object. """Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -39,6 +42,12 @@ def launch(
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags. 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.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -52,10 +61,11 @@ def launch(
>>> print(page.title()) >>> print(page.title())
>>> browser.close() >>> browser.close()
""" """
from playwright.sync_api import sync_playwright from patchright.sync_api import sync_playwright
binary_path = ensure_binary() binary_path = ensure_binary()
chrome_args = _build_args(stealth_args, args) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -86,6 +96,9 @@ async def launch_async(
proxy: str | None = None, proxy: str | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch(). Returns a Playwright Browser object. """Async version of launch(). Returns a Playwright Browser object.
@@ -95,6 +108,9 @@ async def launch_async(
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080').
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). 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).
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -113,10 +129,11 @@ async def launch_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
from playwright.async_api import async_playwright from patchright.async_api import async_playwright
binary_path = ensure_binary() binary_path = ensure_binary()
chrome_args = _build_args(stealth_args, args) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -151,6 +168,8 @@ def launch_context(
viewport: dict | None = None, viewport: dict | None = None,
locale: str | None = None, locale: str | None = None,
timezone_id: str | None = None, timezone_id: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set. """Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -167,22 +186,31 @@ def launch_context(
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
locale: Browser locale, e.g. "en-US". locale: Browser locale, e.g. "en-US".
timezone_id: Timezone, e.g. "America/New_York". timezone_id: Timezone, e.g. "America/New_York".
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
Note: 'no-preference' doesn't work in Patchright (falls back to 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
**kwargs: Passed to browser.new_context(). **kwargs: Passed to browser.new_context().
Returns: Returns:
Playwright BrowserContext object. Playwright BrowserContext object.
""" """
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args) # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params
timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale)
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone_id, locale=locale)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport: context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
context_kwargs["viewport"] = viewport
if locale: if locale:
context_kwargs["locale"] = locale context_kwargs["locale"] = locale
if timezone_id: if timezone_id:
context_kwargs["timezone_id"] = timezone_id context_kwargs["timezone_id"] = timezone_id
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
try: try:
@@ -208,13 +236,43 @@ def launch_context(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_args(stealth_args: bool, extra_args: list[str] | None) -> list[str]: def _maybe_resolve_geoip(
"""Combine stealth args with user-provided args.""" geoip: bool,
proxy: str | None,
timezone: str | None,
locale: str | None,
) -> tuple[str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled."""
if not geoip or not proxy or (timezone is not None and locale is not None):
return timezone, locale
from .geoip import resolve_proxy_geo
geo_tz, geo_locale = resolve_proxy_geo(proxy)
if timezone is None:
timezone = geo_tz
if locale is None:
locale = geo_locale
return timezone, locale
def _build_args(
stealth_args: bool,
extra_args: list[str] | None,
timezone: str | None = None,
locale: str | None = None,
) -> list[str]:
"""Combine stealth args with user-provided args and locale flags."""
result = [] result = []
if stealth_args: if stealth_args:
result.extend(get_default_stealth_args()) result.extend(get_default_stealth_args())
if extra_args: if extra_args:
result.extend(extra_args) result.extend(extra_args)
# Timezone/locale flags are independent of stealth_args — always inject when set
if timezone:
result.append(f"--fingerprint-timezone={timezone}")
if locale:
result.append(f"--lang={locale}")
return result return result
+72 -26
View File
@@ -10,9 +10,19 @@ from pathlib import Path
from ._version import __version__ from ._version import __version__
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Chromium version shipped with this release # Chromium version shipped with this release.
# Different platforms may ship different versions during transition periods.
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "142.0.7444.175" CHROMIUM_VERSION = "145.0.7632.109"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary. # Default stealth arguments passed to the patched Chromium binary.
@@ -37,16 +47,30 @@ def get_default_stealth_args() -> list[str]:
# Tell the fingerprint patches we're on macOS so GPU/UA match natively # Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + [ return base + [
"--fingerprint-platform=macos", "--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
] ]
# Linux: spoof as Windows # Linux/Windows: Windows fingerprint profile
return base + [ return base + [
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8", "--fingerprint-hardware-concurrency=8",
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
] ]
# ---------------------------------------------------------------------------
# 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 = {"width": 1920, "height": 947}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform detection # Platform detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -55,11 +79,18 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
("Linux", "aarch64"): "linux-arm64", ("Linux", "aarch64"): "linux-arm64",
("Darwin", "arm64"): "darwin-arm64", ("Darwin", "arm64"): "darwin-arm64",
("Darwin", "x86_64"): "darwin-x64", ("Darwin", "x86_64"): "darwin-x64",
("Windows", "AMD64"): "windows-x64",
("Windows", "x86_64"): "windows-x64",
} }
# Platforms with pre-built binaries available for download. # Platforms with pre-built binaries available for download (derived from version map).
# Update this set as new platform builds are released. AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
AVAILABLE_PLATFORMS: set[str] = {"linux-x64", "darwin-arm64", "darwin-x64"}
def get_chromium_version() -> str:
"""Return the Chromium version for the current platform."""
tag = get_platform_tag()
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
def get_platform_tag() -> str: def get_platform_tag() -> str:
@@ -92,7 +123,7 @@ def get_cache_dir() -> Path:
def get_binary_dir(version: str | None = None) -> Path: def get_binary_dir(version: str | None = None) -> Path:
"""Return the directory for a Chromium version binary.""" """Return the directory for a Chromium version binary."""
v = version or CHROMIUM_VERSION v = version or get_chromium_version()
return get_cache_dir() / f"chromium-{v}" return get_cache_dir() / f"chromium-{v}"
@@ -103,6 +134,8 @@ def get_binary_path(version: str | None = None) -> Path:
if platform.system() == "Darwin": if platform.system() == "Darwin":
# macOS: Chromium.app bundle # macOS: Chromium.app bundle
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium" return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
elif platform.system() == "Windows":
return binary_dir / "chrome.exe"
else: else:
# Linux: flat binary # Linux: flat binary
return binary_dir / "chrome" return binary_dir / "chrome"
@@ -121,30 +154,32 @@ def check_platform_available() -> None:
available = ", ".join(sorted(AVAILABLE_PLATFORMS)) available = ", ".join(sorted(AVAILABLE_PLATFORMS))
import sys import sys
sys.exit( sys.exit(
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n" f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
f"Windows builds are coming soon.\n\n" f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
f"To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH."
) )
def get_effective_version() -> str: def get_effective_version() -> str:
"""Return the best available version: auto-updated if available, else hardcoded. """Return the best available version: auto-updated if available, else platform default.
Reads the latest_version marker file from the cache directory. Reads a platform-scoped marker file from the cache directory.
Returns CHROMIUM_VERSION if no update has been downloaded. Returns the platform's hardcoded version if no update has been downloaded.
""" """
marker = get_cache_dir() / "latest_version" base = get_chromium_version()
if marker.exists(): # Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
try: cache = get_cache_dir()
version = marker.read_text().strip() for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
if version and _version_newer(version, CHROMIUM_VERSION): marker = cache / name
# Verify the binary actually exists if marker.exists():
binary = get_binary_path(version) try:
if binary.exists(): version = marker.read_text().strip()
return version if version and _version_newer(version, base):
except (ValueError, OSError): binary = get_binary_path(version)
pass if binary.exists():
return CHROMIUM_VERSION return version
except (ValueError, OSError):
pass
return base
def _version_tuple(v: str) -> tuple[int, ...]: def _version_tuple(v: str) -> tuple[int, ...]:
@@ -167,14 +202,25 @@ DOWNLOAD_BASE_URL = os.environ.get(
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases" GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
GITHUB_DOWNLOAD_BASE_URL = (
"https://github.com/CloakHQ/cloakbrowser/releases/download"
)
def get_download_url(version: str | None = None) -> str: def get_download_url(version: str | None = None) -> str:
"""Return the full download URL for the current platform's binary archive.""" """Return the full download URL for the current platform's binary archive."""
v = version or CHROMIUM_VERSION v = version or get_chromium_version()
tag = get_platform_tag() tag = get_platform_tag()
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz" return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
def get_fallback_download_url(version: str | None = None) -> str:
"""Return the GitHub Releases fallback URL for the binary archive."""
v = version or get_chromium_version()
tag = get_platform_tag()
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/cloakbrowser-{tag}.tar.gz"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Local binary override (skip download, use your own build) # Local binary override (skip download, use your own build)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+159 -15
View File
@@ -6,6 +6,7 @@ Similar to how Playwright downloads its own bundled Chromium.
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import os import os
import platform import platform
@@ -19,17 +20,21 @@ from pathlib import Path
import httpx import httpx
from ._version import __version__ as _wrapper_version
from .config import ( from .config import (
CHROMIUM_VERSION, CHROMIUM_VERSION,
DOWNLOAD_BASE_URL, DOWNLOAD_BASE_URL,
GITHUB_API_URL, GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
_version_newer, _version_newer,
check_platform_available, check_platform_available,
get_binary_dir, get_binary_dir,
get_binary_path, get_binary_path,
get_cache_dir, get_cache_dir,
get_chromium_version,
get_download_url, get_download_url,
get_effective_version, get_effective_version,
get_fallback_download_url,
get_local_binary_override, get_local_binary_override,
get_platform_tag, get_platform_tag,
) )
@@ -73,18 +78,19 @@ def ensure_binary() -> str:
_maybe_trigger_update_check() _maybe_trigger_update_check()
return str(binary_path) return str(binary_path)
# Fall back to hardcoded version if effective version binary doesn't exist # Fall back to platform's hardcoded version if effective version binary doesn't exist
if effective != CHROMIUM_VERSION: platform_version = get_chromium_version()
if effective != platform_version:
fallback_path = get_binary_path() fallback_path = get_binary_path()
if fallback_path.exists() and _is_executable(fallback_path): if fallback_path.exists() and _is_executable(fallback_path):
logger.debug("Binary found in cache: %s", fallback_path) logger.debug("Binary found in cache: %s", fallback_path)
_maybe_trigger_update_check() _maybe_trigger_update_check()
return str(fallback_path) return str(fallback_path)
# Download hardcoded version # Download platform's hardcoded version
logger.info( logger.info(
"Stealth Chromium %s not found. Downloading for %s...", "Stealth Chromium %s not found. Downloading for %s...",
CHROMIUM_VERSION, platform_version,
get_platform_tag(), get_platform_tag(),
) )
_download_and_extract() _download_and_extract()
@@ -102,8 +108,14 @@ def ensure_binary() -> str:
def _download_and_extract(version: str | None = None) -> None: def _download_and_extract(version: str | None = None) -> None:
"""Download the binary archive and extract to cache directory.""" """Download the binary archive and extract to cache directory.
url = get_download_url(version)
Tries the primary server (cloakbrowser.dev) first, falls back to
GitHub Releases if the primary is unreachable or returns an error.
Verifies SHA-256 checksum before extraction when available.
"""
primary_url = get_download_url(version)
fallback_url = get_fallback_download_url(version)
binary_dir = get_binary_dir(version) binary_dir = get_binary_dir(version)
binary_path = get_binary_path(version) binary_path = get_binary_path(version)
@@ -115,7 +127,22 @@ def _download_and_extract(version: str | None = None) -> None:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
_download_file(url, tmp_path) # Try primary, fall back to GitHub Releases (skip fallback if custom URL)
try:
_download_file(primary_url, tmp_path)
except Exception as primary_err:
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
raise
logger.warning(
"Primary download failed (%s), trying GitHub Releases...",
primary_err,
)
_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)
_extract_archive(tmp_path, binary_dir, binary_path) _extract_archive(tmp_path, binary_dir, binary_path)
logger.info("Visit https://cloakbrowser.dev for docs and release notifications.") logger.info("Visit https://cloakbrowser.dev for docs and release notifications.")
logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues") logger.info("Issues? https://github.com/CloakHQ/CloakBrowser/issues")
@@ -125,6 +152,76 @@ def _download_and_extract(version: str | None = None) -> None:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
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)
tarball_name = f"cloakbrowser-{get_platform_tag()}.tar.gz"
if checksums is None:
logger.warning("SHA256SUMS not available for this release — 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)
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()
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
# Build URL list — respect custom URL contract (no GitHub fallback)
urls = [f"{DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS"]
if not has_custom_url:
urls.append(f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS")
for url in urls:
try:
resp = httpx.get(url, follow_redirects=True, timeout=10.0)
resp.raise_for_status()
return _parse_checksums(resp.text)
except Exception:
continue
return None
def _parse_checksums(text: str) -> dict[str, str]:
"""Parse SHA256SUMS format: 'hash filename' per line."""
result = {}
for line in text.strip().splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) == 2:
hash_val, filename = parts
filename = filename.lstrip("*")
result[filename] = hash_val.lower()
return result
def _verify_checksum(file_path: Path, expected_hash: str) -> None:
"""Verify SHA-256 of a file. Raises RuntimeError on mismatch."""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
actual = sha256.hexdigest().lower()
if actual != expected_hash:
raise RuntimeError(
f"Checksum verification failed!\n"
f" Expected: {expected_hash}\n"
f" Got: {actual}\n"
f" File may be corrupted or tampered with. "
f"Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues"
)
logger.info("Checksum verified: SHA-256 OK")
def _download_file(url: str, dest: Path) -> None: def _download_file(url: str, dest: Path) -> None:
"""Download a file with progress logging.""" """Download a file with progress logging."""
logger.info("Downloading from %s", url) logger.info("Downloading from %s", url)
@@ -233,7 +330,9 @@ def _is_executable(path: Path) -> bool:
def _make_executable(path: Path) -> None: def _make_executable(path: Path) -> None:
"""Make a file executable (chmod +x).""" """Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
if platform.system() == "Windows":
return
current = path.stat().st_mode current = path.stat().st_mode
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -290,7 +389,7 @@ def check_for_update() -> str | None:
latest = _get_latest_chromium_version() latest = _get_latest_chromium_version()
if latest is None: if latest is None:
return None return None
if not _version_newer(latest, CHROMIUM_VERSION): if not _version_newer(latest, get_chromium_version()):
return None return None
binary_dir = get_binary_dir(latest) binary_dir = get_binary_dir(latest)
@@ -326,16 +425,23 @@ def _should_check_for_update() -> bool:
def _get_latest_chromium_version() -> str | None: def _get_latest_chromium_version() -> str | None:
"""Hit GitHub Releases API, return latest chromium-v* version string or None.""" """Hit GitHub Releases API, return latest chromium-v* version for this platform.
Checks that the release has a binary asset for the current platform,
so Linux-only releases won't be offered to macOS users.
"""
try: try:
resp = httpx.get( resp = httpx.get(
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0 GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
) )
resp.raise_for_status() resp.raise_for_status()
platform_tarball = f"cloakbrowser-{get_platform_tag()}.tar.gz"
for release in resp.json(): for release in resp.json():
tag = release.get("tag_name", "") tag = release.get("tag_name", "")
if tag.startswith("chromium-v") and not release.get("draft"): if tag.startswith("chromium-v") and not release.get("draft"):
return tag.removeprefix("chromium-v") asset_names = {a["name"] for a in release.get("assets", [])}
if platform_tarball in asset_names:
return tag.removeprefix("chromium-v")
return None return None
except Exception: except Exception:
logger.debug("Auto-update check failed", exc_info=True) logger.debug("Auto-update check failed", exc_info=True)
@@ -343,16 +449,47 @@ def _get_latest_chromium_version() -> str | None:
def _write_version_marker(version: str) -> None: def _write_version_marker(version: str) -> None:
"""Write the latest version marker to cache dir.""" """Write the latest version marker for this platform to cache dir."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
marker = cache_dir / "latest_version" marker = cache_dir / f"latest_version_{get_platform_tag()}"
# Write to temp file then rename for atomicity # Write to temp file then rename for atomicity
tmp = marker.with_suffix(".tmp") tmp = marker.with_suffix(".tmp")
tmp.write_text(version) tmp.write_text(version)
tmp.rename(marker) tmp.rename(marker)
_wrapper_update_checked = False
def _check_wrapper_update() -> None:
"""Check PyPI for a newer wrapper version. Runs once per process."""
global _wrapper_update_checked
if _wrapper_update_checked:
return
_wrapper_update_checked = True
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
return
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
return
try:
resp = httpx.get(
"https://pypi.org/pypi/cloakbrowser/json",
timeout=5.0,
)
resp.raise_for_status()
latest = resp.json()["info"]["version"]
if _version_newer(latest, _wrapper_version):
logger.warning(
"Update available: cloakbrowser %s%s. "
"Run: pip install --upgrade cloakbrowser",
_wrapper_version,
latest,
)
except Exception:
logger.debug("Wrapper update check failed", exc_info=True)
def _check_and_download_update() -> None: def _check_and_download_update() -> None:
"""Background task: check for newer binary, download if available.""" """Background task: check for newer binary, download if available."""
try: try:
@@ -361,10 +498,11 @@ def _check_and_download_update() -> None:
check_file.parent.mkdir(parents=True, exist_ok=True) check_file.parent.mkdir(parents=True, exist_ok=True)
check_file.write_text(str(time.time())) check_file.write_text(str(time.time()))
platform_version = get_chromium_version()
latest = _get_latest_chromium_version() latest = _get_latest_chromium_version()
if latest is None: if latest is None:
return return
if not _version_newer(latest, CHROMIUM_VERSION): if not _version_newer(latest, platform_version):
return return
# Already downloaded? # Already downloaded?
@@ -375,7 +513,7 @@ def _check_and_download_update() -> None:
logger.info( logger.info(
"Newer Chromium available: %s (current: %s). Downloading in background...", "Newer Chromium available: %s (current: %s). Downloading in background...",
latest, latest,
CHROMIUM_VERSION, platform_version,
) )
_download_and_extract(version=latest) _download_and_extract(version=latest)
_write_version_marker(latest) _write_version_marker(latest)
@@ -389,6 +527,12 @@ def _check_and_download_update() -> None:
def _maybe_trigger_update_check() -> None: def _maybe_trigger_update_check() -> None:
"""Fire-and-forget update check in a daemon thread.""" """Fire-and-forget update check in a daemon thread."""
# Wrapper update: once per process, not rate-limited
if not _wrapper_update_checked:
t = threading.Thread(target=_check_wrapper_update, daemon=True)
t.start()
# Binary update: rate-limited to once per hour
if not _should_check_for_update(): if not _should_check_for_update():
return return
t = threading.Thread(target=_check_and_download_update, daemon=True) t = threading.Thread(target=_check_and_download_update, daemon=True)
+238
View File
@@ -0,0 +1,238 @@
"""GeoIP-based timezone and locale detection from proxy IP.
Optional feature requires ``geoip2`` package::
pip install cloakbrowser[geoip]
Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
``~/.cloakbrowser/geoip/``. Background re-download after 30 days.
"""
from __future__ import annotations
import ipaddress
import logging
import socket
import tempfile
import threading
import time
from pathlib import Path
from urllib.parse import urlparse
logger = logging.getLogger("cloakbrowser")
# P3TERX mirror of MaxMind GeoLite2-City — no license key needed
GEOIP_DB_URL = (
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
)
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
COUNTRY_LOCALE_MAP: dict[str, str] = {
"US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ",
"IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG",
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
"FR": "fr-FR", "BE": "fr-BE",
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL",
"BR": "pt-BR", "PT": "pt-PT",
"IT": "it-IT", "NL": "nl-NL",
"JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK",
"RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO",
"IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG",
"IN": "hi-IN", "ID": "id-ID", "PH": "en-PH",
"TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY",
"SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI",
"GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG",
}
def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"""Resolve timezone and locale from a proxy's IP address.
Returns ``(timezone, locale)`` either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
raise ImportError(
"geoip2 is required for geoip=True. Install it with:\n"
" pip install cloakbrowser[geoip]"
) from None
db_path = _ensure_geoip_db()
if db_path is None:
return None, None
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
return None, None
try:
with geoip2.database.Reader(str(db_path)) as reader:
resp = reader.city(ip)
timezone = resp.location.time_zone
country = resp.country.iso_code
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
logger.debug(
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
# ---------------------------------------------------------------------------
# Proxy IP resolution
# ---------------------------------------------------------------------------
def _resolve_proxy_ip(proxy_url: str) -> str | None:
"""Extract proxy hostname from URL and resolve to an IP address."""
try:
hostname = urlparse(proxy_url).hostname
if not hostname:
return None
# Already a literal IP?
try:
socket.inet_pton(socket.AF_INET, hostname)
return hostname
except OSError:
pass
try:
socket.inet_pton(socket.AF_INET6, hostname)
return hostname
except OSError:
pass
# DNS resolve (returns first result, handles both v4/v6)
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
if results:
ip = results[0][4][0]
logger.debug("Resolved proxy %s%s", hostname, ip)
return ip
return None
except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc)
return None
def _is_private_ip(ip: str) -> bool:
"""Check if an IP address is private/internal (not routable on the internet)."""
try:
return ipaddress.ip_address(ip).is_private
except ValueError:
return False
# IP echo services — fast, no auth, return just the IP
_IP_ECHO_URLS = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
]
def _resolve_exit_ip(proxy_url: str) -> str | None:
"""Discover the proxy's actual exit IP by connecting through it."""
import httpx
for url in _IP_ECHO_URLS:
try:
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
resp.raise_for_status()
ip = resp.text.strip()
# Validate it looks like an IP
ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip)
return ip
except Exception:
continue
logger.debug("Failed to discover exit IP through proxy")
return None
# ---------------------------------------------------------------------------
# GeoIP database management
# ---------------------------------------------------------------------------
def _get_geoip_dir() -> Path:
from .config import get_cache_dir
return get_cache_dir() / "geoip"
def _ensure_geoip_db() -> Path | None:
"""Return path to GeoLite2-City.mmdb, downloading on first use."""
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
if db_path.exists():
_maybe_trigger_update(db_path)
return db_path
try:
_download_geoip_db(db_path)
return db_path
except Exception as exc:
logger.warning("Failed to download GeoIP database: %s", exc)
return None
def _download_geoip_db(dest: Path) -> None:
"""Atomic download of GeoLite2-City.mmdb via httpx."""
import httpx
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info("Downloading GeoIP database (~70 MB) …")
tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
tmp_path = Path(tmp_name)
try:
with httpx.stream(
"GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0
) as resp:
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
last_pct = -1
with open(tmp_fd, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=65_536):
f.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded * 100 // total
if pct >= last_pct + 10:
last_pct = pct
logger.info("GeoIP download: %d %%", pct)
tmp_path.rename(dest)
logger.info("GeoIP database ready: %s", dest)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
def _maybe_trigger_update(db_path: Path) -> None:
"""Re-download in background if DB is older than 30 days."""
try:
age = time.time() - db_path.stat().st_mtime
if age < GEOIP_UPDATE_INTERVAL:
return
except OSError:
return
def _bg() -> None:
try:
_download_geoip_db(db_path)
except Exception:
logger.debug("Background GeoIP update failed", exc_info=True)
threading.Thread(target=_bg, daemon=True).start()
+228
View File
@@ -0,0 +1,228 @@
"""Test against fingerprint-scan.com and CreepJS.
Tests the specific headless detection signals flagged by the community:
- noTaskbar, noContentIndex, noContactsManager, noDownlinkMax
- Bot risk score (fingerprint-scan.com)
- Headless/stealth percentages (CreepJS)
- Full CreepJS signal breakdown (likeHeadless, headless, stealth)
Usage:
python examples/fingerprint_scan_test.py
python examples/fingerprint_scan_test.py --proxy http://10.50.96.5:8888
python examples/fingerprint_scan_test.py --headless
"""
import sys
import time
from cloakbrowser import launch_context
HEADLESS = "--headless" in sys.argv
PROXY = None
for i, arg in enumerate(sys.argv):
if arg == "--proxy" and i + 1 < len(sys.argv):
PROXY = sys.argv[i + 1]
def test_fingerprint_scan(page):
"""fingerprint-scan.com — bot risk score + headless detection signals."""
print("=== fingerprint-scan.com ===")
page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000)
time.sleep(20) # Castle.js needs time to compute score
# Check bot risk score
score = page.evaluate(
'document.getElementById("fingerprintScore")?.textContent || "Score not rendered"'
)
print(f"Bot Risk Score: {score}")
# Check headless detection signals
apis = page.evaluate("""() => ({
noTaskbar: screen.height === screen.availHeight,
taskbarSize: screen.height - screen.availHeight,
noContentIndex: typeof window.ContentIndex === "undefined",
noContactsManager: !("contacts" in navigator),
noDownlinkMax: !("downlinkMax" in (navigator.connection || {})),
downlinkMax: navigator.connection?.downlinkMax ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
webdriver: navigator.webdriver,
isPlaywright: "__pwInitScripts" in window || "__playwright__binding__" in window,
webgpu: typeof navigator.gpu !== "undefined" ? "available" : "NOT_AVAILABLE",
scrollbarWidth: (() => { const d = document.createElement("div"); d.style.cssText = "overflow:scroll;width:100px;height:100px;position:absolute;top:-999px"; document.body.appendChild(d); const w = d.offsetWidth - d.clientWidth; d.remove(); return w; })()
})""")
print("\nHeadless detection signals:")
headless_fails = 0
for k, v in apis.items():
is_fail = k.startswith("no") and v is True
if is_fail:
headless_fails += 1
flag = "FAIL" if is_fail else ""
print(f" {k}: {v} {flag}")
# Extract bot test results from page
bot_tests = page.evaluate("""() => {
const text = document.body.innerText;
const tests = {};
for (const key of ['WebDriver', 'Is Selenium Chrome', 'CDP Check', 'Is Playwright']) {
const match = text.match(new RegExp(key + '\\\\s+(true|false)'));
if (match) tests[key] = match[1];
}
return tests;
}""")
print("\nBot Detection Tests:")
for k, v in bot_tests.items():
status = "PASS" if v == "false" else "FAIL"
print(f" {k}: {v} [{status}]")
page.screenshot(path="/results/fingerprint-scan.png", full_page=True)
print("\nScreenshot: /results/fingerprint-scan.png")
return {
"score": score,
"headless_fails": headless_fails,
"apis": apis,
"bot_tests": bot_tests,
}
def test_creepjs(page):
"""abrahamjuliot.github.io/creepjs — comprehensive fingerprint analysis."""
print("\n=== CreepJS ===")
page.goto(
"https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000
)
print("Waiting 30s for CreepJS analysis...")
time.sleep(30)
# Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py)
scores = page.evaluate("""() => {
const text = document.body.innerText;
const likeMatch = text.match(/(\\d+)%\\s*like headless/i);
const headlessMatch = text.match(/(\\d+)%\\s*headless:/i);
const stealthMatch = text.match(/(\\d+)%\\s*stealth:/i);
return {
likeHeadlessPct: likeMatch ? parseInt(likeMatch[1]) : null,
headlessPct: headlessMatch ? parseInt(headlessMatch[1]) : null,
stealthPct: stealthMatch ? parseInt(stealthMatch[1]) : null,
};
}""")
print(f"\nScores:")
print(f" like-headless: {scores['likeHeadlessPct']}% (target: <=30%)")
print(f" headless: {scores['headlessPct']}% (target: 0%)")
print(f" stealth: {scores['stealthPct']}% (target: 0%)")
# Extract full signal breakdown from window.Fingerprint.headless (CreepJS internal object)
signals = page.evaluate("""() => {
try {
const fp = window.Fingerprint;
if (!fp || !fp.headless) return null;
return {
likeHeadless: fp.headless.likeHeadless || null,
headless: fp.headless.headless || null,
stealth: fp.headless.stealth || null,
};
} catch { return null; }
}""")
if signals:
if signals.get("likeHeadless"):
print("\nlikeHeadless signals:")
fails = 0
for k, v in signals["likeHeadless"].items():
is_fail = v is True
if is_fail:
fails += 1
flag = " FAIL" if is_fail else ""
print(f" {k}: {v}{flag}")
print(f" ({fails} fails)")
if signals.get("headless"):
print("\nheadless signals:")
for k, v in signals["headless"].items():
flag = " FAIL" if v is True else ""
print(f" {k}: {v}{flag}")
if signals.get("stealth"):
print("\nstealth signals:")
for k, v in signals["stealth"].items():
flag = " FAIL" if v is True else ""
print(f" {k}: {v}{flag}")
else:
print("\n(window.Fingerprint.headless not available — signals not extracted)")
# Extract platform estimate
platform = page.evaluate("""() => {
try {
const fp = window.Fingerprint;
if (!fp || !fp.platformEstimate) return null;
return fp.platformEstimate;
} catch { return null; }
}""")
if platform:
print(f"\nPlatform estimate: {platform}")
passed = (
scores["headlessPct"] is not None
and scores["headlessPct"] <= 30
and scores["stealthPct"] is not None
and scores["stealthPct"] <= 30
)
print(f"\nVerdict: {'PASS' if passed else 'FAIL'} (<=30% headless, <=30% stealth)")
page.screenshot(path="/results/creepjs.png", full_page=True)
print("Screenshot: /results/creepjs.png")
return {**scores, "signals": signals, "platform": platform}
def main():
print("=" * 60)
print("CloakBrowser — Fingerprint & Headless Detection Tests")
print("=" * 60)
print(f"Mode: {'headless' if HEADLESS else 'headed'}")
print(f"Proxy: {PROXY or 'none'}")
print()
context = launch_context(
headless=HEADLESS,
proxy=PROXY,
args=[
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--fingerprint-timezone=Asia/Jerusalem",
],
)
page = context.new_page()
try:
fp_result = test_fingerprint_scan(page)
creep_result = test_creepjs(page)
finally:
context.close()
# Summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"fingerprint-scan.com: {fp_result['score']}")
print(f" Headless signal fails: {fp_result['headless_fails']}")
like = creep_result["likeHeadlessPct"]
headless = creep_result["headlessPct"]
stealth = creep_result["stealthPct"]
print(f"CreepJS: like-headless={like}%, headless={headless}%, stealth={stealth}%")
# Count CreepJS signal fails
sigs = creep_result.get("signals")
if sigs and sigs.get("likeHeadless"):
fail_names = [k for k, v in sigs["likeHeadless"].items() if v is True]
if fail_names:
print(f" likeHeadless fails: {', '.join(fail_names)}")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -1
View File
@@ -5,6 +5,8 @@ Expected: 0.9 (human-level) with cloakbrowser.
Default Playwright typically scores 0.1-0.3. Default Playwright typically scores 0.1-0.3.
""" """
import time
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=True) browser = launch(headless=True)
@@ -18,7 +20,7 @@ page.wait_for_load_state("networkidle")
button = page.query_selector("button") button = page.query_selector("button")
if button: if button:
button.click() button.click()
page.wait_for_timeout(3000) time.sleep(3)
# Extract score from page # Extract score from page
content = page.content() content = page.content()
+13 -9
View File
@@ -27,7 +27,7 @@ for i, arg in enumerate(sys.argv):
def test_bot_sannysoft(page): def test_bot_sannysoft(page):
"""bot.sannysoft.com — classic bot detection checks.""" """bot.sannysoft.com — classic bot detection checks."""
page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000) page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(3000) time.sleep(3)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const rows = document.querySelectorAll('table tr'); const rows = document.querySelectorAll('table tr');
@@ -53,7 +53,7 @@ def test_bot_sannysoft(page):
def test_bot_incolumitas(page): def test_bot_incolumitas(page):
"""bot.incolumitas.com — comprehensive 30+ check bot detection.""" """bot.incolumitas.com — comprehensive 30+ check bot detection."""
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000) page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(12000) # needs time to run all detection tests time.sleep(12) # needs time to run all detection tests
# Site outputs JSON blocks in page text, not HTML tables # Site outputs JSON blocks in page text, not HTML tables
results = page.evaluate("""() => { results = page.evaluate("""() => {
@@ -74,7 +74,7 @@ def test_bot_incolumitas(page):
def test_browserscan(page): def test_browserscan(page):
"""browserscan.net/bot-detection — WebDriver, UA, CDP, Navigator checks.""" """browserscan.net/bot-detection — WebDriver, UA, CDP, Navigator checks."""
page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000) page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(5)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const items = document.querySelectorAll('[class*="result"], [class*="item"], [class*="check"]'); const items = document.querySelectorAll('[class*="result"], [class*="item"], [class*="check"]');
@@ -95,7 +95,7 @@ def test_browserscan(page):
def test_deviceandbrowserinfo(page): def test_deviceandbrowserinfo(page):
"""deviceandbrowserinfo.com/are_you_a_bot — fingerprint + behavioral detection.""" """deviceandbrowserinfo.com/are_you_a_bot — fingerprint + behavioral detection."""
page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000) page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(8000) time.sleep(8)
results = page.evaluate("""() => { results = page.evaluate("""() => {
const text = document.body.innerText; const text = document.body.innerText;
@@ -120,12 +120,12 @@ def test_deviceandbrowserinfo(page):
def test_fingerprintjs(page): def test_fingerprintjs(page):
"""demo.fingerprint.com/web-scraping — industry-standard bot detection.""" """demo.fingerprint.com/web-scraping — industry-standard bot detection."""
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000) page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(8000) time.sleep(8)
# Click search to trigger bot detection — bots get blocked, humans see flights # Click search to trigger bot detection — bots get blocked, humans see flights
try: try:
page.click("button:has-text('Search')", timeout=5000) page.click("button:has-text('Search')", timeout=5000)
page.wait_for_timeout(5000) time.sleep(5)
except Exception: except Exception:
pass pass
@@ -143,11 +143,15 @@ def test_recaptcha(page):
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score.""" """recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
page.goto( page.goto(
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php", "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
wait_until="networkidle", wait_until="domcontentloaded",
timeout=30000, timeout=30000,
) )
# Page auto-submits via grecaptcha.execute() — wait for backend response # Wait for backend response (step3 element appears when score arrives)
page.wait_for_timeout(8000) try:
page.wait_for_selector("li.step3", timeout=20000)
time.sleep(1)
except Exception:
time.sleep(10) # fallback
results = page.evaluate("""() => { results = page.evaluate("""() => {
const text = document.body.innerText; const text = document.body.innerText;
+61 -6
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests. Drop-in Playwright/Puppeteer replacement. Same API — just swap the import. Scores **0.9 on reCAPTCHA v3**, passes **Cloudflare Turnstile**, and clears **30/30** stealth detection tests.
- 🔒 **16 source-level C++ patches** — not JS injection, not config flags - 🔒 **25 source-level C++ patches** — not JS injection, not config flags
- 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified - 🎯 **0.9 reCAPTCHA v3 score** — human-level, server-verified
- ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests - ☁️ **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — 30/30 tests
- 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer - 🔄 **Drop-in replacement** — works with both Playwright and Puppeteer
@@ -75,7 +75,19 @@ const browser = await launch({
args: ['--window-size=1920,1080'], args: ['--window-size=1920,1080'],
}); });
// Browser + context in one call // With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
const browser = await launch({
timezone: 'America/New_York',
locale: 'en-US',
});
// Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib)
const browser = await launch({
proxy: 'http://proxy:8080',
geoip: true,
});
// Browser + context in one call (timezone/locale set both binary flags AND context)
const context = await launchContext({ const context = await launchContext({
userAgent: 'Custom UA', userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
@@ -84,6 +96,27 @@ const context = await launchContext({
}); });
``` ```
### Auto Timezone/Locale from Proxy IP
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use):
```bash
npm install mmdb-lib
```
```javascript
// Auto-detect — timezone and locale set from proxy's IP geolocation
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true });
// Works with launchContext too
const context = await launchContext({ proxy: 'http://proxy:8080', geoip: true });
// Explicit values always win over auto-detection
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone: 'Europe/London' });
```
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### Utilities ### Utilities
```javascript ```javascript
@@ -122,6 +155,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | | `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL | | `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks | | `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 |
## Migrate From Playwright ## Migrate From Playwright
@@ -142,15 +176,35 @@ const page = await browser.newPage();
| Linux x86_64 | ✅ Available | | Linux x86_64 | ✅ Available |
| macOS arm64 (Apple Silicon) | ✅ Available | | macOS arm64 (Apple Silicon) | ✅ Available |
| macOS x86_64 (Intel) | ✅ Available | | macOS x86_64 (Intel) | ✅ Available |
| Windows | Planned | | Windows x86_64 | ✅ Available |
**On Windows?** You can still use CloakBrowser via Docker or with your own Chromium binary by setting `CLOAKBROWSER_BINARY_PATH=/path/to/chrome`.
## Requirements ## Requirements
- Node.js >= 18 - Node.js >= 18
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21 - One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting
**reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
```javascript
// Bad — sends CDP commands, reCAPTCHA detects this
await page.waitForTimeout(3000);
// Good — invisible to the browser
await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **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** (`--fingerprint=12345`) for consistent device identity across sessions
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
## Links ## Links
- 🌐 [Website](https://cloakbrowser.dev) - 🌐 [Website](https://cloakbrowser.dev)
@@ -161,4 +215,5 @@ const page = await browser.newPage();
## License ## License
MIT — see [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/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).
+18 -2
View File
@@ -1,18 +1,19 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.1.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tar": "^7.0.0" "tar": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
@@ -22,10 +23,14 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0" "puppeteer-core": ">=21.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"mmdb-lib": {
"optional": true
},
"playwright-core": { "playwright-core": {
"optional": true "optional": true
}, },
@@ -1839,6 +1844,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mmdb-lib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz",
"integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+15 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.2.0", "version": "0.3.3",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -25,12 +25,20 @@
"playwright", "playwright",
"puppeteer", "puppeteer",
"scraping", "scraping",
"web-scraping",
"anti-detect", "anti-detect",
"antidetect",
"undetected",
"bot-detection", "bot-detection",
"fingerprint", "fingerprint",
"recaptcha", "recaptcha",
"cloudflare", "cloudflare",
"datadome" "turnstile",
"datadome",
"captcha",
"headless",
"automation",
"ai-agent"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -43,6 +51,7 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0" "puppeteer-core": ">=21.0.0"
}, },
@@ -52,6 +61,9 @@
}, },
"puppeteer-core": { "puppeteer-core": {
"optional": true "optional": true
},
"mmdb-lib": {
"optional": true
} }
}, },
"dependencies": { "dependencies": {
@@ -59,6 +71,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
+83 -23
View File
@@ -6,11 +6,35 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
// Read wrapper version from package.json (single source of truth)
let WRAPPER_VERSION = "0.0.0";
try {
const _configDir = path.dirname(fileURLToPath(import.meta.url));
const _pkgPath = path.resolve(_configDir, "..", "package.json");
const _pkg = JSON.parse(fs.readFileSync(_pkgPath, "utf-8")) as { version: string };
WRAPPER_VERSION = _pkg.version;
} catch {
// Fallback — package.json not found (bundled or unusual layout).
// Wrapper update check will compare against 0.0.0 and always suggest updating.
}
export { WRAPPER_VERSION };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chromium version shipped with this release // Chromium version shipped with this release.
// Different platforms may ship different versions during transition periods.
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "142.0.7444.175"; export const CHROMIUM_VERSION = "145.0.7632.109";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.109",
"darwin-arm64": "145.0.7632.109",
"darwin-x64": "145.0.7632.109",
"windows-x64": "145.0.7632.109",
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Platform detection // Platform detection
@@ -20,11 +44,16 @@ const SUPPORTED_PLATFORMS: Record<string, string> = {
"linux-arm64": "linux-arm64", "linux-arm64": "linux-arm64",
"darwin-arm64": "darwin-arm64", "darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64", "darwin-x64": "darwin-x64",
"win32-x64": "windows-x64",
}; };
// Platforms with pre-built binaries available for download. // Platforms with pre-built binaries available for download (derived from version map).
// Update this set as new platform builds are released. const AVAILABLE_PLATFORMS = new Set(Object.keys(PLATFORM_CHROMIUM_VERSIONS));
const AVAILABLE_PLATFORMS = new Set(["linux-x64", "darwin-arm64", "darwin-x64"]);
export function getChromiumVersion(): string {
const tag = getPlatformTag();
return PLATFORM_CHROMIUM_VERSIONS[tag] ?? CHROMIUM_VERSION;
}
export function getPlatformTag(): string { export function getPlatformTag(): string {
const platform = process.platform; const platform = process.platform;
@@ -36,6 +65,7 @@ export function getPlatformTag(): string {
else if (platform === "linux" && arch === "arm64") key = "linux-arm64"; else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64"; else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
else if (platform === "darwin" && arch === "x64") key = "darwin-x64"; else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
else if (platform === "win32" && arch === "x64") key = "win32-x64";
else { else {
const supported = Object.values(SUPPORTED_PLATFORMS).join(", "); const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
throw new Error( throw new Error(
@@ -56,7 +86,7 @@ export function getCacheDir(): string {
} }
export function getBinaryDir(version?: string): string { export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${version || CHROMIUM_VERSION}`); return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
} }
export function getBinaryPath(version?: string): string { export function getBinaryPath(version?: string): string {
@@ -64,6 +94,9 @@ export function getBinaryPath(version?: string): string {
if (process.platform === "darwin") { if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium"); return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
} }
if (process.platform === "win32") {
return path.join(binaryDir, "chrome.exe");
}
return path.join(binaryDir, "chrome"); return path.join(binaryDir, "chrome");
} }
@@ -74,9 +107,8 @@ export function checkPlatformAvailable(): void {
if (!AVAILABLE_PLATFORMS.has(tag)) { if (!AVAILABLE_PLATFORMS.has(tag)) {
const available = [...AVAILABLE_PLATFORMS].sort().join(", "); const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
throw new Error( throw new Error(
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n` + `CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
`Windows builds are coming soon.\n\n` + `To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
`To use CloakBrowser now, run in Docker (see README) or set CLOAKBROWSER_BINARY_PATH.`
); );
} }
} }
@@ -91,28 +123,42 @@ export const DOWNLOAD_BASE_URL =
export const GITHUB_API_URL = export const GITHUB_API_URL =
"https://api.github.com/repos/CloakHQ/cloakbrowser/releases"; "https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
export const GITHUB_DOWNLOAD_BASE_URL =
"https://github.com/CloakHQ/cloakbrowser/releases/download";
export function getDownloadUrl(version?: string): string { export function getDownloadUrl(version?: string): string {
const v = version || CHROMIUM_VERSION; const v = version || getChromiumVersion();
const tag = getPlatformTag(); const tag = getPlatformTag();
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`; return `${DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
} }
export function getFallbackDownloadUrl(version?: string): string {
const v = version || getChromiumVersion();
const tag = getPlatformTag();
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/cloakbrowser-${tag}.tar.gz`;
}
export function getEffectiveVersion(): string { export function getEffectiveVersion(): string {
const marker = path.join(getCacheDir(), "latest_version"); const base = getChromiumVersion();
try { const cacheDir = getCacheDir();
if (fs.existsSync(marker)) { // Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
const version = fs.readFileSync(marker, "utf-8").trim(); for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
if (version && versionNewer(version, CHROMIUM_VERSION)) { const marker = path.join(cacheDir, name);
const binary = getBinaryPath(version); try {
if (fs.existsSync(binary)) { if (fs.existsSync(marker)) {
return version; const version = fs.readFileSync(marker, "utf-8").trim();
if (version && versionNewer(version, base)) {
const binary = getBinaryPath(version);
if (fs.existsSync(binary)) {
return version;
}
} }
} }
} catch {
// Marker unreadable — try next
} }
} catch {
// Marker unreadable — fall back to hardcoded
} }
return CHROMIUM_VERSION; return base;
} }
export function parseVersion(v: string): number[] { export function parseVersion(v: string): number[] {
@@ -139,6 +185,11 @@ export function getLocalBinaryOverride(): string | undefined {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Default stealth arguments // 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)
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
export function getDefaultStealthArgs(): string[] { export function getDefaultStealthArgs(): string[] {
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999 const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
@@ -151,15 +202,24 @@ export function getDefaultStealthArgs(): string[] {
if (isMac) { if (isMac) {
// macOS: run as native Mac browser — GPU/UA match natively // macOS: run as native Mac browser — GPU/UA match natively
return [...base, "--fingerprint-platform=macos"]; return [
...base,
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
];
} }
// Linux: spoof as Windows // Linux/Windows: Windows fingerprint profile
return [ return [
...base, ...base,
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-hardware-concurrency=8", "--fingerprint-hardware-concurrency=8",
"--fingerprint-device-memory=8",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
"--fingerprint-screen-width=1920",
"--fingerprint-screen-height=1080",
"--window-size=1920,1080",
]; ];
} }
+160 -16
View File
@@ -5,6 +5,7 @@
*/ */
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
@@ -13,14 +14,18 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js"; import type { BinaryInfo } from "./types.js";
import { import {
CHROMIUM_VERSION, DOWNLOAD_BASE_URL,
GITHUB_API_URL, GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
WRAPPER_VERSION,
checkPlatformAvailable, checkPlatformAvailable,
getBinaryDir, getBinaryDir,
getBinaryPath, getBinaryPath,
getCacheDir, getCacheDir,
getChromiumVersion,
getDownloadUrl, getDownloadUrl,
getEffectiveVersion, getEffectiveVersion,
getFallbackDownloadUrl,
getLocalBinaryOverride, getLocalBinaryOverride,
getPlatformTag, getPlatformTag,
versionNewer, versionNewer,
@@ -62,8 +67,9 @@ export async function ensureBinary(): Promise<string> {
return binaryPath; return binaryPath;
} }
// Fall back to hardcoded version if effective version binary doesn't exist // Fall back to platform's hardcoded version if effective version binary doesn't exist
if (effective !== CHROMIUM_VERSION) { const platformVersion = getChromiumVersion();
if (effective !== platformVersion) {
const fallbackPath = getBinaryPath(); const fallbackPath = getBinaryPath();
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) { if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
maybeTriggerUpdateCheck(); maybeTriggerUpdateCheck();
@@ -71,9 +77,9 @@ export async function ensureBinary(): Promise<string> {
} }
} }
// Download hardcoded version // Download platform's hardcoded version
console.log( console.log(
`[cloakbrowser] Stealth Chromium ${CHROMIUM_VERSION} not found. Downloading for ${getPlatformTag()}...` `[cloakbrowser] Stealth Chromium ${platformVersion} not found. Downloading for ${getPlatformTag()}...`
); );
await downloadAndExtract(); await downloadAndExtract();
@@ -116,7 +122,7 @@ export function binaryInfo(): BinaryInfo {
/** Manually check for a newer Chromium version. Returns new version or null. */ /** Manually check for a newer Chromium version. Returns new version or null. */
export async function checkForUpdate(): Promise<string | null> { export async function checkForUpdate(): Promise<string | null> {
const latest = await getLatestChromiumVersion(); const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return null; if (!latest || !versionNewer(latest, getChromiumVersion())) return null;
const binaryDir = getBinaryDir(latest); const binaryDir = getBinaryDir(latest);
if (fs.existsSync(binaryDir)) { if (fs.existsSync(binaryDir)) {
@@ -135,7 +141,8 @@ export async function checkForUpdate(): Promise<string | null> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function downloadAndExtract(version?: string): Promise<void> { async function downloadAndExtract(version?: string): Promise<void> {
const url = getDownloadUrl(version); const primaryUrl = getDownloadUrl(version);
const fallbackUrl = getFallbackDownloadUrl(version);
const binaryDir = getBinaryDir(version); const binaryDir = getBinaryDir(version);
const binaryPath = getBinaryPath(version); const binaryPath = getBinaryPath(version);
@@ -149,7 +156,24 @@ async function downloadAndExtract(version?: string): Promise<void> {
); );
try { try {
await downloadFile(url, tmpPath); // Try primary server, fall back to GitHub Releases (skip fallback if custom URL)
try {
await downloadFile(primaryUrl, tmpPath);
} catch (primaryErr) {
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
throw primaryErr;
}
console.warn(
`[cloakbrowser] Primary download failed (${primaryErr instanceof Error ? primaryErr.message : primaryErr}), trying GitHub Releases...`
);
await downloadFile(fallbackUrl, tmpPath);
}
// Verify checksum before extraction
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
await verifyDownloadChecksum(tmpPath, version);
}
await extractArchive(tmpPath, binaryDir, binaryPath); await extractArchive(tmpPath, binaryDir, binaryPath);
console.log( console.log(
`[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.` `[cloakbrowser] Visit https://cloakbrowser.dev for docs and release notifications.`
@@ -168,6 +192,82 @@ async function downloadAndExtract(version?: string): Promise<void> {
} }
} }
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
const tarballName = `cloakbrowser-${getPlatformTag()}.tar.gz`;
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
return;
}
const expected = checksums.get(tarballName);
if (!expected) {
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
return;
}
await verifyChecksum(filePath, expected);
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
// Respect custom URL contract — no GitHub fallback when custom URL is set
const urls = [`${DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`];
if (!hasCustomUrl) {
urls.push(`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`);
}
for (const url of urls) {
try {
const resp = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) continue;
return parseChecksums(await resp.text());
} catch {
continue;
}
}
return null;
}
/** @internal Exported for testing only. */
export function parseChecksums(text: string): Map<string, string> {
const result = new Map<string, string>();
for (const line of text.trim().split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (match) {
result.set(match[2]!, match[1]!.toLowerCase());
}
}
return result;
}
async function verifyChecksum(filePath: string, expectedHash: string): Promise<void> {
const hash = createHash("sha256");
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
const actual = hash.digest("hex").toLowerCase();
if (actual !== expectedHash) {
throw new Error(
`Checksum verification failed!\n` +
` Expected: ${expectedHash}\n` +
` Got: ${actual}\n` +
` File may be corrupted or tampered with. ` +
`Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues`
);
}
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
}
async function downloadFile(url: string, dest: string): Promise<void> { async function downloadFile(url: string, dest: string): Promise<void> {
console.log(`[cloakbrowser] Downloading from ${url}`); console.log(`[cloakbrowser] Downloading from ${url}`);
@@ -263,9 +363,9 @@ async function extractArchive(
// Flatten single subdirectory if needed // Flatten single subdirectory if needed
flattenSingleSubdir(destDir); flattenSingleSubdir(destDir);
// Make binary executable // Make binary executable (skip on Windows — no-op / AV lock risk)
const bp = binaryPath || getBinaryPath(); const bp = binaryPath || getBinaryPath();
if (fs.existsSync(bp)) { if (process.platform !== "win32" && fs.existsSync(bp)) {
fs.chmodSync(bp, 0o755); fs.chmodSync(bp, 0o755);
} }
@@ -340,7 +440,8 @@ function shouldCheckForUpdate(): boolean {
return true; return true;
} }
async function getLatestChromiumVersion(): Promise<string | null> { /** @internal Exported for testing only. */
export async function getLatestChromiumVersion(): Promise<string | null> {
try { try {
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, { const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
@@ -349,10 +450,17 @@ async function getLatestChromiumVersion(): Promise<string | null> {
const releases = (await resp.json()) as Array<{ const releases = (await resp.json()) as Array<{
tag_name: string; tag_name: string;
draft: boolean; draft: boolean;
assets: Array<{ name: string }>;
}>; }>;
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
for (const release of releases) { for (const release of releases) {
if (release.tag_name.startsWith("chromium-v") && !release.draft) { if (release.tag_name.startsWith("chromium-v") && !release.draft) {
return release.tag_name.replace("chromium-v", ""); const assetNames = new Set(
(release.assets ?? []).map((a) => a.name)
);
if (assetNames.has(platformTarball)) {
return release.tag_name.replace(/^chromium-v/, "");
}
} }
} }
return null; return null;
@@ -364,12 +472,42 @@ async function getLatestChromiumVersion(): Promise<string | null> {
function writeVersionMarker(version: string): void { function writeVersionMarker(version: string): void {
const cacheDir = getCacheDir(); const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true }); fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, "latest_version"); const marker = path.join(cacheDir, `latest_version_${getPlatformTag()}`);
const tmp = `${marker}.tmp`; const tmp = `${marker}.tmp`;
fs.writeFileSync(tmp, version); fs.writeFileSync(tmp, version);
fs.renameSync(tmp, marker); fs.renameSync(tmp, marker);
} }
let wrapperUpdateChecked = false;
/** @internal Exported for testing only. */
export function resetWrapperUpdateChecked(): void {
wrapperUpdateChecked = false;
}
/** @internal Exported for testing only. */
export async function checkWrapperUpdate(): Promise<void> {
if (wrapperUpdateChecked) return;
wrapperUpdateChecked = true;
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false") return;
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return;
try {
const resp = await fetch("https://registry.npmjs.org/cloakbrowser/latest", {
signal: AbortSignal.timeout(5_000),
});
if (!resp.ok) return;
const data = (await resp.json()) as { version: string };
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
console.warn(
`[cloakbrowser] Update available: ${WRAPPER_VERSION}${data.version}. ` +
`Run: npm install cloakbrowser@latest`
);
}
} catch {
// Non-fatal — never block binary update check
}
}
async function checkAndDownloadUpdate(): Promise<void> { async function checkAndDownloadUpdate(): Promise<void> {
try { try {
// Record check timestamp first (rate limiting) // Record check timestamp first (rate limiting)
@@ -380,8 +518,9 @@ async function checkAndDownloadUpdate(): Promise<void> {
String(Date.now()) String(Date.now())
); );
const platformVersion = getChromiumVersion();
const latest = await getLatestChromiumVersion(); const latest = await getLatestChromiumVersion();
if (!latest || !versionNewer(latest, CHROMIUM_VERSION)) return; if (!latest || !versionNewer(latest, platformVersion)) return;
// Already downloaded? // Already downloaded?
if (fs.existsSync(getBinaryDir(latest))) { if (fs.existsSync(getBinaryDir(latest))) {
@@ -390,7 +529,7 @@ async function checkAndDownloadUpdate(): Promise<void> {
} }
console.log( console.log(
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${CHROMIUM_VERSION}). Downloading in background...` `[cloakbrowser] Newer Chromium available: ${latest} (current: ${platformVersion}). Downloading in background...`
); );
await downloadAndExtract(latest); await downloadAndExtract(latest);
writeVersionMarker(latest); writeVersionMarker(latest);
@@ -406,7 +545,12 @@ async function checkAndDownloadUpdate(): Promise<void> {
} }
function maybeTriggerUpdateCheck(): void { function maybeTriggerUpdateCheck(): void {
// Wrapper update: once per process, not rate-limited
if (!wrapperUpdateChecked) {
checkWrapperUpdate().catch(() => {});
}
// Binary update: rate-limited to once per hour
if (!shouldCheckForUpdate()) return; if (!shouldCheckForUpdate()) return;
// Fire-and-forget — don't await
checkAndDownloadUpdate().catch(() => {}); checkAndDownloadUpdate().catch(() => {});
} }
+262
View File
@@ -0,0 +1,262 @@
/**
* GeoIP-based timezone and locale detection from proxy IP.
*
* Optional feature requires `mmdb-lib` package:
* npm install mmdb-lib
*
* Downloads GeoLite2-City.mmdb (~70 MB) on first use,
* caches in `~/.cloakbrowser/geoip/`.
*/
import fs from "node:fs";
import path from "node:path";
import { createWriteStream } from "node:fs";
import dns from "node:dns/promises";
import net from "node:net";
import { getCacheDir } from "./config.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
US: "en-US", GB: "en-GB", AU: "en-AU", CA: "en-CA", NZ: "en-NZ",
IE: "en-IE", ZA: "en-ZA", SG: "en-SG",
DE: "de-DE", AT: "de-AT", CH: "de-CH",
FR: "fr-FR", BE: "fr-BE",
ES: "es-ES", MX: "es-MX", AR: "es-AR", CO: "es-CO", CL: "es-CL",
BR: "pt-BR", PT: "pt-PT",
IT: "it-IT", NL: "nl-NL",
JP: "ja-JP", KR: "ko-KR", CN: "zh-CN", TW: "zh-TW", HK: "zh-HK",
RU: "ru-RU", UA: "uk-UA", PL: "pl-PL", CZ: "cs-CZ", RO: "ro-RO",
IL: "he-IL", TR: "tr-TR", SA: "ar-SA", AE: "ar-AE", EG: "ar-EG",
IN: "hi-IN", ID: "id-ID", PH: "en-PH",
TH: "th-TH", VN: "vi-VN", MY: "ms-MY",
SE: "sv-SE", NO: "nb-NO", DK: "da-DK", FI: "fi-FI",
GR: "el-GR", HU: "hu-HU", BG: "bg-BG",
};
export interface GeoResult {
timezone: string | null;
locale: string | null;
}
/**
* Resolve timezone and locale from a proxy's IP address.
* Returns `{ timezone, locale }` either may be null on failure.
* Never throws.
*/
export async function resolveProxyGeo(
proxyUrl: string
): Promise<GeoResult> {
let Reader: any;
try {
const mmdb = await import("mmdb-lib");
Reader = mmdb.default?.Reader ?? mmdb.Reader;
} catch {
throw new Error(
"mmdb-lib is required for geoip: true. Install it with:\n npm install mmdb-lib"
);
}
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null };
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null };
try {
const buf = fs.readFileSync(dbPath);
const reader = new Reader(buf);
const result = reader.get(ip) as any;
const timezone: string | null = result?.location?.time_zone ?? null;
const countryCode: string | null = result?.country?.iso_code ?? null;
const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale };
} catch {
return { timezone: null, locale: null };
}
}
// ---------------------------------------------------------------------------
// Proxy IP resolution
// ---------------------------------------------------------------------------
/** @internal Exported for testing. */
export async function resolveProxyIp(
proxyUrl: string
): Promise<string | null> {
try {
const url = new URL(proxyUrl);
const hostname = url.hostname;
if (!hostname) return null;
// Already a literal IP?
if (net.isIP(hostname)) return hostname;
// DNS resolve
const { address } = await dns.lookup(hostname);
return address;
} catch {
return null;
}
}
function isPrivateIp(ip: string): boolean {
// Quick check for common private ranges
if (ip.startsWith("10.") || ip.startsWith("127.") || ip === "::1") return true;
if (ip.startsWith("172.")) {
const second = parseInt(ip.split(".")[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith("192.168.")) return true;
return false;
}
const IP_ECHO_URLS = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ifconfig.me/ip",
];
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
// For simplicity, use a direct HTTP request to a plain-text IP echo service
// through the proxy using Node's http module
try {
const { default: http } = await import("node:http");
const { default: https } = await import("node:https");
const proxyUrlObj = new URL(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
try {
const ip = await new Promise<string | null>((resolve, reject) => {
const targetUrl = new URL(echoUrl);
const connectReq = http.request({
host: proxyUrlObj.hostname,
port: parseInt(proxyUrlObj.port || "80", 10),
method: "CONNECT",
path: `${targetUrl.hostname}:443`,
headers: proxyUrlObj.username
? {
"Proxy-Authorization":
"Basic " +
Buffer.from(
`${decodeURIComponent(proxyUrlObj.username)}:${decodeURIComponent(proxyUrlObj.password || "")}`
).toString("base64"),
}
: {},
timeout: 10_000,
});
connectReq.on("connect", (_res, socket) => {
const req = https.request(
echoUrl,
{ socket, timeout: 5_000 } as any,
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const ip = data.trim();
resolve(net.isIP(ip) ? ip : null);
});
}
);
req.on("error", () => resolve(null));
req.end();
});
connectReq.on("error", () => resolve(null));
connectReq.on("timeout", () => {
connectReq.destroy();
resolve(null);
});
connectReq.end();
});
if (ip) return ip;
} catch {
continue;
}
}
} catch {
// Fallback: couldn't import http modules
}
return null;
}
// ---------------------------------------------------------------------------
// GeoIP database management
// ---------------------------------------------------------------------------
function getGeoipDir(): string {
return path.join(getCacheDir(), "geoip");
}
async function ensureGeoipDb(): Promise<string | null> {
const dir = getGeoipDir();
const dbPath = path.join(dir, GEOIP_DB_FILENAME);
if (fs.existsSync(dbPath)) {
maybeTriggerUpdate(dbPath);
return dbPath;
}
try {
await downloadGeoipDb(dbPath);
return dbPath;
} catch {
return null;
}
}
async function downloadGeoipDb(dest: string): Promise<void> {
const dir = path.dirname(dest);
fs.mkdirSync(dir, { recursive: true });
console.log("[cloakbrowser] Downloading GeoIP database (~70 MB)…");
const tmpPath = `${dest}.tmp.${Date.now()}`;
try {
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const fileStream = createWriteStream(tmpPath);
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
fileStream.write(value);
}
await new Promise<void>((resolve, reject) => {
fileStream.end(() => resolve());
fileStream.on("error", reject);
});
fs.renameSync(tmpPath, dest);
console.log(`[cloakbrowser] GeoIP database ready: ${dest}`);
} catch (err) {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
throw err;
}
}
function maybeTriggerUpdate(dbPath: string): void {
try {
const age = Date.now() - fs.statSync(dbPath).mtimeMs;
if (age < GEOIP_UPDATE_INTERVAL_MS) return;
} catch {
return;
}
// Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {});
}
+36 -6
View File
@@ -5,7 +5,7 @@
import type { Browser, BrowserContext } from "playwright-core"; import type { Browser, BrowserContext } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions } from "./types.js"; import type { LaunchOptions, LaunchContextOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js"; import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
@@ -26,7 +26,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const args = buildArgs(options); const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const browser = await chromium.launch({ const browser = await chromium.launch({
executablePath: binaryPath, executablePath: binaryPath,
@@ -59,15 +60,18 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext( export async function launchContext(
options: LaunchContextOptions = {} options: LaunchContextOptions = {}
): Promise<BrowserContext> { ): Promise<BrowserContext> {
const browser = await launch(options); // Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext; let context: BrowserContext;
try { try {
context = await browser.newContext({ context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
...(options.viewport ? { viewport: options.viewport } : {}), viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(options.locale ? { locale: options.locale } : {}), ...(resolved.locale ? { locale: resolved.locale } : {}),
...(options.timezoneId ? { timezoneId: options.timezoneId } : {}), ...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
}); });
} catch (err) { } catch (err) {
await browser.close(); await browser.close();
@@ -88,6 +92,25 @@ export async function launchContext(
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */
export function _buildArgsForTest(options: LaunchOptions): string[] {
return buildArgs(options);
}
function buildArgs(options: LaunchOptions): string[] { function buildArgs(options: LaunchOptions): string[] {
const args: string[] = []; const args: string[] = [];
if (options.stealthArgs !== false) { if (options.stealthArgs !== false) {
@@ -96,5 +119,12 @@ function buildArgs(options: LaunchOptions): string[] {
if (options.args) { if (options.args) {
args.push(...options.args); args.push(...options.args);
} }
// Timezone/locale flags — always inject when set
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args; return args;
} }
+22 -1
View File
@@ -26,7 +26,8 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core"); const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const args = buildArgs(options); const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
// Puppeteer handles proxy via CLI args, not a separate option. // Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials, // Chromium's --proxy-server does NOT support inline credentials,
@@ -66,6 +67,20 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
function buildArgs(options: LaunchOptions): string[] { function buildArgs(options: LaunchOptions): string[] {
const args: string[] = []; const args: string[] = [];
if (options.stealthArgs !== false) { if (options.stealthArgs !== false) {
@@ -74,5 +89,11 @@ function buildArgs(options: LaunchOptions): string[] {
if (options.args) { if (options.args) {
args.push(...options.args); args.push(...options.args);
} }
if (options.timezone) {
args.push(`--fingerprint-timezone=${options.timezone}`);
}
if (options.locale) {
args.push(`--lang=${options.locale}`);
}
return args; return args;
} }
+8
View File
@@ -11,6 +11,12 @@ export interface LaunchOptions {
args?: string[]; args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */ /** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
stealthArgs?: boolean; stealthArgs?: boolean;
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
timezone?: string;
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
locale?: string;
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
geoip?: boolean;
/** Raw options passed directly to playwright/puppeteer launch(). */ /** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>; launchOptions?: Record<string, unknown>;
} }
@@ -24,6 +30,8 @@ export interface LaunchContextOptions extends LaunchOptions {
locale?: string; locale?: string;
/** Timezone, e.g. "America/New_York". */ /** Timezone, e.g. "America/New_York". */
timezoneId?: string; timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
} }
export interface BinaryInfo { export interface BinaryInfo {
+38 -5
View File
@@ -1,15 +1,17 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
CHROMIUM_VERSION, CHROMIUM_VERSION,
getChromiumVersion,
getDefaultStealthArgs, getDefaultStealthArgs,
getCacheDir, getCacheDir,
getBinaryDir, getBinaryDir,
getDownloadUrl, getDownloadUrl,
} from "../src/config.js"; } from "../src/config.js";
import { _buildArgsForTest } from "../src/playwright.js";
describe("config", () => { describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => { it("CHROMIUM_VERSION matches expected format", () => {
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+$/); expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+(\.\d+)?$/);
}); });
it("getDefaultStealthArgs returns expected flags", () => { it("getDefaultStealthArgs returns expected flags", () => {
@@ -52,16 +54,47 @@ describe("config", () => {
expect(dir).toContain(".cloakbrowser"); expect(dir).toContain(".cloakbrowser");
}); });
it("getBinaryDir includes version", () => { it("getBinaryDir includes platform version", () => {
const dir = getBinaryDir(); const dir = getBinaryDir();
expect(dir).toContain(`chromium-${CHROMIUM_VERSION}`); expect(dir).toContain(`chromium-${getChromiumVersion()}`);
}); });
it("getDownloadUrl contains version and platform tag", () => { it("getDownloadUrl contains platform version and platform tag", () => {
const url = getDownloadUrl(); const url = getDownloadUrl();
expect(url).toContain(CHROMIUM_VERSION); expect(url).toContain(getChromiumVersion());
expect(url).toContain("cloakbrowser-"); expect(url).toContain("cloakbrowser-");
expect(url).toContain(".tar.gz"); expect(url).toContain(".tar.gz");
expect(url).toContain("cloakbrowser.dev"); expect(url).toContain("cloakbrowser.dev");
}); });
}); });
describe("buildArgs timezone/locale", () => {
it("injects --fingerprint-timezone when timezone is set", () => {
const args = _buildArgsForTest({ timezone: "America/New_York" });
expect(args).toContain("--fingerprint-timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US");
});
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE");
});
it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
it("does not inject flags when not set", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
describe("resolveProxyIp", () => {
it("returns literal IPv4 from proxy URL", async () => {
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
it("handles proxy URL with credentials", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe(
"10.50.96.5"
);
});
it("resolves localhost", async () => {
const ip = await resolveProxyIp("http://localhost:8888");
expect(ip).toBeTruthy();
expect(["127.0.0.1", "::1"]).toContain(ip);
});
it("returns null for invalid URL", async () => {
expect(await resolveProxyIp("not-a-url")).toBeNull();
});
it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull();
});
});
describe("COUNTRY_LOCALE_MAP", () => {
it("contains common countries", () => {
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
expect(COUNTRY_LOCALE_MAP[code]).toBeDefined();
}
});
it("values are BCP 47 language-REGION format", () => {
for (const [code, locale] of Object.entries(COUNTRY_LOCALE_MAP)) {
const parts = locale.split("-");
expect(parts).toHaveLength(2);
expect(parts[0]).toMatch(/^[a-z]{2,3}$/);
expect(parts[1]).toMatch(/^[A-Z]{2}$/);
}
});
});
+3 -3
View File
@@ -1,13 +1,13 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { binaryInfo } from "../src/download.js"; import { binaryInfo } from "../src/download.js";
import { CHROMIUM_VERSION } from "../src/config.js"; import { getChromiumVersion } from "../src/config.js";
describe("binaryInfo", () => { describe("binaryInfo", () => {
it("returns correct structure", () => { it("returns correct structure", () => {
const info = binaryInfo(); const info = binaryInfo();
expect(info.version).toBe(CHROMIUM_VERSION); expect(info.version).toBe(getChromiumVersion());
expect(info.platform).toMatch(/^(linux|darwin)-(x64|arm64)$/); expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy(); expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean"); expect(typeof info.installed).toBe("boolean");
expect(info.cacheDir).toContain("cloakbrowser"); expect(info.cacheDir).toContain("cloakbrowser");
+216 -4
View File
@@ -1,11 +1,19 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { import {
CHROMIUM_VERSION, CHROMIUM_VERSION,
getChromiumVersion,
getDownloadUrl, getDownloadUrl,
getEffectiveVersion, getEffectiveVersion,
getPlatformTag,
parseVersion, parseVersion,
versionNewer, versionNewer,
} from "../src/config.js"; } from "../src/config.js";
import {
checkWrapperUpdate,
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
} from "../src/download.js";
describe("version comparison", () => { describe("version comparison", () => {
it("parseVersion handles 4-part versions", () => { it("parseVersion handles 4-part versions", () => {
@@ -32,13 +40,29 @@ describe("version comparison", () => {
it("major bump wins over minor", () => { it("major bump wins over minor", () => {
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true); expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
}); });
it("parseVersion handles 5-part build numbers", () => {
expect(parseVersion("145.0.7632.109.2")).toEqual([145, 0, 7632, 109, 2]);
});
it("build bump detected", () => {
expect(versionNewer("145.0.7632.109.3", "145.0.7632.109.2")).toBe(true);
});
it("build suffix newer than no suffix", () => {
expect(versionNewer("145.0.7632.109.2", "145.0.7632.109")).toBe(true);
});
it("no suffix older than build suffix", () => {
expect(versionNewer("145.0.7632.109", "145.0.7632.109.2")).toBe(false);
});
}); });
describe("download URL", () => { describe("download URL", () => {
it("uses chromium-v prefix and cloakbrowser repo", () => { it("uses chromium-v prefix and cloakbrowser repo", () => {
const url = getDownloadUrl(); const url = getDownloadUrl();
expect(url).toContain("cloakbrowser.dev"); expect(url).toContain("cloakbrowser.dev");
expect(url).toContain(`chromium-v${CHROMIUM_VERSION}`); expect(url).toContain(`chromium-v${getChromiumVersion()}`);
expect(url.endsWith(".tar.gz")).toBe(true); expect(url.endsWith(".tar.gz")).toBe(true);
}); });
@@ -53,9 +77,197 @@ describe("download URL", () => {
}); });
}); });
describe("latest version (platform-aware)", () => {
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
function makeAssets(platforms: string[]) {
return platforms.map((p) => ({ name: `cloakbrowser-${p}.tar.gz` }));
}
function mockFetch(releases: Array<Record<string, unknown>>) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => releases,
} as Response);
}
afterEach(() => {
vi.restoreAllMocks();
});
it("returns version when release has platform asset", async () => {
mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]);
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
});
it("skips release without platform asset", async () => {
const spy = mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: makeAssets(["linux-x64"]), // Linux only
},
{
tag_name: "chromium-v142.0.7444.175",
draft: false,
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]);
const result = await getLatestChromiumVersion();
const tag = getPlatformTag();
if (tag === "linux-x64") {
expect(result).toBe("145.0.7718.0");
} else {
expect(result).toBe("142.0.7444.175");
}
});
it("returns null when no release has platform asset", async () => {
mockFetch([
{
tag_name: "chromium-v145.0.7718.0",
draft: false,
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
},
]);
expect(await getLatestChromiumVersion()).toBeNull();
});
it("skips draft releases", async () => {
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
mockFetch([
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
]);
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
});
it("returns null on network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
expect(await getLatestChromiumVersion()).toBeNull();
});
});
describe("wrapper update check", () => {
beforeEach(() => {
resetWrapperUpdateChecked();
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
it("warns when newer version available", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "99.0.0" }),
} as Response);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await checkWrapperUpdate();
expect(spy).toHaveBeenCalledOnce();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Update available"));
});
it("silent when current version", async () => {
const { WRAPPER_VERSION } = await import("../src/config.js");
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: WRAPPER_VERSION }),
} as Response);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await checkWrapperUpdate();
expect(warnSpy).not.toHaveBeenCalled();
});
it("disabled by CLOAKBROWSER_AUTO_UPDATE=false", async () => {
process.env.CLOAKBROWSER_AUTO_UPDATE = "false";
const spy = vi.spyOn(globalThis, "fetch");
await checkWrapperUpdate();
expect(spy).not.toHaveBeenCalled();
});
it("disabled by CLOAKBROWSER_DOWNLOAD_URL", async () => {
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://mirror.example.com";
const spy = vi.spyOn(globalThis, "fetch");
await checkWrapperUpdate();
expect(spy).not.toHaveBeenCalled();
});
it("silent on network error", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await checkWrapperUpdate();
expect(warnSpy).not.toHaveBeenCalled();
});
it("runs only once per process", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.1" }),
} as Response);
await checkWrapperUpdate();
await checkWrapperUpdate();
expect(spy).toHaveBeenCalledOnce();
});
});
describe("parseChecksums", () => {
// Valid 64-char hex strings for testing
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
it("parses standard SHA256SUMS format", () => {
const text = [
`${HASH_A} cloakbrowser-linux-x64.tar.gz`,
`${HASH_B} cloakbrowser-darwin-arm64.tar.gz`,
].join("\n");
const result = parseChecksums(text);
expect(result.get("cloakbrowser-linux-x64.tar.gz")).toBe(HASH_A);
expect(result.get("cloakbrowser-darwin-arm64.tar.gz")).toBe(HASH_B);
});
it("handles binary-mode asterisk prefix", () => {
const text = `${HASH_A} *cloakbrowser-linux-x64.tar.gz`;
const result = parseChecksums(text);
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
});
it("skips empty lines", () => {
const text = `\n\n${HASH_A} file.tar.gz\n\n`;
expect(parseChecksums(text).size).toBe(1);
});
it("returns empty map for empty input", () => {
expect(parseChecksums("").size).toBe(0);
expect(parseChecksums(" \n \n").size).toBe(0);
});
});
describe("effective version", () => { describe("effective version", () => {
it("returns CHROMIUM_VERSION when no marker exists", () => { it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment // Default behavior — no marker file in test environment
expect(getEffectiveVersion()).toBe(CHROMIUM_VERSION); expect(getEffectiveVersion()).toBe(getChromiumVersion());
}); });
}); });
+14 -4
View File
@@ -17,15 +17,22 @@ keywords = [
"browser", "browser",
"chromium", "chromium",
"playwright", "playwright",
"puppeteer",
"scraping", "scraping",
"web-scraping",
"anti-detect", "anti-detect",
"antidetect",
"undetected",
"bot-detection",
"fingerprint",
"recaptcha", "recaptcha",
"cloudflare", "cloudflare",
"turnstile", "turnstile",
"bot-detection", "datadome",
"fingerprint", "captcha",
"web-scraping", "headless",
"automation", "automation",
"ai-agent",
] ]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
@@ -42,10 +49,13 @@ classifiers = [
"Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing",
] ]
dependencies = [ dependencies = [
"playwright>=1.40", "patchright>=1.40",
"httpx>=0.24", "httpx>=0.24",
] ]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
[project.urls] [project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser" Homepage = "https://github.com/CloakHQ/CloakBrowser"
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme" Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
+46
View File
@@ -0,0 +1,46 @@
"""Unit tests for _build_args timezone/locale injection."""
from cloakbrowser.browser import _build_args
def test_timezone_injected():
"""--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected():
"""--lang flag should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args
def test_both_injected():
"""Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
def test_timezone_independent_of_stealth_args():
"""--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args
# No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args)
def test_extra_args_preserved():
"""Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
+138
View File
@@ -0,0 +1,138 @@
"""Unit tests for GeoIP-based timezone/locale detection."""
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_resolve_proxy_ip,
)
# ---------------------------------------------------------------------------
# _resolve_proxy_ip
# ---------------------------------------------------------------------------
def test_resolve_literal_ipv4():
assert _resolve_proxy_ip("http://10.50.96.5:8888") == "10.50.96.5"
def test_resolve_literal_ipv4_with_auth():
assert _resolve_proxy_ip("http://user:pass@10.50.96.5:8888") == "10.50.96.5"
def test_resolve_literal_ipv6():
ip = _resolve_proxy_ip("http://[::1]:8888")
assert ip == "::1"
def test_resolve_hostname():
"""DNS resolution of a known hostname should return an IP."""
ip = _resolve_proxy_ip("http://localhost:8888")
assert ip is not None
assert ip in ("127.0.0.1", "::1")
def test_resolve_invalid_url():
assert _resolve_proxy_ip("not-a-url") is None
def test_resolve_empty():
assert _resolve_proxy_ip("") is None
# ---------------------------------------------------------------------------
# COUNTRY_LOCALE_MAP
# ---------------------------------------------------------------------------
def test_locale_map_has_common_countries():
for code in ("US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"):
assert code in COUNTRY_LOCALE_MAP, f"Missing {code}"
def test_locale_map_values_are_bcp47():
"""All locales should be language-REGION format."""
for code, locale in COUNTRY_LOCALE_MAP.items():
parts = locale.split("-")
assert len(parts) == 2, f"{code}: {locale} not language-REGION"
assert parts[0].islower(), f"{code}: language part should be lowercase"
assert parts[1].isupper(), f"{code}: region part should be uppercase"
# ---------------------------------------------------------------------------
# resolve_proxy_geo fallbacks
# ---------------------------------------------------------------------------
def test_resolve_geo_raises_when_geoip2_missing():
"""Should raise ImportError with install instructions when geoip2 not installed."""
with patch.dict("sys.modules", {"geoip2": None, "geoip2.database": None}):
from importlib import reload
import cloakbrowser.geoip as geoip_mod
reload(geoip_mod)
with pytest.raises(ImportError, match="pip install cloakbrowser"):
geoip_mod.resolve_proxy_geo("http://10.50.96.5:8888")
# Restore
reload(geoip_mod)
def test_resolve_geo_returns_none_when_db_missing():
"""Should return (None, None) when DB file doesn't exist."""
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=None):
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value=None):
from cloakbrowser.geoip import resolve_proxy_geo
assert resolve_proxy_geo("http://10.50.96.5:8888") == (None, None)
# ---------------------------------------------------------------------------
# _maybe_resolve_geoip (browser.py helper)
# ---------------------------------------------------------------------------
def test_maybe_resolve_skips_when_geoip_false():
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins
def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US"
def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
+3 -2
View File
@@ -1,7 +1,8 @@
"""Basic launch tests for cloakbrowser.""" """Basic launch tests for cloakbrowser."""
import pytest import pytest
from cloakbrowser import launch, launch_async, binary_info, CHROMIUM_VERSION from cloakbrowser import launch, launch_async, binary_info
from cloakbrowser.config import get_chromium_version
def test_binary_info(): def test_binary_info():
@@ -11,7 +12,7 @@ def test_binary_info():
assert "platform" in info assert "platform" in info
assert "binary_path" in info assert "binary_path" in info
assert "installed" in info assert "installed" in info
assert info["version"] == CHROMIUM_VERSION assert info["version"] == get_chromium_version()
def test_launch_and_close(): def test_launch_and_close():
+130 -19
View File
@@ -4,14 +4,19 @@ These tests verify that the stealth Chromium binary passes common
bot detection checks. They require network access. bot detection checks. They require network access.
""" """
import os
import time
import pytest import pytest
from cloakbrowser import launch from cloakbrowser import launch
PROXY = os.environ.get("CLOAKBROWSER_TEST_PROXY")
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def browser(): def browser():
"""Shared browser instance for stealth tests.""" """Shared browser instance for stealth tests."""
b = launch(headless=True) b = launch(headless=True, proxy=PROXY)
yield b yield b
b.close() b.close()
@@ -48,7 +53,7 @@ class TestWebDriverDetection:
"""Must have browser plugins (real Chrome has 5).""" """Must have browser plugins (real Chrome has 5)."""
page.goto("https://example.com") page.goto("https://example.com")
count = page.evaluate("navigator.plugins.length") count = page.evaluate("navigator.plugins.length")
assert count >= 1, f"Expected plugins, got {count}" assert count >= 5, f"Expected 5+ plugins (real Chrome), got {count}"
def test_languages_present(self, page): def test_languages_present(self, page):
"""navigator.languages must be populated.""" """navigator.languages must be populated."""
@@ -81,28 +86,134 @@ class TestBotDetectionSites:
Mark with pytest -m slow to skip in CI. Mark with pytest -m slow to skip in CI.
""" """
@pytest.mark.slow
def test_bot_sannysoft(self, page):
"""bot.sannysoft.com — all checks should pass (0 failures)."""
page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000)
time.sleep(3)
results = page.evaluate("""() => {
const rows = document.querySelectorAll('table tr');
const failed = [];
let total = 0;
rows.forEach(r => {
const cells = r.querySelectorAll('td');
if (cells.length >= 2) {
total++;
const cls = cells[1].className || '';
if (cls.includes('failed')) {
failed.push(cells[0].innerText.trim());
}
}
});
return {total, failed};
}""")
failed = results["failed"]
assert len(failed) == 0, f"Sannysoft failures: {', '.join(failed)}"
@pytest.mark.slow @pytest.mark.slow
def test_bot_incolumitas(self, page): def test_bot_incolumitas(self, page):
"""bot.incolumitas.com should detect minimal flags.""" """bot.incolumitas.com — max 1 failure (WEBDRIVER false positive expected)."""
page.goto("https://bot.incolumitas.com", timeout=30000) page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(12)
# Check that we're not immediately flagged
title = page.title() # Known acceptable failures (not browser fingerprint issues):
assert title # Page loaded successfully # - WEBDRIVER: spec-level false positive across all builds
# - connectionRTT: detects datacenter/proxy network latency, not browser
KNOWN_ACCEPTABLE = {"WEBDRIVER", "connectionRTT"}
results = page.evaluate("""() => {
const text = document.body.innerText;
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
return {passed: okMatches.length, failed: failMatches.length, failedTests};
}""")
failed_names = results["failedTests"]
real_failures = [f for f in failed_names if f not in KNOWN_ACCEPTABLE]
assert len(real_failures) == 0, f"Incolumitas unexpected failures: {', '.join(real_failures)}"
@pytest.mark.slow @pytest.mark.slow
def test_browserscan(self, page): def test_browserscan(self, page):
"""BrowserScan bot detection should show NORMAL.""" """BrowserScan bot detection — 0 abnormal checks."""
page.goto("https://www.browserscan.net/bot-detection", timeout=30000) page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(5000) time.sleep(5)
title = page.title()
assert title # Page loaded results = page.evaluate("""() => {
const text = document.body.innerText;
const normalMatches = text.match(/Normal/g);
const abnormalMatches = text.match(/Abnormal/g);
return {
normal: normalMatches ? normalMatches.length : 0,
abnormal: abnormalMatches ? abnormalMatches.length : 0
};
}""")
assert results["abnormal"] == 0, \
f"BrowserScan: {results['abnormal']} abnormal, {results['normal']} normal"
@pytest.mark.slow @pytest.mark.slow
def test_device_and_browser_info(self, page): def test_device_and_browser_info(self, page):
"""deviceandbrowserinfo.com should report isBot: false.""" """deviceandbrowserinfo.com — isBot must be false."""
page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", timeout=30000) page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(5000) time.sleep(8)
content = page.content()
# The page shows bot detection results results = page.evaluate("""() => {
assert "deviceandbrowserinfo" in page.url.lower() const text = document.body.innerText;
const botMatch = text.match(/"isBot":\\s*(true|false)/);
const isBot = botMatch ? botMatch[1] === 'true' : null;
const checks = {};
['isBot', 'hasBotUserAgent', 'hasWebdriverTrue', 'isHeadlessChrome',
'isAutomatedWithCDP', 'hasSuspiciousWeakSignals', 'isPlaywright',
'hasInconsistentChromeObject'].forEach(p => {
const match = text.match(new RegExp('"' + p + '":\\s*(true|false)'));
if (match) checks[p] = match[1] === 'true';
});
return {isBot, checks};
}""")
assert results["isBot"] is False, f"Detected as bot! Checks: {results['checks']}"
@pytest.mark.slow
def test_fingerprintjs(self, page):
"""FingerprintJS — must not be blocked, should see flight data."""
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
time.sleep(8)
try:
page.click("button:has-text('Search')", timeout=5000)
time.sleep(5)
except Exception:
pass
results = page.evaluate("""() => {
const text = document.body.innerText;
const hasFlights = text.includes('Price per adult') || text.includes('$');
const isBlocked = text.includes('request was blocked') || text.includes('bot visit detected');
return {passed: hasFlights && !isBlocked, isBlocked, hasFlights};
}""")
assert not results["isBlocked"], "FingerprintJS blocked us as a bot"
assert results["passed"], "FingerprintJS: no flight data shown"
@pytest.mark.slow
def test_recaptcha_v3(self, page):
"""reCAPTCHA v3 — score must be >= 0.7."""
page.goto(
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
wait_until="domcontentloaded",
timeout=60000,
)
time.sleep(8)
results = page.evaluate("""() => {
const text = document.body.innerText;
const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/);
return {score: scoreMatch ? parseFloat(scoreMatch[1]) : null};
}""")
score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}"
+201 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -12,12 +13,17 @@ from cloakbrowser.config import (
CHROMIUM_VERSION, CHROMIUM_VERSION,
_version_newer, _version_newer,
_version_tuple, _version_tuple,
get_chromium_version,
get_download_url, get_download_url,
get_effective_version, get_effective_version,
get_platform_tag,
) )
from cloakbrowser.download import ( from cloakbrowser.download import (
_check_wrapper_update,
_get_latest_chromium_version, _get_latest_chromium_version,
_parse_checksums,
_should_check_for_update, _should_check_for_update,
_verify_checksum,
) )
@@ -41,12 +47,27 @@ class TestVersionComparison:
def test_major_bump(self): def test_major_bump(self):
assert _version_newer("143.0.0.0", "142.9.9999.999") is True assert _version_newer("143.0.0.0", "142.9.9999.999") is True
def test_5th_segment_parsing(self):
assert _version_tuple("145.0.7632.109.2") == (145, 0, 7632, 109, 2)
def test_build_bump(self):
assert _version_newer("145.0.7632.109.3", "145.0.7632.109.2") is True
def test_build_suffix_newer_than_no_suffix(self):
assert _version_newer("145.0.7632.109.2", "145.0.7632.109") is True
def test_no_suffix_older_than_build_suffix(self):
assert _version_newer("145.0.7632.109", "145.0.7632.109.2") is False
def test_new_chromium_beats_old_build(self):
assert _version_newer("146.0.0.0", "145.0.7632.109.2") is True
class TestDownloadUrl: class TestDownloadUrl:
def test_default_url_format(self): def test_default_url_format(self):
url = get_download_url() url = get_download_url()
assert "cloakbrowser.dev" in url assert "cloakbrowser.dev" in url
assert f"chromium-v{CHROMIUM_VERSION}" in url assert f"chromium-v{get_chromium_version()}" in url
assert url.endswith(".tar.gz") assert url.endswith(".tar.gz")
def test_custom_version_url(self): def test_custom_version_url(self):
@@ -111,30 +132,42 @@ class TestShouldCheckForUpdate:
class TestEffectiveVersion: class TestEffectiveVersion:
def test_no_marker_returns_hardcoded(self, tmp_path): def test_no_marker_returns_platform_version(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
assert get_effective_version() == CHROMIUM_VERSION assert get_effective_version() == get_chromium_version()
def test_marker_with_newer_version(self, tmp_path): def test_marker_with_newer_version(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version" marker = tmp_path / f"latest_version_{get_platform_tag()}"
marker.write_text("999.0.0.0") marker.write_text("999.0.0.0")
# Binary doesn't exist, so should fall back # Binary doesn't exist, so should fall back
assert get_effective_version() == CHROMIUM_VERSION assert get_effective_version() == get_chromium_version()
def test_marker_with_older_version_ignored(self, tmp_path): def test_marker_with_older_version_ignored(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
marker = tmp_path / "latest_version" marker = tmp_path / f"latest_version_{get_platform_tag()}"
marker.write_text("100.0.0.0") marker.write_text("100.0.0.0")
assert get_effective_version() == CHROMIUM_VERSION assert get_effective_version() == get_chromium_version()
class TestGetLatestVersion: class TestGetLatestVersion:
def test_parses_chromium_tag(self): """Tests for _get_latest_chromium_version with platform-aware asset checking."""
def _make_assets(self, platforms: list[str]) -> list[dict]:
"""Helper to build asset list from platform tags."""
return [{"name": f"cloakbrowser-{p}.tar.gz"} for p in platforms]
def _platform_tarball(self) -> str:
return f"cloakbrowser-{get_platform_tag()}.tar.gz"
def test_parses_chromium_tag_with_platform_asset(self):
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [ mock_response.json.return_value = [
{"tag_name": "chromium-v145.0.7718.0", "draft": False}, {
{"tag_name": "chromium-v142.0.7444.175", "draft": False}, "tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
] ]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@@ -142,11 +175,37 @@ class TestGetLatestVersion:
result = _get_latest_chromium_version() result = _get_latest_chromium_version()
assert result == "145.0.7718.0" assert result == "145.0.7718.0"
def test_skips_draft_releases(self): def test_skips_release_without_platform_asset(self):
"""If latest release has no asset for our platform, fall back to older release."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [ mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True}, {
{"tag_name": "chromium-v145.0.7718.0", "draft": False}, "tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": self._make_assets(["linux-x64"]), # Linux only
},
{
"tag_name": "chromium-v142.0.7444.175",
"draft": False,
"assets": self._make_assets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
tag = get_platform_tag()
if tag == "linux-x64":
assert result == "145.0.7718.0"
else:
assert result == "142.0.7444.175"
def test_skips_draft_releases(self):
mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
mock_response.json.return_value = [
{"tag_name": "chromium-v999.0.0.0", "draft": True, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
] ]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@@ -156,9 +215,10 @@ class TestGetLatestVersion:
def test_skips_non_chromium_tags(self): def test_skips_non_chromium_tags(self):
mock_response = MagicMock() mock_response = MagicMock()
all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]
mock_response.json.return_value = [ mock_response.json.return_value = [
{"tag_name": "v0.2.0", "draft": False}, {"tag_name": "v0.2.0", "draft": False, "assets": self._make_assets(all_platforms)},
{"tag_name": "chromium-v145.0.7718.0", "draft": False}, {"tag_name": "chromium-v145.0.7718.0", "draft": False, "assets": self._make_assets(all_platforms)},
] ]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@@ -166,7 +226,133 @@ class TestGetLatestVersion:
result = _get_latest_chromium_version() result = _get_latest_chromium_version()
assert result == "145.0.7718.0" assert result == "145.0.7718.0"
def test_returns_none_when_no_platform_assets(self):
"""If no release has our platform, return None."""
mock_response = MagicMock()
mock_response.json.return_value = [
{
"tag_name": "chromium-v145.0.7718.0",
"draft": False,
"assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}],
},
]
mock_response.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_response):
result = _get_latest_chromium_version()
assert result is None
def test_network_error_returns_none(self): def test_network_error_returns_none(self):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")): with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
result = _get_latest_chromium_version() result = _get_latest_chromium_version()
assert result is None assert result is None
class TestWrapperUpdateCheck:
"""Tests for _check_wrapper_update (PyPI version check)."""
def setup_method(self):
import cloakbrowser.download as dl
dl._wrapper_update_checked = False
def test_warns_when_newer_version_available(self, caplog):
mock_resp = MagicMock()
mock_resp.json.return_value = {"info": {"version": "99.0.0"}}
mock_resp.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
import logging
with caplog.at_level(logging.WARNING):
_check_wrapper_update()
assert "Update available" in caplog.text
assert "99.0.0" in caplog.text
def test_silent_when_current(self, caplog):
import cloakbrowser.download as dl
mock_resp = MagicMock()
mock_resp.json.return_value = {"info": {"version": dl._wrapper_version}}
mock_resp.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp):
import logging
with caplog.at_level(logging.WARNING):
_check_wrapper_update()
assert "Update available" not in caplog.text
def test_disabled_by_auto_update_env(self):
with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}):
with patch("cloakbrowser.download.httpx.get") as mock_get:
_check_wrapper_update()
mock_get.assert_not_called()
def test_disabled_by_custom_download_url(self):
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://mirror.example.com"}):
with patch("cloakbrowser.download.httpx.get") as mock_get:
_check_wrapper_update()
mock_get.assert_not_called()
def test_network_error_silent(self, caplog):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")):
import logging
with caplog.at_level(logging.WARNING):
_check_wrapper_update()
assert "Update available" not in caplog.text
def test_runs_only_once(self):
mock_resp = MagicMock()
mock_resp.json.return_value = {"info": {"version": "0.0.1"}}
mock_resp.raise_for_status = MagicMock()
with patch("cloakbrowser.download.httpx.get", return_value=mock_resp) as mock_get:
_check_wrapper_update()
_check_wrapper_update()
assert mock_get.call_count == 1
class TestParseChecksums:
HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
def test_standard_format(self):
text = (
f"{self.HASH_A} cloakbrowser-linux-x64.tar.gz\n"
f"{self.HASH_B} cloakbrowser-darwin-arm64.tar.gz\n"
)
result = _parse_checksums(text)
assert result["cloakbrowser-linux-x64.tar.gz"] == self.HASH_A
assert result["cloakbrowser-darwin-arm64.tar.gz"] == self.HASH_B
def test_binary_mode_asterisk(self):
text = f"{self.HASH_A} *cloakbrowser-linux-x64.tar.gz\n"
result = _parse_checksums(text)
assert "cloakbrowser-linux-x64.tar.gz" in result
def test_empty_lines_skipped(self):
text = f"\n\n{self.HASH_A} file.tar.gz\n\n"
result = _parse_checksums(text)
assert len(result) == 1
def test_uppercase_lowered(self):
text = f"{self.HASH_A.upper()} file.tar.gz\n"
result = _parse_checksums(text)
assert result["file.tar.gz"] == self.HASH_A
def test_empty_input(self):
assert _parse_checksums("") == {}
assert _parse_checksums(" \n \n") == {}
class TestVerifyChecksum:
def test_matching_checksum(self, tmp_path):
content = b"test binary content"
file = tmp_path / "test.tar.gz"
file.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
# Should not raise
_verify_checksum(file, expected)
def test_mismatched_checksum(self, tmp_path):
file = tmp_path / "test.tar.gz"
file.write_bytes(b"real content")
with pytest.raises(RuntimeError, match="Checksum verification failed"):
_verify_checksum(file, "0" * 64)