Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub ab8f1108fa chore(deps): bump actions/checkout in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 20:12:34 +00:00
CloakHQ 660b6bf58c feat(security): verify binaries with pinned Ed25519 signature on SHA256SUMS
Replace the same-origin checksum with a detached Ed25519 signature
(SHA256SUMS.sig) verified against a pinned public key before extraction,
closing #308: a compromised download mirror can no longer certify a
tampered binary. The signed manifest also binds the release version,
rejecting a forced downgrade to an older signed build.

Verification is mandatory and non-bypassable on the official download path;
custom CLOAKBROWSER_DOWNLOAD_URL mirrors keep the legacy skippable checksum.
Silent auto-update is preserved for everyone because only a constant public
key is pinned, not per-version hashes. Older installed wrappers are
unaffected — the version= line is ignored by their checksum parser.

Python uses cryptography; JS uses node:crypto. Adds tamper, downgrade, and
fail-closed tests in both languages.
2026-06-21 02:42:18 +02:00
CloakHQ 50bf14b3f9 fix(wrapper): track real window geometry on headed launches
Headed launches applied a fixed emulated viewport on top of the real
browser window, yielding outerWidth < innerWidth (an impossible window).
Default headed new_page()/new_context() to no_viewport so the page tracks
the real window; headless keeps a deterministic viewport. Covers Python
launch/launch_context/launch_persistent_context (+async) and the JS
Playwright/Puppeteer wrappers. Explicit viewport still honored.
2026-06-20 22:53:53 +02:00
CloakHQ d67c21abbe refactor: remove optional patchright backend
Patchright scored identically to plain Playwright on reCAPTCHA v3 (the
binary handles stealth at C++ level) while breaking proxy auth and
add_init_script (#27). Removed the backend param, CLOAKBROWSER_BACKEND
env var, the patchright extra, and the two backend-specific tests.
Stock Playwright is now the only backend.
2026-06-20 21:50:22 +02:00
CloakHQ 776630e08b release: v0.3.32 — Windows extraction security fix, Widevine CDM seeding, cloakserve fixes 2026-06-20 03:17:07 +02:00
CloakHQ 402a884088 fix(download): pass extract paths to PowerShell via env vars
Windows zip extraction interpolated archive/dest paths directly into the
PowerShell -Command string. A single quote in the path (e.g. a Windows
account like C:\Users\O'Brien) closed the string literal early, breaking
extraction and creating a code-injection shape. execFileSync guards the
OS-shell boundary but not the PowerShell interpreter inside.

Pass both paths via env vars ($env:CB_ARCHIVE / $env:CB_DEST) so
PowerShell reads them as data, never as code. No escaping needed.

Python wrapper unaffected (zipfile module + argv).
2026-06-20 02:19:48 +02:00
CloakHQ 39db492b04 fix(examples): wait for reCAPTCHA score to render before screenshot (#374)
networkidle raced the async scoring and the dead button-click never ran.
Wait on the rendered result instead. Verified 4/4 in Docker.
2026-06-15 17:58:50 +02:00
CloakHQ b06499b0c1 test(humanize): deterministic timing for password CDP test
Zero typing_delay so the '!'-uses-CDP assertion no longer races the
5s default timeout under random thinking-pauses + CI load. Test only
checks which chars route through CDP, not timing.
2026-06-09 17:38:09 +02:00
KumarioandGitHub a6b1363244 fix(cloakserve): add idle cleanup for seeded profiles (#352)
* fix(cloakserve): add idle cleanup for seeded profiles

* docs(cloakserve): document idle process cleanup
2026-06-09 17:22:33 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b4a4ad21ab chore(deps): bump the actions group across 1 directory with 2 updates (#358)
Bumps the actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 16:53:44 +02:00
CloakHQ dcf9ba55d6 feat(widevine): auto-seed CDM hint file for persistent contexts (Linux)
Sideloaded Widevine works on the first launch of a persistent context
instead of needing a manual two-launch hint-file workaround. The wrapper
writes Chromium's CDM hint file into the profile before launch when a
WidevineCdm directory is present next to the binary.

- New cloakbrowser/widevine.py and js/src/widevine.ts: resolve a sideloaded
  CDM (CLOAKBROWSER_WIDEVINE_CDM env var, else next to the binary) and seed
  the hint file. Linux only; no-op elsewhere. CLOAKBROWSER_WIDEVINE=0 disables.
- Never bundles/downloads/copies the CDM (proprietary); seeds only when the
  user-provided CDM is already present.
- Wired into launch_persistent_context[_async] and launchPersistentContext.
- README + js/README: Widevine / DRM section, env vars, FPJS tradeoff note.
- Tests: tests/test_widevine.py, js/tests/widevine.test.ts, persistent-context
  integration assertions.
2026-05-29 22:59:59 +02:00
14ec2ebf5f fix: rewrite cloakserve CDP WebSocket URLs (#234)
* fix: rewrite cloakserve CDP WebSocket URLs

* fix: guard against blank forwarded host

---------

Co-authored-by: honor2030 <19909783+honor2030@users.noreply.github.com>
2026-05-26 23:13:10 +02:00
38 changed files with 2361 additions and 242 deletions
+5
View File
@@ -0,0 +1,5 @@
# Never let signing material enter a Docker build context / image.
# The test image (test-infra/Dockerfile.test) uses selective COPY today, but
# this is defense-in-depth against a future `COPY . .`.
test-infra/signing/
*.pem
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -22,7 +22,7 @@ jobs:
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
+6 -6
View File
@@ -24,7 +24,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -42,7 +42,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -62,7 +62,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
@@ -80,7 +80,7 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
@@ -100,12 +100,12 @@ jobs:
attestations: write
packages: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
+1
View File
@@ -73,3 +73,4 @@ captures
.dolt/
*.db
.beads-credential-key
.antigravitycli
+13
View File
@@ -8,6 +8,19 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
## [0.3.32] — 2026-06-20
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding
- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234)
- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352)
- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374)
- **[meta]** Bump GitHub Actions in the actions group (#358)
## [0.3.31] — 2026-05-26
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
+59 -8
View File
@@ -150,7 +150,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
---
## Latest: v0.3.31 (Chromium 146.0.7680.177.5)
## Latest: v0.3.32 (Chromium 146.0.7680.177.5)
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
@@ -254,7 +254,7 @@ The binary includes 58 source-level patches covering canvas, WebGL, audio, fonts
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
Binary downloads are verified with SHA-256 checksums to ensure integrity.
Binary downloads are verified against a pinned Ed25519 signature on the published checksums before extraction, so the download is confirmed authentic (genuinely ours) and not just intact. A compromised mirror cannot serve a tampered or downgraded binary.
## API
@@ -383,6 +383,7 @@ Use this when you need to:
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm))
```python
from cloakbrowser import launch_persistent_context
@@ -419,6 +420,26 @@ ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quo
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### Widevine / DRM
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
```python
from cloakbrowser import launch_persistent_context
# WidevineCdm sideloaded next to the binary -> Widevine works on first launch
ctx = launch_persistent_context("./my-profile", headless=False)
```
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
### CLI
Pre-download the binary or check installation status from the command line:
@@ -600,8 +621,10 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Only applies to a custom `CLOAKBROWSER_DOWNLOAD_URL`: set to `true` to skip its checksum check. Signature verification on the official download path is mandatory and cannot be skipped. |
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
## Fingerprint Management
@@ -839,6 +862,26 @@ print(page.title())
browser.close()
```
If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact:
```bash
curl http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/devtools/browser/<browser-id>
curl 'http://localhost:9222/json/version?fingerprint=11111' | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/fingerprint/11111/devtools/browser/<browser-id>
```
When `cloakserve` runs behind a reverse proxy or TLS terminator, forward the public host/protocol headers so generated WebSocket URLs use the address clients can actually reach:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
```
With those headers, `/json/version` returns public endpoints such as `wss://cdp.example.com/fingerprint/11111/devtools/browser/<browser-id>` instead of an internal container host.
Pass extra flags to the browser:
```bash
@@ -849,6 +892,10 @@ docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
# Reap disconnected per-seed browser processes after 5 minutes
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --idle-timeout=300
```
Stop the server:
@@ -900,7 +947,9 @@ b4 = pw.chromium.connect_over_cdp(
)
```
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible).
By default, per-seed processes stay alive until `cloakserve` exits. If clients create many unique seeds, set `--idle-timeout=SECONDS` or `CLOAKSERVE_IDLE_TIMEOUT=SECONDS` to automatically terminate a seed's Chrome process after its last CDP WebSocket disconnects. `0`, `off`, `false`, `none`, or `disabled` disable idle cleanup. When cleanup runs, the seed's temporary profile directory under `--data-dir` is removed too. Check active processes at `GET /` (returns JSON with PIDs, ports, connection counts, idle timeout, and pending cleanup status).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
@@ -1049,7 +1098,9 @@ const browser = await launch({
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. You can't satisfy both simultaneously — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
---
@@ -1189,7 +1240,6 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
@@ -1238,7 +1288,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@
## Security
All releases are signed for supply chain verification.
The wrapper automatically verifies every binary download against a pinned Ed25519 signature on the published checksums before extraction — a compromised mirror cannot serve a tampered or downgraded binary. Releases are additionally signed for manual supply chain verification:
```bash
# Verify GPG signature (binary release tag)
@@ -1278,6 +1328,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
- [@245678000000](https://github.com/245678000000) — package-lock sync
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, composable JS launch helpers
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, CDP WebSocket URL rewrite, composable JS launch helpers
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
- [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
+97 -2
View File
@@ -181,6 +181,7 @@ class ChromePool:
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
idle_timeout: float = 0.0,
):
self._binary = binary
self._global_args = global_args
@@ -189,12 +190,14 @@ class ChromePool:
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._idle_timeout = idle_timeout
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
self._next_port = BASE_CDP_PORT
# Connection refcounting for status reporting
self._connections: dict[str, int] = {}
self._idle_tasks: dict[str, asyncio.Task] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
@@ -224,6 +227,7 @@ class ChromePool:
def connect(self, seed_key: str) -> None:
"""Increment connection refcount for a seed."""
self._cancel_idle_cleanup(seed_key)
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
def disconnect(self, seed_key: str) -> None:
@@ -231,9 +235,54 @@ class ChromePool:
count = self._connections.get(seed_key, 0) - 1
if count <= 0:
self._connections.pop(seed_key, None)
self._schedule_idle_cleanup(seed_key)
else:
self._connections[seed_key] = count
def _cancel_idle_cleanup(self, seed_key: str) -> None:
task = self._idle_tasks.pop(seed_key, None)
if task is None or task.done():
return
try:
current_task = asyncio.current_task()
except RuntimeError:
current_task = None
if task is not current_task:
task.cancel()
def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None:
if self._idle_tasks.get(seed_key) is task:
self._idle_tasks.pop(seed_key, None)
def _schedule_idle_cleanup(self, seed_key: str) -> None:
if self._idle_timeout <= 0 or seed_key not in self._processes:
return
self._cancel_idle_cleanup(seed_key)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(
self._cleanup_after_idle(seed_key, self._idle_timeout),
name=f"cloakserve-idle-cleanup-{seed_key}",
)
self._idle_tasks[seed_key] = task
task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task))
async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None:
try:
await asyncio.sleep(timeout)
if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes:
return
logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key)
await self._cleanup_process(seed_key)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Idle cleanup failed for seed=%s", seed_key)
async def get_or_launch(
self,
seed: str | None,
@@ -271,6 +320,8 @@ class ChromePool:
if seed_key in self._processes:
proc = self._processes[seed_key]
if proc.process.poll() is None:
if seed_key in self._idle_tasks:
self._schedule_idle_cleanup(seed_key)
if any([extra_args, timezone, locale, proxy, geoip]):
logger.warning(
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
@@ -360,6 +411,7 @@ class ChromePool:
async def _cleanup_process(self, key: str) -> None:
"""Terminate a Chrome process and clean up."""
self._cancel_idle_cleanup(key)
proc = self._processes.pop(key, None)
if not proc:
return
@@ -377,6 +429,13 @@ class ChromePool:
async def shutdown(self) -> None:
"""Terminate all Chrome processes."""
idle_tasks = list(self._idle_tasks.values())
self._idle_tasks.clear()
for task in idle_tasks:
if not task.done():
task.cancel()
if idle_tasks:
await asyncio.gather(*idle_tasks, return_exceptions=True)
for key in list(self._processes.keys()):
await self._cleanup_process(key)
logger.info("All Chrome processes terminated")
@@ -453,9 +512,21 @@ def parse_connection_params(query_string: str) -> dict:
def _ws_scheme(request: web.Request) -> str:
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
proto = proto.split(",", 1)[0].strip().lower()
return "wss" if proto == "https" else "ws"
def _external_host(request: web.Request) -> str:
"""Return the public host to use in rewritten CDP WebSocket URLs."""
fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}"
forwarded_host = request.headers.get("X-Forwarded-Host")
if forwarded_host:
public_host = forwarded_host.split(",", 1)[0].strip()
if public_host:
return public_host
return fallback_host
async def handle_root(request: web.Request) -> web.Response:
"""Health check / process status."""
pool: ChromePool = request.app["pool"]
@@ -467,6 +538,7 @@ async def handle_root(request: web.Request) -> web.Response:
"port": proc.cdp_port,
"seed": proc.seed,
"connections": pool._connections.get(key, 0),
"idle_cleanup_pending": key in pool._idle_tasks,
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
@@ -474,6 +546,7 @@ async def handle_root(request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"active": len(processes),
"idle_timeout": pool._idle_timeout,
"processes": processes,
})
@@ -504,7 +577,7 @@ async def handle_json_version(request: web.Request) -> web.Response:
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
# Rewrite webSocketDebuggerUrl to route through our multiplexer
host = request.headers.get("Host", f"localhost:{request.app['port']}")
host = _external_host(request)
seed_key = params["seed"]
if seed_key:
ws_path = f"fingerprint/{seed_key}/devtools/browser"
@@ -545,7 +618,7 @@ async def handle_json_list(request: web.Request) -> web.Response:
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
host = request.headers.get("Host", f"localhost:{request.app['port']}")
host = _external_host(request)
scheme = _ws_scheme(request)
seed_key = params["seed"]
@@ -675,6 +748,23 @@ def _default_data_dir() -> str:
return str(Path.home() / ".cloakbrowser" / "cloakserve")
def _parse_idle_timeout(value: str) -> float:
value = value.strip()
if value.lower() in {"0", "false", "off", "none", "disabled"}:
return 0.0
timeout = float(value)
if timeout < 0:
raise ValueError("--idle-timeout must be greater than or equal to 0")
return timeout
def _default_idle_timeout() -> float:
value = os.environ.get("CLOAKSERVE_IDLE_TIMEOUT")
if value is None:
return 0.0
return _parse_idle_timeout(value)
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args).
@@ -690,12 +780,14 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"default_seed": None,
"default_locale": None,
"default_timezone": None,
"idle_timeout": _default_idle_timeout(),
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--idle-timeout=",
"--remote-debugging-port=",
"--remote-debugging-address=",
)
@@ -705,6 +797,8 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
config["port"] = int(arg.split("=", 1)[1])
elif arg.startswith("--data-dir="):
config["data_dir"] = arg.split("=", 1)[1]
elif arg.startswith("--idle-timeout="):
config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1])
elif arg == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
@@ -749,6 +843,7 @@ def main() -> None:
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
idle_timeout=config["idle_timeout"],
)
app = web.Application()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.31"
__version__ = "0.3.32"
+124 -87
View File
@@ -22,6 +22,7 @@ from urllib.parse import quote, unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary
from .human.config import HumanConfigOverrides, HumanPreset
from .widevine import seed_widevine_hint
logger = logging.getLogger("cloakbrowser")
@@ -30,6 +31,79 @@ logger = logging.getLogger("cloakbrowser")
_VIEWPORT_UNSET = object()
def _default_no_viewport(browser: Any) -> None:
"""Default ``new_page()``/``new_context()`` to ``no_viewport=True``.
``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()``
would otherwise inherit Playwright's emulated 1280x720 viewport, producing
``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap
the two factory methods so pages track the real OS window instead. ``setdefault``
only: an explicit ``viewport`` or ``no_viewport`` from the caller is never
overridden (Playwright rejects passing both). Applied for headed launches only.
Composes under humanize's ``patch_browser`` (apply this first).
"""
orig_new_context = browser.new_context
orig_new_page = browser.new_page
def _patched_new_context(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return orig_new_context(**kwargs)
def _patched_new_page(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return orig_new_page(**kwargs)
browser.new_context = _patched_new_context
browser.new_page = _patched_new_page
def _default_no_viewport_async(browser: Any) -> None:
"""Async variant of :func:`_default_no_viewport`."""
orig_new_context = browser.new_context
orig_new_page = browser.new_page
async def _patched_new_context(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return await orig_new_context(**kwargs)
async def _patched_new_page(**kwargs: Any) -> Any:
if "viewport" not in kwargs:
kwargs.setdefault("no_viewport", True)
return await orig_new_page(**kwargs)
browser.new_context = _patched_new_context
browser.new_page = _patched_new_page
def _resolve_context_viewport(viewport: Any, headless: bool) -> dict[str, Any]:
"""Return the viewport kwarg for a context.
Headed: no emulated viewport so the page tracks the real window (CDP viewport
emulation forces outerWidth < innerWidth = a physically impossible window =
bot tell). Headless: a fixed ``DEFAULT_VIEWPORT`` stays coherent (outer == inner)
and keeps dimensions deterministic. Explicit ``viewport`` / ``None`` honored.
"""
if viewport is _VIEWPORT_UNSET:
return {"viewport": DEFAULT_VIEWPORT} if headless else {"no_viewport": True}
if viewport is None:
return {"no_viewport": True}
return {"viewport": viewport}
def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None:
"""Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a
named parameter (never in ``**kwargs``), so the only conflict is a caller passing
``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit
``no_viewport`` wins; drop the viewport so Playwright doesn't error.
"""
if "no_viewport" in kwargs and "viewport" in context_kwargs:
logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins")
context_kwargs.pop("viewport", None)
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs:
@@ -40,6 +114,15 @@ def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | Non
return timezone
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
"""Raise a clear error for removed parameters that now fall into **kwargs."""
if "backend" in kwargs:
raise TypeError(
"The 'backend' parameter has been removed — patchright is no longer "
"supported and stock Playwright is the only backend. Remove the argument."
)
class _ProxySettingsRequired(TypedDict):
server: str
@@ -60,7 +143,6 @@ def launch(
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -85,10 +167,6 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -105,7 +183,9 @@ def launch(
>>> print(page.title())
>>> browser.close()
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -114,7 +194,7 @@ def launch(
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -140,6 +220,12 @@ def launch(
browser.close = _close_with_cleanup
# Headed: default new_page()/new_context() to no_viewport so the page tracks the
# real window (avoids the impossible-window tell). Headless keeps Playwright's
# default viewport (coherent there). Apply before humanize so the wraps compose.
if not headless:
_default_no_viewport(browser)
# Human-like behavioral patching
if humanize:
from .human import patch_browser
@@ -158,7 +244,6 @@ async def launch_async( # noqa: C901
timezone: str | None = None,
locale: str | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -176,7 +261,6 @@ async def launch_async( # noqa: C901
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -198,7 +282,9 @@ async def launch_async( # noqa: C901
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -232,6 +318,10 @@ async def launch_async( # noqa: C901
browser.close = _close_with_cleanup
# Headed: default new_page()/new_context() to no_viewport (see launch()).
if not headless:
_default_no_viewport_async(browser)
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_browser_async
@@ -254,7 +344,6 @@ def launch_persistent_context(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -285,7 +374,6 @@ def launch_persistent_context(
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -302,7 +390,9 @@ def launch_persistent_context(
>>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
"""
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.sync_api import sync_playwright
timezone = _resolve_timezone(timezone, kwargs)
@@ -326,15 +416,13 @@ def launch_persistent_context(
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
context_kwargs.update(_resolve_context_viewport(viewport, headless))
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
seed_widevine_hint(user_data_dir, binary_path)
pw = sync_playwright().start()
context = pw.chromium.launch_persistent_context(
@@ -380,7 +468,6 @@ async def launch_persistent_context_async(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -408,7 +495,6 @@ async def launch_persistent_context_async(
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -430,7 +516,9 @@ async def launch_persistent_context_async(
>>>
>>> asyncio.run(main())
"""
async_playwright = _import_async_playwright(_resolve_backend(backend))
_check_removed_kwargs(kwargs)
from playwright.async_api import async_playwright
timezone = _resolve_timezone(timezone, kwargs)
@@ -454,15 +542,13 @@ async def launch_persistent_context_async(
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
context_kwargs.update(_resolve_context_viewport(viewport, headless))
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
seed_widevine_hint(user_data_dir, binary_path)
pw = await async_playwright().start()
context = await pw.chromium.launch_persistent_context(
@@ -507,7 +593,6 @@ def launch_context(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -533,7 +618,6 @@ def launch_context(
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -542,6 +626,8 @@ def launch_context(
Returns:
Playwright BrowserContext object.
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
@@ -555,20 +641,16 @@ def launch_context(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, extension_paths=extension_paths)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
context_kwargs.update(_resolve_context_viewport(viewport, headless))
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
try:
context = browser.new_context(**context_kwargs)
@@ -608,7 +690,6 @@ async def launch_context_async(
timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False,
backend: str | None = None,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
@@ -635,7 +716,6 @@ async def launch_context_async(
timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend — 'playwright' (default) or 'patchright'.
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
@@ -663,6 +743,8 @@ async def launch_context_async(
>>>
>>> asyncio.run(main())
"""
_check_removed_kwargs(kwargs)
timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
@@ -675,20 +757,16 @@ async def launch_context_async(
# so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation.
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=timezone, locale=locale, backend=backend, extension_paths=extension_paths)
timezone=timezone, locale=locale, extension_paths=extension_paths)
context_kwargs: dict[str, Any] = {}
if user_agent:
context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET:
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
context_kwargs.update(_resolve_context_viewport(viewport, headless))
if color_scheme:
context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs)
_drop_conflicting_viewport(context_kwargs, kwargs)
# Catch BaseException (not just Exception) so that asyncio.CancelledError
# triggers browser cleanup — otherwise the underlying Chromium process
@@ -723,47 +801,6 @@ async def launch_context_async(
return context
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
+22 -6
View File
@@ -25,6 +25,19 @@ PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"windows-x64": "146.0.7680.177.5",
}
# ---------------------------------------------------------------------------
# Ed25519 public keys for verifying downloaded binaries.
#
# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
# The wrapper verifies that signature against the keys below before trusting
# any hash in the manifest, so the download origin alone cannot certify a
# tampered binary. Values are base64 of the 32-byte raw public key. Multiple
# entries are accepted to allow key rotation.
# ---------------------------------------------------------------------------
BINARY_SIGNING_PUBKEYS: list[str] = [
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
]
# ---------------------------------------------------------------------------
# Playwright default args to suppress — these leak automation signals.
# --enable-automation: exposes navigator.webdriver = true
@@ -55,16 +68,19 @@ def get_default_stealth_args() -> list[str]:
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + ["--fingerprint-platform=macos"]
# Linux/Windows: Windows fingerprint profile
# Hardware concurrency, device memory, screen, window size, and GPU are
# auto-generated by the binary from the seed (v14+).
# Linux/Windows: Windows fingerprint profile.
# Screen and window size come from the real display, not this flag (verified:
# identical across seeds), so the wrapper must not emulate a viewport on top in
# headed mode — that would break outerWidth >= innerWidth coherence.
return base + ["--fingerprint-platform=windows"]
# ---------------------------------------------------------------------------
# Default viewport — realistic maximized Chrome on 1080p Windows
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
# the page tracks the real window). Headless has no window chrome, so a fixed
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
# ---------------------------------------------------------------------------
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
+163 -18
View File
@@ -23,6 +23,7 @@ import httpx
from ._version import __version__ as _wrapper_version
from .config import (
BINARY_SIGNING_PUBKEYS,
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
@@ -162,9 +163,11 @@ def _download_and_extract(version: str | None = None) -> None:
)
_download_file(fallback_url, tmp_path)
# Verify checksum before extraction
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
_verify_download_checksum(tmp_path, version)
# Verify the download before extraction. On the official path this is a
# mandatory, non-bypassable Ed25519 signature check (see
# _verify_download_checksum); the skip flag only applies to custom
# self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
_verify_download_checksum(tmp_path, version)
_extract_archive(tmp_path, binary_dir, binary_path)
_show_welcome()
@@ -174,22 +177,159 @@ def _download_and_extract(version: str | None = None) -> None:
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
checksums = _fetch_checksums(version)
"""Verify the downloaded archive's integrity and authenticity.
Official path (cloakbrowser.dev / GitHub Releases): fetch SHA256SUMS plus
its detached Ed25519 signature SHA256SUMS.sig, verify the signature against
the pinned public keys FIRST, then verify the archive's SHA-256 against the
now-authenticated manifest. Mandatory and non-bypassable — a same-origin
manifest can no longer certify a tampered binary (#308).
Custom self-hosted path (CLOAKBROWSER_DOWNLOAD_URL set): the pinned keys do
not apply to a third-party server, so fall back to the plain same-origin
SHA256SUMS check, which CLOAKBROWSER_SKIP_CHECKSUM may bypass.
"""
tarball_name = get_archive_name()
if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
# Self-hosted mirror: signature scheme does not apply. Preserve the
# legacy same-origin checksum behavior, skippable as before.
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() == "true":
logger.warning(
"CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
)
return
checksums = _fetch_checksums(version)
if checksums is None:
logger.warning(
"SHA256SUMS not available from custom URL — skipping checksum verification"
)
return
expected = checksums.get(tarball_name)
if expected is None:
logger.warning(
"SHA256SUMS found but no entry for %s — skipping verification", tarball_name
)
return
_verify_checksum(file_path, expected)
return
# Official path: signature is the trust root and is non-bypassable.
manifest = _fetch_signed_manifest(version)
if manifest is None:
raise RuntimeError(
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) "
"for this release — refusing to use an unverified binary. "
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
)
manifest_bytes, sig_bytes = manifest
_verify_signature(manifest_bytes, sig_bytes)
manifest_text = manifest_bytes.decode("utf-8")
# Version binding: the signed manifest must declare the version we asked for.
# The signature proves "we made this manifest", not "this is the version you
# requested" — without this check a mirror could serve a genuinely-signed
# older release in place of the requested one (forced downgrade).
requested = version or get_chromium_version()
declared = _parse_manifest_version(manifest_text)
if declared != requested:
raise RuntimeError(
f"Version mismatch in signed SHA256SUMS: requested {requested}, "
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
)
checksums = _parse_checksums(manifest_text)
expected = checksums.get(tarball_name)
if expected is None:
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
return
raise RuntimeError(
f"Signature-verified SHA256SUMS has no entry for {tarball_name}"
f"cannot confirm binary integrity."
)
_verify_checksum(file_path, expected)
def _parse_manifest_version(text: str) -> str | None:
"""Read the 'version=<v>' line from a signed manifest. None if absent.
The line has no internal whitespace so older wrappers' SHA256SUMS parsers
ignore it (they only accept '<hash> <filename>' lines).
"""
for line in text.splitlines():
line = line.strip()
if line.startswith("version="):
return line[len("version="):].strip()
return None
def _fetch_signed_manifest(version: str | None = None) -> tuple[bytes, bytes] | None:
"""Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or None.
Both files are fetched from the SAME origin so the signature always matches
the exact manifest bytes it certifies. The primary origin is tried first,
then the GitHub Releases mirror. follow_redirects mirrors _fetch_checksums:
cloakbrowser.dev 301-redirects /chromium-v* to GitHub Releases.
"""
v = version or get_chromium_version()
bases = [
f"{DOWNLOAD_BASE_URL}/chromium-v{v}",
f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}",
]
for base in bases:
try:
manifest_resp = httpx.get(
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
)
manifest_resp.raise_for_status()
sig_resp = httpx.get(
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
)
sig_resp.raise_for_status()
return manifest_resp.content, sig_resp.content
except Exception:
continue
return None
def _verify_signature(manifest_bytes: bytes, sig_b64: bytes) -> None:
"""Verify a detached Ed25519 signature over the raw manifest bytes.
sig_b64 is the base64 of the 64-byte raw signature. Tries each pinned key
in BINARY_SIGNING_PUBKEYS; succeeds if any validates. Raises RuntimeError
if the signature is malformed or no pinned key validates it.
"""
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
try:
signature = base64.b64decode(sig_b64.strip(), validate=True)
except Exception as exc:
raise RuntimeError(f"Malformed SHA256SUMS.sig (not valid base64): {exc}")
for pubkey_b64 in BINARY_SIGNING_PUBKEYS:
try:
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64))
except Exception:
# Skip an unparseable pinned key (e.g. the placeholder) rather than
# aborting — another pinned key may still validate.
continue
try:
pub.verify(signature, manifest_bytes)
logger.info("SHA256SUMS signature verified: Ed25519 OK")
return
except Exception:
# InvalidSignature, or a malformed/wrong-length signature that makes
# verify raise something else — either way this key didn't match,
# so try the next pinned key (and ultimately fail closed below).
continue
raise RuntimeError(
"SHA256SUMS signature verification failed — no pinned key validated the "
"manifest. The binary's authenticity could not be confirmed. "
"Report at https://github.com/CloakHQ/cloakbrowser/issues"
)
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
v = version or get_chromium_version()
@@ -211,17 +351,22 @@ def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
def _parse_checksums(text: str) -> dict[str, str]:
"""Parse SHA256SUMS format: 'hash filename' per line."""
"""Parse SHA256SUMS format: '<64-hex sha256> filename' per line.
Only lines whose first token is a 64-character hex digest are accepted
(matches the JS parser); blank lines, the version= line, and any other
junk are ignored.
"""
result = {}
for line in text.strip().splitlines():
line = line.strip()
if not line:
parts = line.strip().split(None, 1)
if len(parts) != 2:
continue
parts = line.split(None, 1)
if len(parts) == 2:
hash_val, filename = parts
filename = filename.lstrip("*")
result[filename] = hash_val.lower()
hash_val, filename = parts
hash_val = hash_val.lower()
if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val):
continue
result[filename.lstrip("*")] = hash_val
return result
+112
View File
@@ -0,0 +1,112 @@
"""Widevine CDM hint-file seeding for persistent contexts.
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
is a proprietary Google binary we can't redistribute). Users sideload it by
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
binary (see issue #96).
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
reads a "hint file" from the user-data-dir, and a later async component-updater
pass that writes that hint file. On a fresh profile the hint file doesn't exist
on the first launch, and Playwright passes ``--disable-component-update``, so the
updater never writes it — Widevine only works after a manual two-launch dance.
This module pre-seeds the hint file before launch so a sideloaded CDM works on
the very first launch. It never bundles, downloads, or copies the CDM itself —
it only writes the hint when a CDM the user provided is already present.
Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows
the CDM can't initialise (DRM host verification), and macOS uses a different CDM
layout, so seeding is a no-op there.
"""
from __future__ import annotations
import json
import logging
import os
import platform
from pathlib import Path
logger = logging.getLogger("cloakbrowser")
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
def _seeding_disabled() -> bool:
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
return val in ("0", "false", "off", "no")
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
"""Locate a sideloaded Widevine CDM directory, or None if absent.
Resolution:
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` — where a user
naturally drops it, and where it ends up for both downloaded and
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
A directory counts only if it contains ``manifest.json`` (so we don't seed a
hint pointing at a bogus path). The returned path is absolute and
symlink-resolved (``Path.resolve()``).
"""
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
# `is not None` (not truthiness): a present-but-empty env var is "set" and
# used exclusively — it resolves to an invalid path and skips seeding.
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
"""Write the Widevine CDM hint file into a persistent profile before launch.
``binary_path`` is the resolved chrome executable; the CDM is looked for next
to it. No-op on non-Linux platforms, when seeding is disabled via
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises —
a failure here must not break the browser launch.
"""
if platform.system() != "Linux":
return
if _seeding_disabled():
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
return
if not user_data_dir:
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
# a persistent hint can't be placed there, and "" would pollute the CWD.
return
# Everything below is best-effort and must never break the browser launch,
# so the whole body (resolution + write) is guarded.
try:
cdm_dir = resolve_widevine_cdm_dir(binary_path)
if cdm_dir is None:
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
logger.warning(
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
"skipping Widevine hint seeding"
)
else:
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
return
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
hint_dir.mkdir(parents=True, exist_ok=True)
hint_file = hint_dir / _HINT_FILENAME
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
try:
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
return # already seeded correctly
except Exception:
logger.warning("Existing Widevine hint unreadable; rewriting")
hint_file.write_text(content, encoding="utf-8")
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
except Exception as e:
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
+12 -11
View File
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
Default Playwright typically scores 0.1-0.3.
"""
import time
import re
from cloakbrowser import launch
@@ -13,19 +13,20 @@ print("Launching stealth browser...", flush=True)
browser = launch(headless=True)
page = browser.new_page()
# Google's official reCAPTCHA v3 demo
# Google's official reCAPTCHA v3 demo — scores automatically on page load.
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
page.wait_for_load_state("networkidle")
# Click to trigger reCAPTCHA scoring
button = page.query_selector("button")
if button:
button.click()
time.sleep(3)
# The score renders only after an async token + backend-verify round-trip,
# which can finish *after* "networkidle". Wait for the actual result text
# instead of a proxy signal, or the screenshot races the scoring.
page.wait_for_function(
"() => document.body.innerText.includes('Received response from our backend')",
timeout=20000,
)
# Extract score from page
content = page.content()
print("Page loaded. Check the score in the response.")
# Extract score from the rendered response
match = re.search(r'"score":\s*([0-9.]+)', page.inner_text("body"))
print(f"reCAPTCHA v3 score: {match.group(1) if match else 'not found'}")
print(f"URL: {page.url}")
# Take screenshot as proof
+12
View File
@@ -202,6 +202,18 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
### Widevine / DRM
The binary supports Widevine, but the CDM is proprietary and can't be redistributed. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launchPersistentContext()` enables Widevine on the **first** launch — the wrapper auto-seeds the CDM hint file into the profile. This plays DRM-protected video (Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support. **Linux only.** A sideloaded CDM is the opt-in (no flag); set `CLOAKBROWSER_WIDEVINE_CDM` for a custom path or `CLOAKBROWSER_WIDEVINE=0` to disable. See the [main README](https://github.com/CloakHQ/CloakBrowser#widevine--drm) for details.
## Migrate From Playwright
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cloakbrowser",
"version": "0.3.31",
"version": "0.3.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloakbrowser",
"version": "0.3.31",
"version": "0.3.32",
"license": "MIT",
"dependencies": {
"tar": "^7.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.31",
"version": "0.3.32",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
+22 -6
View File
@@ -37,6 +37,19 @@ export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"windows-x64": "146.0.7680.177.5",
};
// ---------------------------------------------------------------------------
// Ed25519 public keys for verifying downloaded binaries.
//
// Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
// The wrapper verifies that signature against the keys below before trusting
// any hash in the manifest, so the download origin alone cannot certify a
// tampered binary. Values are base64 of the 32-byte raw public key. Multiple
// entries are accepted to allow key rotation. Keep in parity with config.py.
// ---------------------------------------------------------------------------
export const BINARY_SIGNING_PUBKEYS: string[] = [
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
];
// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------
@@ -200,9 +213,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
// ---------------------------------------------------------------------------
// Default stealth arguments
// ---------------------------------------------------------------------------
// Default viewport — realistic maximized Chrome on 1080p Windows
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
// Default viewport — used for HEADLESS only (headed launches use no viewport so
// the page tracks the real window). Headless has no window chrome, so a fixed
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
export function getDefaultStealthArgs(): string[] {
@@ -219,8 +234,9 @@ export function getDefaultStealthArgs(): string[] {
return [...base, "--fingerprint-platform=macos"];
}
// Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, window size, and GPU are
// auto-generated by the binary from the seed (v14+).
// Linux/Windows: spoof as Windows desktop.
// Screen and window size come from the real display, not this flag (verified:
// identical across seeds), so the wrapper must not emulate a viewport on top in
// headed mode — that would break outerWidth >= innerWidth coherence.
return [...base, "--fingerprint-platform=windows"];
}
+175 -14
View File
@@ -5,7 +5,7 @@
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
@@ -14,6 +14,7 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js";
import {
BINARY_SIGNING_PUBKEYS,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
@@ -195,10 +196,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
await downloadFile(fallbackUrl, tmpPath);
}
// Verify checksum before extraction
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
await verifyDownloadChecksum(tmpPath, version);
}
// Verify the download before extraction. On the official path this is a
// mandatory, non-bypassable Ed25519 signature check (see
// verifyDownloadChecksum); the skip flag only applies to custom
// self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
await verifyDownloadChecksum(tmpPath, version);
await extractArchive(tmpPath, binaryDir, binaryPath);
showWelcome();
@@ -210,22 +212,176 @@ async function downloadAndExtract(version?: string): Promise<void> {
}
}
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
/** @internal Exported for testing only. */
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const tarballName = getArchiveName();
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
// Self-hosted mirror: the pinned signature keys do not apply to a
// third-party server. Preserve the legacy same-origin checksum behavior,
// skippable via CLOAKBROWSER_SKIP_CHECKSUM.
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() === "true") {
console.warn(
"[cloakbrowser] CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
);
return;
}
const checksums = await fetchChecksums(version);
if (!checksums) {
console.warn(
"[cloakbrowser] SHA256SUMS not available from custom URL — skipping checksum verification"
);
return;
}
const expectedCustom = checksums.get(tarballName);
if (!expectedCustom) {
console.warn(
`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`
);
return;
}
await verifyChecksum(filePath, expectedCustom);
return;
}
// Official path: signature is the trust root and is non-bypassable.
const manifest = await fetchSignedManifest(version);
if (!manifest) {
throw new Error(
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) for " +
"this release — refusing to use an unverified binary. " +
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
);
}
const { manifestBytes, sigBytes } = manifest;
verifySignature(manifestBytes, sigBytes);
const manifestText = new TextDecoder().decode(manifestBytes);
// Version binding: the signed manifest must declare the version we asked for.
// The signature proves "we made this manifest", not "this is the version you
// requested" — without this check a mirror could serve a genuinely-signed
// older release in place of the requested one (forced downgrade).
const requested = version || getChromiumVersion();
const declared = parseManifestVersion(manifestText);
if (declared !== requested) {
throw new Error(
`Version mismatch in signed SHA256SUMS: requested ${requested}, ` +
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
);
}
const checksums = parseChecksums(manifestText);
const expected = checksums.get(tarballName);
if (!expected) {
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
return;
throw new Error(
`Signature-verified SHA256SUMS has no entry for ${tarballName}` +
`cannot confirm binary integrity.`
);
}
await verifyChecksum(filePath, expected);
}
/**
* Read the 'version=<v>' line from a signed manifest. null if absent.
* The line has no internal whitespace so older wrappers' SHA256SUMS parsers
* ignore it (they only accept '<hash> <filename>' lines).
* @internal Exported for testing only.
*/
export function parseManifestVersion(text: string): string | null {
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line.startsWith("version=")) {
return line.slice("version=".length).trim();
}
}
return null;
}
/**
* Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or null.
* Both files come from the SAME origin so the signature always matches the
* exact manifest bytes it certifies. Primary origin first, then GitHub mirror.
* @internal Exported for testing only.
*/
export async function fetchSignedManifest(
version?: string
): Promise<{ manifestBytes: Uint8Array; sigBytes: Uint8Array } | null> {
const v = version || getChromiumVersion();
const bases = [
`${DOWNLOAD_BASE_URL}/chromium-v${v}`,
`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}`,
];
for (const base of bases) {
try {
const manifestResp = await fetch(`${base}/SHA256SUMS`, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!manifestResp.ok) continue;
const sigResp = await fetch(`${base}/SHA256SUMS.sig`, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!sigResp.ok) continue;
return {
manifestBytes: new Uint8Array(await manifestResp.arrayBuffer()),
sigBytes: new Uint8Array(await sigResp.arrayBuffer()),
};
} catch {
continue;
}
}
return null;
}
/**
* Verify a detached Ed25519 signature over the raw manifest bytes.
* sigB64Bytes is the (base64-text) content of SHA256SUMS.sig. Tries each pinned
* key; succeeds if any validates. Throws if malformed or no key validates.
* @internal Exported for testing only.
*/
export function verifySignature(manifestBytes: Uint8Array, sigB64Bytes: Uint8Array): void {
// Node's Buffer.from(...,"base64") is lenient — it silently drops invalid
// characters instead of throwing. Validate by canonical round-trip so a
// malformed .sig is reported as such (parity with Python's
// base64.b64decode(validate=True)).
const sigText = new TextDecoder().decode(sigB64Bytes).trim();
const signature = Buffer.from(sigText, "base64");
if (signature.toString("base64") !== sigText) {
throw new Error("Malformed SHA256SUMS.sig (not valid base64)");
}
await verifyChecksum(filePath, expected);
for (const pubkeyB64 of BINARY_SIGNING_PUBKEYS) {
let keyObject;
try {
// Build an Ed25519 public key from raw 32 bytes via JWK import.
const x = Buffer.from(pubkeyB64, "base64").toString("base64url");
keyObject = createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x },
format: "jwk",
});
} catch {
// Skip an unparseable pinned key (e.g. the placeholder); another may validate.
continue;
}
try {
if (cryptoVerify(null, manifestBytes, keyObject, signature)) {
console.log("[cloakbrowser] SHA256SUMS signature verified: Ed25519 OK");
return;
}
} catch {
// A malformed/wrong-length signature can make verify throw rather than
// return false — treat it as a non-match and try the next pinned key
// (parity with Python's try/except around pub.verify), failing closed below.
continue;
}
}
throw new Error(
"SHA256SUMS signature verification failed — no pinned key validated the " +
"manifest. The binary's authenticity could not be confirmed. " +
"Report at https://github.com/CloakHQ/cloakbrowser/issues"
);
}
/** @internal Exported for testing only. */
@@ -425,11 +581,16 @@ async function extractZip(archivePath: string, destDir: string): Promise<void> {
if (process.platform === "win32") {
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
// with recently-closed Node.js file handles. Use ZipFile API directly.
// Pass paths via env vars (not interpolated into the script) so a quote or
// other special char in the path can't break out and be parsed as code.
execFileSync("powershell", [
"-NoProfile", "-Command",
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
], { timeout: 120_000 });
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
], {
timeout: 120_000,
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
});
} else {
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
}
+58 -2
View File
@@ -10,6 +10,7 @@ import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
@@ -51,15 +52,43 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
* Useful when integrating CloakBrowser with an existing Playwright Browser while
* keeping the wrapper's stealth-safe defaults for `newContext()`.
*/
/**
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
* top-level field at the actual chromium.launch() call. Viewport logic must read
* the same effective value — otherwise a headed browser gets a fixed viewport
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
* resolves headless the opposite way).
*/
function effectiveHeadless(options: LaunchOptions): boolean {
return (
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
options.headless ??
true
);
}
export function buildContextOptions(
options: LaunchContextOptions = {}
): BrowserContextOptions {
// Headed: viewport=null (no emulation) so the page tracks the real window and
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
// inner > outer = a physically impossible window = bot tell. Headless has no
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
const headless = effectiveHeadless(options);
const viewport =
options.viewport !== undefined
? options.viewport
: headless
? DEFAULT_VIEWPORT
: null;
return {
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
} as BrowserContextOptions;
}
@@ -126,10 +155,33 @@ export async function humanizeBrowser(
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core");
const browser = await chromium.launch(await buildLaunchOptions(options));
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
// newPage()/newContext() to viewport:null so the page tracks the real window.
// Headless keeps Playwright's default viewport (coherent there).
if (!effectiveHeadless(options)) {
applyDefaultNoViewport(browser);
}
await humanizeBrowser(browser, options);
return browser;
}
/**
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
* emulation) when the caller didn't specify a viewport. setdefault-style: an
* explicit viewport (including null) is always honored. Apply before humanize's
* patchBrowser so the wraps compose.
*/
function applyDefaultNoViewport(browser: Browser): void {
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
}
/**
* Launch stealth browser and return a BrowserContext with common options pre-set.
* Closing the context also closes the browser.
@@ -160,7 +212,9 @@ export async function launchContext(
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
// humanize:false on the inner launch — patchContext below applies humanize
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
let context: BrowserContext;
try {
@@ -227,6 +281,8 @@ export async function launchPersistentContext(
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
seedWidevineHint(options.userDataDir, binaryPath);
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
+24 -1
View File
@@ -6,11 +6,30 @@
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/**
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
* emulated viewport = a physically impossible window = bot tell). Headless has no
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
*/
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
// launchOptions spread, so the top-level field wins at launch — match it here.
if (launchOpts.defaultViewport !== undefined) {
return launchOpts.defaultViewport as { width: number; height: number } | null;
}
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
}
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
@@ -124,6 +143,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);
@@ -155,6 +175,8 @@ export async function launchPersistentContext(
const { binaryPath, args } = await resolveArgs(options);
const proxyAuth = resolveProxy(options, args);
seedWidevineHint(options.userDataDir, binaryPath);
const browser = await puppeteer.default.launch({
...options.launchOptions,
executablePath: binaryPath,
@@ -162,6 +184,7 @@ export async function launchPersistentContext(
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
userDataDir: options.userDataDir,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);
+111
View File
@@ -0,0 +1,111 @@
/**
* Widevine CDM hint-file seeding for persistent contexts.
* Mirrors Python cloakbrowser/widevine.py.
*
* CloakBrowser's binary supports Widevine but ships no CDM (proprietary, can't
* redistribute). Users sideload it by copying a `WidevineCdm/` directory from a
* real Chrome install next to the binary (see issue #96). Chromium reads a
* "hint file" from the user-data-dir at early startup to register the CDM, but
* on a fresh profile it doesn't exist yet, and Playwright disables the component
* updater that would write it. This seeds the hint file before launch so a
* sideloaded CDM works on the first run. It never bundles, downloads, or copies
* the CDM — only writes the hint when a user-provided CDM is already present.
*
* Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
*/
import fs from "node:fs";
import path from "node:path";
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
function isFile(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
/** Absolute, symlink-resolved path (mirrors Python's Path.resolve()). */
function realPath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}
function seedingDisabled(): boolean {
const val = (process.env.CLOAKBROWSER_WIDEVINE ?? "").trim().toLowerCase();
return val === "0" || val === "false" || val === "off" || val === "no";
}
/**
* Locate a sideloaded Widevine CDM directory, or null if absent.
*
* Resolution:
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` — where a user naturally
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
*
* A directory counts only if it contains `manifest.json`. The returned path is
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
* @internal Exported for testing.
*/
export function resolveWidevineCdmDir(binaryPath: string): string | null {
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
// used exclusively — it resolves to an invalid path and skips seeding.
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
}
/**
* Write the Widevine CDM hint file into a persistent profile before launch.
* `binaryPath` is the resolved chrome executable; the CDM is looked for next to
* it. No-op on non-Linux, when disabled via CLOAKBROWSER_WIDEVINE, or when no
* sideloaded CDM is present. Never throws — a failure must not break launch.
*/
export function seedWidevineHint(userDataDir: string, binaryPath: string): void {
if (process.platform !== "linux") return;
if (seedingDisabled()) return;
// Empty userDataDir = Playwright's ephemeral profile (its own temp dir);
// a persistent hint can't be placed there, and "" would pollute the CWD.
if (!userDataDir) return;
// Everything below is best-effort and must never break the browser launch,
// so the whole body (resolution + write) is guarded.
try {
const cdmDir = resolveWidevineCdmDir(binaryPath);
if (cdmDir === null) {
if (process.env.CLOAKBROWSER_WIDEVINE_CDM !== undefined) {
console.warn(
"[cloakbrowser] CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
"skipping Widevine hint seeding",
);
}
return;
}
const hintDir = path.join(userDataDir, "WidevineCdm");
fs.mkdirSync(hintDir, { recursive: true });
const hintFile = path.join(hintDir, HINT_FILENAME);
// cdmDir is already absolute/resolved.
const content = JSON.stringify({ Path: cdmDir });
try {
if (isFile(hintFile) && fs.readFileSync(hintFile, "utf-8") === content) {
return; // already seeded correctly
}
} catch {
console.warn("[cloakbrowser] Existing Widevine hint unreadable; rewriting");
}
fs.writeFileSync(hintFile, content);
} catch (e) {
// Best-effort: never break the launch, but surface the failure.
console.warn("[cloakbrowser] Failed to seed Widevine CDM hint file:", e);
}
}
+26
View File
@@ -84,6 +84,32 @@ describe("composable Playwright launch helpers", () => {
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
});
it("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
const { buildContextOptions } = await import("../src/index.js");
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
// Headless keeps the deterministic default.
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
// Explicit viewport always honored, even headed.
const custom = { width: 800, height: 600 };
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
});
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
const { buildContextOptions } = await import("../src/index.js");
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
// at the actual launch. Viewport must follow it — a raw headless:false (browser
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
// And launchOptions.headless:true forces the deterministic viewport even if the
// top-level field said headed.
expect(
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
).toEqual(DEFAULT_VIEWPORT);
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
+63
View File
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
});
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Headless (default): deterministic viewport.
await launch();
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
const { launch } = await import("../src/puppeteer.js");
const custom = { width: 640, height: 480 };
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(custom);
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: true, launchOptions: { defaultViewport: null } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
// launch — the viewport decision must follow the same (top-level) value.
await launch({ headless: true, launchOptions: { headless: false } });
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(opts.headless).toBe(true);
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
});
it("adds --proxy-server for string proxy", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ proxy: "http://proxy:8080" });
@@ -212,6 +259,22 @@ describe("puppeteer launchPersistentContext", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("headed persistent context uses null defaultViewport (tracks real window)", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
vi.mocked(puppeteerMock.default.launch).mockClear();
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
});
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
+234
View File
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { sign as cryptoSign, createPrivateKey, createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Generate a throwaway signing keypair BEFORE the config mock is hoisted, then
// pin its public key so verifySignature accepts signatures we produce here.
const h = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require("node:crypto");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const otherPub = crypto.generateKeyPairSync("ed25519").publicKey;
const rawB64 = (pk: any) =>
Buffer.from(pk.export({ format: "jwk" }).x, "base64url").toString("base64");
return {
pinnedPubB64: rawB64(publicKey),
otherPubB64: rawB64(otherPub),
privPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
};
});
vi.mock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.pinnedPubB64] };
});
import {
fetchSignedManifest,
parseChecksums,
parseManifestVersion,
verifyDownloadChecksum,
verifySignature,
} from "../src/download.js";
import { getArchiveName, getChromiumVersion } from "../src/config.js";
/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
function sign(manifest: Uint8Array): Uint8Array {
const priv = createPrivateKey(h.privPem);
const sig = cryptoSign(null, manifest, priv); // raw 64-byte Ed25519 signature
return new TextEncoder().encode(sig.toString("base64"));
}
const enc = (s: string) => new TextEncoder().encode(s);
describe("verifySignature", () => {
it("accepts a valid signature", () => {
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
expect(() => verifySignature(manifest, sign(manifest))).not.toThrow();
});
it("rejects a tampered manifest", () => {
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
const sig = sign(manifest);
const tampered = enc("xyz cloakbrowser-linux-x64.tar.gz\n");
expect(() => verifySignature(tampered, sig)).toThrow(/signature verification failed/);
});
it("rejects malformed base64 in the .sig", () => {
expect(() => verifySignature(enc("data\n"), enc("!!!not base64!!!")))
.toThrow(/Malformed/);
});
it("rejects a signature from a non-pinned key", async () => {
// Re-mock config so ONLY the other key is pinned, then the signature
// (made with the real key) must fail.
vi.resetModules();
vi.doMock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64] };
});
const { verifySignature: vs } = await import("../src/download.js");
const manifest = enc("data\n");
expect(() => vs(manifest, sign(manifest))).toThrow(/signature verification failed/);
vi.doUnmock("../src/config.js");
vi.resetModules();
});
it("accepts a signature under the new key during rotation", async () => {
// Pin BOTH keys (old + new) and sign with the real (new) key — must pass.
vi.resetModules();
vi.doMock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64, h.pinnedPubB64] };
});
const { verifySignature: vs } = await import("../src/download.js");
const manifest = enc("rotated\n");
expect(() => vs(manifest, sign(manifest))).not.toThrow();
vi.doUnmock("../src/config.js");
vi.resetModules();
});
});
describe("verifyDownloadChecksum (official path, fail-closed)", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
});
function tmpFile(bytes: Buffer): string {
const p = path.join(os.tmpdir(), `cloak-sig-${process.pid}-${bytes.length}-${bytes[0]}`);
fs.writeFileSync(p, bytes);
return p;
}
/** Mock fetch to serve a signed manifest for the official URLs. */
function mockManifest(manifestBytes: Uint8Array) {
const sig = sign(manifestBytes);
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const body = url.endsWith(".sig") ? sig : manifestBytes;
return { ok: true, arrayBuffer: async () => body.buffer } as Response;
});
}
/** Manifest body with the bound version line prepended (defaults to current). */
const body = (lines: string, version = getChromiumVersion()) =>
enc(`version=${version}\n${lines}`);
it("passes when signature is valid and hash matches", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(body(`${hash} ${getArchiveName()}\n`));
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
});
it("fails when the binary is tampered (hash mismatch)", async () => {
const file = tmpFile(Buffer.from("a malicious binary"));
const goodHash = createHash("sha256").update(Buffer.from("the real binary")).digest("hex");
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Checksum verification failed/);
});
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
// Genuinely signed, but declares an old version we did not request.
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
});
it("fails when the version line is missing (binding required)", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(enc(`${hash} ${getArchiveName()}\n`)); // no version= line
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
});
it("fails closed when no signed manifest can be fetched", async () => {
const file = tmpFile(Buffer.from("x"));
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/signed SHA256SUMS/);
});
it("fails when the signed manifest has no entry for this platform", async () => {
const file = tmpFile(Buffer.from("x"));
const someHash = "0".repeat(64);
mockManifest(body(`${someHash} some-other-file.tar.gz\n`));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/no entry for/);
});
it("custom download URL keeps the legacy skippable path (no signature fetch)", async () => {
const file = tmpFile(Buffer.from("x"));
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://my-mirror.test";
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
const spy = vi.spyOn(globalThis, "fetch");
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
expect(spy).not.toHaveBeenCalled();
});
});
describe("version binding", () => {
it("reads the version= line", () => {
expect(
parseManifestVersion("version=146.0.7680.177.5\nabc file.tar.gz\n")
).toBe("146.0.7680.177.5");
});
it("returns null when absent", () => {
expect(parseManifestVersion("abc file.tar.gz\n")).toBeNull();
});
it("old parseChecksums ignores the version line", () => {
const result = parseChecksums(
`version=146.0.7680.177.5\n${"a".repeat(64)} cloakbrowser-linux-x64.tar.gz\n`
);
expect(result.size).toBe(1);
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
});
});
describe("fetchSignedManifest", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const mockPair = (manifest: string, sig: string, failPrimarySig = false) =>
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const isSig = url.endsWith(".sig");
if (url.includes("cloakbrowser.dev") && isSig && failPrimarySig) {
return { ok: false, status: 404 } as Response;
}
return {
ok: true,
arrayBuffer: async () =>
new TextEncoder().encode(isSig ? sig : manifest).buffer,
} as Response;
});
it("returns manifest + sig from the primary origin", async () => {
mockPair("MANIFEST", "U0lH");
const result = await fetchSignedManifest("1.2.3.4");
expect(new TextDecoder().decode(result!.manifestBytes)).toBe("MANIFEST");
expect(new TextDecoder().decode(result!.sigBytes)).toBe("U0lH");
});
it("falls back to GitHub when the primary .sig is missing", async () => {
const spy = mockPair("MANIFEST", "U0lH", true);
const result = await fetchSignedManifest("1.2.3.4");
expect(result).not.toBeNull();
// primary SHA256SUMS + primary .sig (404) + github SHA256SUMS + github .sig
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it("returns null when everything fails", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
expect(await fetchSignedManifest("1.2.3.4")).toBeNull();
});
});
+1 -1
View File
@@ -418,7 +418,7 @@ describe("humanType mixed text with CDP", () => {
});
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const cfg = resolveConfig("default", { mistype_chance: 0, typing_delay: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// Assert the persistent-context launchers actually invoke seedWidevineHint,
// so accidental removal of the wiring fails CI (parity with the Python
// test_persistent_context_seeds_widevine tests).
vi.mock("../src/widevine.js", () => ({
seedWidevineHint: vi.fn(),
resolveWidevineCdmDir: vi.fn(),
}));
vi.mock("../src/download.js", () => ({
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
}));
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
}));
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
describe("persistent context seeds Widevine (integration)", () => {
beforeEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
});
afterEach(() => {
vi.clearAllMocks();
});
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pw = await import("playwright-core");
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
close: vi.fn(),
pages: () => [],
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pptr = await import("puppeteer-core");
vi.mocked(pptr.default.launch).mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
close: vi.fn(),
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
const tempDirs: string[] = [];
const origPlatform = process.platform;
function tmpDir(prefix: string): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(d);
return d;
}
function makeCdm(dir: string): string {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
return dir;
}
/** A fake chrome binary path inside its own dir. */
function fakeBinary(): string {
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
fs.mkdirSync(bdir, { recursive: true });
return path.join(bdir, "chrome");
}
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
beforeEach(() => {
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveWidevineCdmDir", () => {
it("returns env-var dir when it has manifest.json", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
});
it("returns null when dir lacks manifest.json", () => {
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
fs.mkdirSync(bogus, { recursive: true });
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
});
it("falls back to <binary dir>/WidevineCdm", () => {
const binary = fakeBinary();
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
});
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
it("empty env var is exclusive — no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
});
describe("seedWidevineHint", () => {
it("writes the hint file with the absolute CDM path", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
const hint = path.join(profile, HINT);
expect(fs.existsSync(hint)).toBe(true);
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
it("no-ops when no CDM present", () => {
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
process.env.CLOAKBROWSER_WIDEVINE = "0";
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("is idempotent", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
fs.realpathSync(cdm),
);
});
it("no-ops on non-Linux", () => {
setPlatform("win32");
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("skips empty userDataDir (no CWD pollution)", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
seedWidevineHint("", fakeBinary());
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
});
it("never throws on write failure", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
});
it("rewrites a mismatched existing hint", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
const hint = path.join(profile, HINT);
fs.mkdirSync(path.dirname(hint), { recursive: true });
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
});
+1 -1
View File
@@ -51,11 +51,11 @@ classifiers = [
dependencies = [
"playwright>=1.40",
"httpx>=0.24",
"cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it
]
[project.optional-dependencies]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
-11
View File
@@ -1,11 +0,0 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
-45
View File
@@ -1,45 +0,0 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+251 -1
View File
@@ -3,6 +3,7 @@
import asyncio
import importlib.machinery
import importlib.util
import json
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -24,6 +25,8 @@ parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
_external_host = _mod._external_host
_ws_scheme = _mod._ws_scheme
SAFE_SEED_RE = _mod.SAFE_SEED_RE
RESERVED_SEEDS = _mod.RESERVED_SEEDS
@@ -90,6 +93,7 @@ class TestParseCliArgs:
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert config["idle_timeout"] == 0.0
assert passthrough == []
def test_custom_port(self):
@@ -128,6 +132,31 @@ class TestParseCliArgs:
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
def test_idle_timeout_not_in_passthrough(self):
config, passthrough = parse_cli_args(["--idle-timeout=30", "--no-sandbox"])
assert config["idle_timeout"] == 30.0
assert "--idle-timeout=30" not in passthrough
assert "--no-sandbox" in passthrough
@pytest.mark.parametrize("value", ["0", "off", "false", "none", "disabled"])
def test_idle_timeout_disabled_values(self, value):
config, _ = parse_cli_args([f"--idle-timeout={value}"])
assert config["idle_timeout"] == 0.0
def test_idle_timeout_env_default(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args([])
assert config["idle_timeout"] == 2.5
def test_idle_timeout_cli_overrides_env(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args(["--idle-timeout=9"])
assert config["idle_timeout"] == 9.0
def test_idle_timeout_rejects_negative_values(self):
with pytest.raises(ValueError):
parse_cli_args(["--idle-timeout=-1"])
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@@ -138,6 +167,141 @@ class TestParseCliArgs:
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# External host detection
# ---------------------------------------------------------------------------
class TestExternalHost:
"""Test public host selection for rewritten CDP WebSocket URLs."""
class _Request:
def __init__(self, headers, port=9222, scheme="http", query_string=""):
self.headers = headers
self.app = {"port": port}
self.scheme = scheme
self.query_string = query_string
def test_forwarded_host_overrides_internal_host(self):
request = self._Request({
"Host": "localhost:8080",
"X-Forwarded-Host": "cdp.example.com:443",
})
assert _external_host(request) == "cdp.example.com:443"
def test_forwarded_host_uses_first_value(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "public.example.com, internal:9222",
})
assert _external_host(request) == "public.example.com"
def test_blank_forwarded_host_falls_back_to_host_header(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": " ",
})
assert _external_host(request) == "internal:9222"
def test_falls_back_to_host_header(self):
request = self._Request({"Host": "localhost:9222"})
assert _external_host(request) == "localhost:9222"
def test_falls_back_to_app_port_without_host_header(self):
request = self._Request({}, port=9333)
assert _external_host(request) == "localhost:9333"
def test_forwarded_proto_selects_wss(self):
request = self._Request({"X-Forwarded-Proto": "https"}, scheme="http")
assert _ws_scheme(request) == "wss"
def test_forwarded_proto_uses_first_value(self):
request = self._Request({"X-Forwarded-Proto": "https, http"}, scheme="http")
assert _ws_scheme(request) == "wss"
class TestHandlerURLRewriting:
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
class _Request:
def __init__(self, headers, query_string="fingerprint=seed1", port=9222, scheme="http"):
self.headers = headers
self.query_string = query_string
self.scheme = scheme
self.app = {"port": port, "pool": self._Pool()}
class _Pool:
async def get_or_launch(self, **_kwargs):
return SimpleNamespace(cdp_port=5100)
class _FakeResponse:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
async def json(self):
return self._data
class _FakeSession:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
def get(self, *_args, **_kwargs):
return TestHandlerURLRewriting._FakeResponse(self._data)
def _patch_session(self, monkeypatch, data):
monkeypatch.setattr(
_mod.aiohttp,
"ClientSession",
lambda *_args, **_kwargs: self._FakeSession(data),
)
def test_json_version_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, {
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/browser/browser-guid",
})
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_version(request))
payload = json.loads(response.text)
assert payload["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/browser/browser-guid"
)
def test_json_list_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, [{
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/page/page-guid",
}])
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_list(request))
payload = json.loads(response.text)
assert payload[0]["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/page/page-guid"
)
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
@@ -293,12 +457,21 @@ class TestHandlerURLRewriting:
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self):
def _make_pool(self, idle_timeout: float = 0.0):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
idle_timeout=idle_timeout,
)
def _track_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace()
def _track_live_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace(
process=SimpleNamespace(poll=lambda: None),
)
def test_connect_increments(self):
@@ -335,6 +508,83 @@ class TestConnectionTracking:
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
def test_idle_cleanup_disabled_by_default(self):
async def run():
pool = self._make_pool()
self._track_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
await asyncio.sleep(0)
assert pool._idle_tasks == {}
asyncio.run(run())
def test_disconnect_to_zero_schedules_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.01)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
await asyncio.sleep(0.05)
assert cleaned == ["seed1"]
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_reconnect_cancels_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.03)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
pool.connect("seed1")
await asyncio.sleep(0.06)
assert cleaned == []
assert pool._connections["seed1"] == 1
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_discovery_refreshes_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=1.0)
self._track_live_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
first_task = pool._idle_tasks["seed1"]
await pool.get_or_launch("seed1")
second_task = pool._idle_tasks["seed1"]
assert second_task is not first_task
pool._cancel_idle_cleanup("seed1")
await asyncio.sleep(0)
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
# ---------------------------------------------------------------------------
# Seed validation (CVE fix — path traversal via fingerprint param)
+3 -6
View File
@@ -5,8 +5,8 @@ from cloakbrowser import launch
@patch("cloakbrowser.browser.ensure_binary")
@patch("cloakbrowser.browser._import_sync_playwright")
def test_extension_loading(mock_playwright_import, mock_ensure_binary):
@patch("playwright.sync_api.sync_playwright")
def test_extension_loading(mock_sync_playwright, mock_ensure_binary):
mock_ensure_binary.return_value = "/fake/chrome"
mock_browser = MagicMock()
@@ -14,10 +14,7 @@ def test_extension_loading(mock_playwright_import, mock_ensure_binary):
mock_pw = MagicMock()
mock_pw.chromium.launch.return_value = mock_browser
mock_pw_manager = MagicMock()
mock_pw_manager.return_value.start.return_value = mock_pw
mock_playwright_import.return_value = mock_pw_manager
mock_sync_playwright.return_value.start.return_value = mock_pw
launch(extension_paths=["./ext"])
+24 -1
View File
@@ -1,10 +1,33 @@
"""Basic launch tests for cloakbrowser."""
import pytest
from cloakbrowser import launch, launch_async, binary_info
from cloakbrowser import (
launch,
launch_async,
launch_context,
launch_persistent_context,
binary_info,
)
from cloakbrowser.config import get_chromium_version
@pytest.mark.parametrize("env", [None, "patchright"])
def test_removed_backend_kwarg_raises(env, monkeypatch):
"""The removed `backend` parameter raises a clear TypeError before any
launch side effects, regardless of the (also removed) CLOAKBROWSER_BACKEND
env var. Guards the patchright removal."""
if env is None:
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
else:
monkeypatch.setenv("CLOAKBROWSER_BACKEND", env)
with pytest.raises(TypeError, match="backend"):
launch(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_context(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright")
def test_binary_info():
"""binary_info() returns expected structure."""
info = binary_info()
+76
View File
@@ -33,6 +33,82 @@ def test_default_viewport(mock_launch, _mock_bin):
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_headed_no_viewport(mock_launch, _mock_bin):
"""Headed (headless=False): no emulated viewport — no_viewport=True so the page
tracks the real window (CDP viewport emulation would force outerWidth < innerWidth)."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(headless=False)
ctx_kwargs = browser.new_context.call_args[1]
assert ctx_kwargs.get("no_viewport") is True
assert "viewport" not in ctx_kwargs
def test_default_no_viewport_helper():
"""_default_no_viewport defaults new_page()/new_context() to no_viewport=True,
but never overrides an explicit viewport (Playwright rejects passing both)."""
from cloakbrowser.browser import _default_no_viewport
browser = MagicMock()
orig_new_page = browser.new_page
orig_new_context = browser.new_context
_default_no_viewport(browser)
browser.new_page()
orig_new_page.assert_called_once_with(no_viewport=True)
browser.new_context()
orig_new_context.assert_called_once_with(no_viewport=True)
# Explicit viewport respected — no_viewport NOT injected.
orig_new_page.reset_mock()
browser.new_page(viewport={"width": 800, "height": 600})
orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600})
@pytest.mark.asyncio
async def test_default_no_viewport_helper_async():
"""_default_no_viewport_async mirrors the sync helper for async new_page/new_context."""
from cloakbrowser.browser import _default_no_viewport_async
browser = MagicMock()
browser.new_page = AsyncMock()
browser.new_context = AsyncMock()
orig_new_page = browser.new_page
orig_new_context = browser.new_context
_default_no_viewport_async(browser)
await browser.new_page()
orig_new_page.assert_awaited_once_with(no_viewport=True)
await browser.new_context()
orig_new_context.assert_awaited_once_with(no_viewport=True)
# Explicit viewport respected — no_viewport NOT injected.
orig_new_page.reset_mock()
await browser.new_page(viewport={"width": 800, "height": 600})
orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600})
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin):
"""If a caller forces no_viewport via **kwargs alongside viewport=, only one
reaches Playwright (which rejects both). The explicit kwargs value wins."""
browser, context = _make_mock_browser()
mock_launch.return_value = browser
from cloakbrowser.browser import launch_context
launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True)
ctx_kwargs = browser.new_context.call_args[1]
assert ctx_kwargs.get("no_viewport") is True
assert "viewport" not in ctx_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch")
def test_custom_viewport(mock_launch, _mock_bin):
+45
View File
@@ -55,6 +55,22 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_headed_no_viewport(_mock_geoip, _mock_bin):
"""Headed (headless=False): no_viewport=True instead of DEFAULT_VIEWPORT so the
page tracks the real window (avoids the outerWidth < innerWidth tell)."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", headless=False)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs.get("no_viewport") is True
assert "viewport" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@@ -258,3 +274,32 @@ async def test_persistent_context_async_timezone_id_alias(_mock_bin):
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
@patch("cloakbrowser.browser.seed_widevine_hint")
def test_persistent_context_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
"""Sync persistent launch seeds the Widevine hint with the profile path."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile")
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
@patch("cloakbrowser.browser.seed_widevine_hint")
async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin):
"""Async persistent launch seeds the Widevine hint with the profile path."""
pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile")
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
+2 -8
View File
@@ -259,9 +259,8 @@ class TestIssueRegressions:
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
Uses context-level proxy to avoid launching a separate browser
(event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
@@ -276,11 +275,6 @@ class TestIssueRegressions:
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()
+238 -1
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import base64
import hashlib
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cloakbrowser.config import (
CHROMIUM_VERSION,
@@ -22,10 +24,14 @@ from cloakbrowser.download import (
_check_wrapper_update,
_download_and_extract,
_fetch_checksums,
_fetch_signed_manifest,
_get_latest_chromium_version,
_parse_checksums,
_parse_manifest_version,
_should_check_for_update,
_verify_checksum,
_verify_download_checksum,
_verify_signature,
_write_version_marker,
check_for_update,
clear_cache,
@@ -486,7 +492,6 @@ class TestDownloadFallback:
with patch.dict(os.environ, {
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
"CLOAKBROWSER_DOWNLOAD_URL": "",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
urls_called = []
@@ -497,7 +502,10 @@ class TestDownloadFallback:
# GitHub fallback succeeds
dest.write_bytes(b"fake")
# This test exercises URL fallback, not verification — stub the
# (now signature-based, non-bypassable) verify step.
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
patch("cloakbrowser.download._verify_download_checksum"), \
patch("cloakbrowser.download._extract_archive"), \
patch("cloakbrowser.download._show_welcome"):
_download_and_extract()
@@ -548,3 +556,232 @@ class TestDownloadFallback:
result = _fetch_checksums()
assert result is None
# ---------------------------------------------------------------------------
# Signed-manifest verification (Ed25519). Trust root is the pinned public key,
# not the same-origin SHA256SUMS — this is what closes M1 (#308).
# ---------------------------------------------------------------------------
def _make_key():
priv = Ed25519PrivateKey.generate()
from cryptography.hazmat.primitives import serialization
raw = priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return priv, base64.b64encode(raw).decode()
def _sign(priv, manifest_bytes: bytes) -> bytes:
"""Return SHA256SUMS.sig content (base64 of the raw signature), as served."""
return base64.b64encode(priv.sign(manifest_bytes))
class TestSignatureVerification:
"""_verify_signature: the cryptographic gate over the raw manifest bytes."""
def test_valid_signature_passes(self):
priv, pub_b64 = _make_key()
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
sig = _sign(priv, manifest)
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
_verify_signature(manifest, sig) # no raise
def test_tampered_manifest_fails(self):
priv, pub_b64 = _make_key()
manifest = b"abc cloakbrowser-linux-x64.tar.gz\n"
sig = _sign(priv, manifest)
tampered = manifest.replace(b"abc", b"xyz")
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
with pytest.raises(RuntimeError, match="signature verification failed"):
_verify_signature(tampered, sig)
def test_wrong_key_fails(self):
priv, _ = _make_key()
_, other_pub = _make_key()
manifest = b"data\n"
sig = _sign(priv, manifest)
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [other_pub]):
with pytest.raises(RuntimeError, match="signature verification failed"):
_verify_signature(manifest, sig)
def test_malformed_signature_fails(self):
_, pub_b64 = _make_key()
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]):
with pytest.raises(RuntimeError, match="Malformed"):
_verify_signature(b"data\n", b"!!!not base64!!!")
def test_placeholder_key_is_skipped_not_crashing(self):
"""An unparseable pinned key (placeholder) must not abort — a real key still validates."""
priv, pub_b64 = _make_key()
manifest = b"data\n"
sig = _sign(priv, manifest)
with patch(
"cloakbrowser.download.BINARY_SIGNING_PUBKEYS",
["REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub_b64],
):
_verify_signature(manifest, sig) # no raise
def test_key_rotation_second_key_accepts(self):
"""A manifest signed with the new key validates while the old key stays pinned."""
old_priv, old_pub = _make_key()
new_priv, new_pub = _make_key()
manifest = b"rotated\n"
sig = _sign(new_priv, manifest)
with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [old_pub, new_pub]):
_verify_signature(manifest, sig) # no raise
class TestVerifyDownloadChecksumSigned:
"""_verify_download_checksum on the official path: signature + version + hash, fail-closed."""
def _hash(self, data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _manifest(self, body: str, version: str | None = None) -> bytes:
"""Build a signed-manifest body with the bound version line prepended."""
v = version if version is not None else get_chromium_version()
return f"version={v}\n{body}".encode()
def test_valid_manifest_and_hash_passes(self, tmp_path):
priv, pub_b64 = _make_key()
archive = tmp_path / "binary"
archive.write_bytes(b"the real binary")
tarball = get_download_url().rsplit("/", 1)[-1]
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
sig = _sign(priv, manifest)
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
_verify_download_checksum(archive) # no raise
def test_tampered_binary_fails_hash(self, tmp_path):
priv, pub_b64 = _make_key()
archive = tmp_path / "binary"
archive.write_bytes(b"a malicious binary") # different bytes
tarball = get_download_url().rsplit("/", 1)[-1]
manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n")
sig = _sign(priv, manifest)
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
with pytest.raises(RuntimeError, match="Checksum verification failed"):
_verify_download_checksum(archive)
def test_wrong_version_fails_downgrade(self, tmp_path):
"""A genuinely-signed manifest for a DIFFERENT version is rejected (downgrade)."""
priv, pub_b64 = _make_key()
archive = tmp_path / "binary"
archive.write_bytes(b"the real binary")
tarball = get_download_url().rsplit("/", 1)[-1]
# Manifest declares an old version, but we ask for get_chromium_version().
manifest = self._manifest(
f"{self._hash(b'the real binary')} {tarball}\n", version="1.0.0.0"
)
sig = _sign(priv, manifest)
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
with pytest.raises(RuntimeError, match="Version mismatch"):
_verify_download_checksum(archive)
def test_missing_version_line_fails(self, tmp_path):
"""A signed manifest without a version line is rejected (binding required)."""
priv, pub_b64 = _make_key()
archive = tmp_path / "binary"
archive.write_bytes(b"the real binary")
tarball = get_download_url().rsplit("/", 1)[-1]
manifest = f"{self._hash(b'the real binary')} {tarball}\n".encode() # no version=
sig = _sign(priv, manifest)
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
with pytest.raises(RuntimeError, match="Version mismatch"):
_verify_download_checksum(archive)
def test_missing_signed_manifest_fails_closed(self, tmp_path):
archive = tmp_path / "binary"
archive.write_bytes(b"x")
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=None):
with pytest.raises(RuntimeError, match="signed SHA256SUMS"):
_verify_download_checksum(archive)
def test_manifest_without_entry_fails(self, tmp_path):
priv, pub_b64 = _make_key()
archive = tmp_path / "binary"
archive.write_bytes(b"x")
manifest = self._manifest("deadbeef some-other-file.tar.gz\n") # no entry for our tarball
sig = _sign(priv, manifest)
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), \
patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), \
patch("cloakbrowser.download._fetch_signed_manifest", return_value=(manifest, sig)):
with pytest.raises(RuntimeError, match="no entry for"):
_verify_download_checksum(archive)
def test_custom_url_uses_plain_checksum_and_skip(self, tmp_path):
"""Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path."""
archive = tmp_path / "binary"
archive.write_bytes(b"x")
with patch.dict(os.environ, {
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.test",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
# Signature path must NOT be consulted for a custom mirror.
with patch("cloakbrowser.download._fetch_signed_manifest") as mocked:
_verify_download_checksum(archive) # skip honored, no raise
mocked.assert_not_called()
class TestVersionBinding:
"""The 'version=<v>' line: read by new wrappers, ignored by old parsers."""
def test_parse_manifest_version(self):
manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n"
assert _parse_manifest_version(manifest) == "146.0.7680.177.5"
def test_parse_manifest_version_absent(self):
assert _parse_manifest_version("abc cloakbrowser-linux-x64.tar.gz\n") is None
def test_old_checksum_parser_ignores_version_line(self):
"""Regression: the version line must not pollute the old hash map."""
h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
manifest = f"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n"
result = _parse_checksums(manifest)
assert result == {"cloakbrowser-linux-x64.tar.gz": h}
class TestFetchSignedManifest:
"""_fetch_signed_manifest pairs SHA256SUMS + .sig from the same origin."""
def test_fetches_both_from_primary(self):
def mock_get(url, **kwargs):
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
return resp
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
result = _fetch_signed_manifest("1.2.3.4")
assert result == (b"MANIFEST", b"SIG")
def test_falls_back_to_github_when_primary_missing_sig(self):
def mock_get(url, **kwargs):
resp = MagicMock()
resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST"
if "cloakbrowser.dev" in url and url.endswith(".sig"):
resp.raise_for_status.side_effect = Exception("404")
else:
resp.raise_for_status = MagicMock()
return resp
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
result = _fetch_signed_manifest("1.2.3.4")
assert result == (b"MANIFEST", b"SIG")
def test_returns_none_when_all_fail(self):
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network")):
assert _fetch_signed_manifest("1.2.3.4") is None
+158
View File
@@ -0,0 +1,158 @@
"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py)."""
import json
import pytest
from cloakbrowser import widevine
from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint
_HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
@pytest.fixture(autouse=True)
def _force_linux(monkeypatch):
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
def _make_cdm(dirpath):
"""Create a fake WidevineCdm dir with a manifest.json."""
dirpath.mkdir(parents=True, exist_ok=True)
(dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}')
return dirpath
def _binary(tmp_path):
"""Return a fake chrome binary path inside its own dir."""
bdir = tmp_path / "bin"
bdir.mkdir(parents=True, exist_ok=True)
return bdir / "chrome"
def test_seeds_hint_next_to_binary(tmp_path):
"""CDM in <binary dir>/WidevineCdm -> hint file written with abs Path."""
binary = _binary(tmp_path)
cdm = _make_cdm(binary.parent / "WidevineCdm")
profile = tmp_path / "profile"
seed_widevine_hint(profile, binary)
hint = profile / _HINT
assert hint.is_file()
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
def test_seeds_hint_from_env_var(tmp_path, monkeypatch):
"""CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
def test_no_cdm_no_file(tmp_path):
"""No CDM present -> nothing written, no exception."""
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_kill_switch_disables(tmp_path, monkeypatch):
"""CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists."""
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm")))
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0")
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_idempotent(tmp_path, monkeypatch):
"""Seeding twice leaves the same correct content and doesn't error."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
binary = _binary(tmp_path)
seed_widevine_hint(profile, binary)
seed_widevine_hint(profile, binary)
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
def test_noop_on_non_linux(tmp_path, monkeypatch):
"""On non-Linux, seeding is a no-op even with a CDM present."""
monkeypatch.setattr(widevine.platform, "system", lambda: "Windows")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm")))
profile = tmp_path / "profile"
seed_widevine_hint(profile, _binary(tmp_path))
assert not (profile / _HINT).exists()
def test_resolve_requires_manifest(tmp_path, monkeypatch):
"""A WidevineCdm dir without manifest.json is not treated as a CDM."""
bogus = tmp_path / "custom_cdm"
bogus.mkdir()
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None
def test_env_var_is_exclusive(tmp_path, monkeypatch):
"""An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir."""
binary = _binary(tmp_path)
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
bogus = tmp_path / "bogus"
bogus.mkdir() # set but no manifest.json
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
assert resolve_widevine_cdm_dir(binary) is None
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
binary = _binary(tmp_path)
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
assert resolve_widevine_cdm_dir(binary) is None
def test_empty_user_data_dir_skips(tmp_path, monkeypatch):
"""Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
monkeypatch.chdir(tmp_path)
seed_widevine_hint("", _binary(tmp_path))
assert not (tmp_path / "WidevineCdm").exists()
def test_never_raises_on_write_failure(tmp_path, monkeypatch):
"""A write failure (hint dir path is a file) must not raise — launch must not break."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
profile.mkdir()
# Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
(profile / "WidevineCdm").write_text("not a dir")
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch):
"""A non-UTF8 / mismatched existing hint is overwritten, without raising."""
cdm = _make_cdm(tmp_path / "custom_cdm")
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
profile = tmp_path / "profile"
hint = profile / "WidevineCdm" / _HINT.split("/")[-1]
hint.parent.mkdir(parents=True)
hint.write_bytes(b"\xff\xfe not valid utf-8")
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
# corrupt content replaced with a valid hint pointing at the CDM
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())