mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab8f1108fa | ||
|
|
660b6bf58c | ||
|
|
50bf14b3f9 | ||
|
|
d67c21abbe | ||
|
|
776630e08b | ||
|
|
402a884088 | ||
|
|
39db492b04 | ||
|
|
b06499b0c1 | ||
|
|
a6b1363244 | ||
|
|
b4a4ad21ab | ||
|
|
dcf9ba55d6 | ||
|
|
14ec2ebf5f |
@@ -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
|
||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
python:
|
python:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
javascript:
|
javascript:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
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
|
attestations: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
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/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -73,3 +73,4 @@ captures
|
|||||||
.dolt/
|
.dolt/
|
||||||
*.db
|
*.db
|
||||||
.beads-credential-key
|
.beads-credential-key
|
||||||
|
.antigravitycli
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.3.31] — 2026-05-26
|
||||||
|
|
||||||
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
|
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
|
||||||
|
|||||||
@@ -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
|
- **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
|
- **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.
|
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
|
## API
|
||||||
|
|
||||||
@@ -383,6 +383,7 @@ Use this when you need to:
|
|||||||
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
|
||||||
- **Load Chrome extensions** (extensions only work from a real user data dir)
|
- **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)
|
- **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
|
```python
|
||||||
from cloakbrowser import launch_persistent_context
|
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) |
|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
||||||
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-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
|
### CLI
|
||||||
|
|
||||||
Pre-download the binary or check installation status from the command line:
|
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_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
| `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_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
|
## Fingerprint Management
|
||||||
|
|
||||||
@@ -839,6 +862,26 @@ print(page.title())
|
|||||||
browser.close()
|
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:
|
Pass extra flags to the browser:
|
||||||
|
|
||||||
```bash
|
```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)
|
# Headed mode (renders to Xvfb inside container)
|
||||||
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
|
||||||
cloakserve --headless=false
|
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:
|
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:
|
**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.
|
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:
|
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 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
|
- **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
|
- **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
|
## 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
|
```bash
|
||||||
# Verify GPG signature (binary release tag)
|
# 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)
|
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
|
||||||
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
|
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
|
||||||
- [@245678000000](https://github.com/245678000000) — package-lock sync
|
- [@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
|
- [@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)
|
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
|
||||||
|
|||||||
+97
-2
@@ -181,6 +181,7 @@ class ChromePool:
|
|||||||
default_seed: str | None = None,
|
default_seed: str | None = None,
|
||||||
default_locale: str | None = None,
|
default_locale: str | None = None,
|
||||||
default_timezone: str | None = None,
|
default_timezone: str | None = None,
|
||||||
|
idle_timeout: float = 0.0,
|
||||||
):
|
):
|
||||||
self._binary = binary
|
self._binary = binary
|
||||||
self._global_args = global_args
|
self._global_args = global_args
|
||||||
@@ -189,12 +190,14 @@ class ChromePool:
|
|||||||
self._default_seed = default_seed
|
self._default_seed = default_seed
|
||||||
self._default_locale = default_locale
|
self._default_locale = default_locale
|
||||||
self._default_timezone = default_timezone
|
self._default_timezone = default_timezone
|
||||||
|
self._idle_timeout = idle_timeout
|
||||||
self._processes: dict[str, ChromeProcess] = {}
|
self._processes: dict[str, ChromeProcess] = {}
|
||||||
self._default: ChromeProcess | None = None
|
self._default: ChromeProcess | None = None
|
||||||
self._locks: dict[str, asyncio.Lock] = {}
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
self._next_port = BASE_CDP_PORT
|
self._next_port = BASE_CDP_PORT
|
||||||
# Connection refcounting for status reporting
|
# Connection refcounting for status reporting
|
||||||
self._connections: dict[str, int] = {}
|
self._connections: dict[str, int] = {}
|
||||||
|
self._idle_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
def _get_lock(self, seed: str) -> asyncio.Lock:
|
def _get_lock(self, seed: str) -> asyncio.Lock:
|
||||||
if seed not in self._locks:
|
if seed not in self._locks:
|
||||||
@@ -224,6 +227,7 @@ class ChromePool:
|
|||||||
|
|
||||||
def connect(self, seed_key: str) -> None:
|
def connect(self, seed_key: str) -> None:
|
||||||
"""Increment connection refcount for a seed."""
|
"""Increment connection refcount for a seed."""
|
||||||
|
self._cancel_idle_cleanup(seed_key)
|
||||||
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
||||||
|
|
||||||
def disconnect(self, seed_key: str) -> None:
|
def disconnect(self, seed_key: str) -> None:
|
||||||
@@ -231,9 +235,54 @@ class ChromePool:
|
|||||||
count = self._connections.get(seed_key, 0) - 1
|
count = self._connections.get(seed_key, 0) - 1
|
||||||
if count <= 0:
|
if count <= 0:
|
||||||
self._connections.pop(seed_key, None)
|
self._connections.pop(seed_key, None)
|
||||||
|
self._schedule_idle_cleanup(seed_key)
|
||||||
else:
|
else:
|
||||||
self._connections[seed_key] = count
|
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(
|
async def get_or_launch(
|
||||||
self,
|
self,
|
||||||
seed: str | None,
|
seed: str | None,
|
||||||
@@ -271,6 +320,8 @@ class ChromePool:
|
|||||||
if seed_key in self._processes:
|
if seed_key in self._processes:
|
||||||
proc = self._processes[seed_key]
|
proc = self._processes[seed_key]
|
||||||
if proc.process.poll() is None:
|
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]):
|
if any([extra_args, timezone, locale, proxy, geoip]):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
"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:
|
async def _cleanup_process(self, key: str) -> None:
|
||||||
"""Terminate a Chrome process and clean up."""
|
"""Terminate a Chrome process and clean up."""
|
||||||
|
self._cancel_idle_cleanup(key)
|
||||||
proc = self._processes.pop(key, None)
|
proc = self._processes.pop(key, None)
|
||||||
if not proc:
|
if not proc:
|
||||||
return
|
return
|
||||||
@@ -377,6 +429,13 @@ class ChromePool:
|
|||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Terminate all Chrome processes."""
|
"""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()):
|
for key in list(self._processes.keys()):
|
||||||
await self._cleanup_process(key)
|
await self._cleanup_process(key)
|
||||||
logger.info("All Chrome processes terminated")
|
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:
|
def _ws_scheme(request: web.Request) -> str:
|
||||||
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
||||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||||
|
proto = proto.split(",", 1)[0].strip().lower()
|
||||||
return "wss" if proto == "https" else "ws"
|
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:
|
async def handle_root(request: web.Request) -> web.Response:
|
||||||
"""Health check / process status."""
|
"""Health check / process status."""
|
||||||
pool: ChromePool = request.app["pool"]
|
pool: ChromePool = request.app["pool"]
|
||||||
@@ -467,6 +538,7 @@ async def handle_root(request: web.Request) -> web.Response:
|
|||||||
"port": proc.cdp_port,
|
"port": proc.cdp_port,
|
||||||
"seed": proc.seed,
|
"seed": proc.seed,
|
||||||
"connections": pool._connections.get(key, 0),
|
"connections": pool._connections.get(key, 0),
|
||||||
|
"idle_cleanup_pending": key in pool._idle_tasks,
|
||||||
"timezone": proc.timezone,
|
"timezone": proc.timezone,
|
||||||
"locale": proc.locale,
|
"locale": proc.locale,
|
||||||
"proxy": proc.proxy,
|
"proxy": proc.proxy,
|
||||||
@@ -474,6 +546,7 @@ async def handle_root(request: web.Request) -> web.Response:
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"active": len(processes),
|
"active": len(processes),
|
||||||
|
"idle_timeout": pool._idle_timeout,
|
||||||
"processes": processes,
|
"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)
|
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||||
|
|
||||||
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
# 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"]
|
seed_key = params["seed"]
|
||||||
if seed_key:
|
if seed_key:
|
||||||
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
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)
|
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
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)
|
scheme = _ws_scheme(request)
|
||||||
seed_key = params["seed"]
|
seed_key = params["seed"]
|
||||||
|
|
||||||
@@ -675,6 +748,23 @@ def _default_data_dir() -> str:
|
|||||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
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]]:
|
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
||||||
"""Parse cloakserve-specific args, return (config, passthrough_args).
|
"""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_seed": None,
|
||||||
"default_locale": None,
|
"default_locale": None,
|
||||||
"default_timezone": None,
|
"default_timezone": None,
|
||||||
|
"idle_timeout": _default_idle_timeout(),
|
||||||
}
|
}
|
||||||
passthrough = []
|
passthrough = []
|
||||||
# Flags consumed by cloakserve (not passed to Chrome)
|
# Flags consumed by cloakserve (not passed to Chrome)
|
||||||
consumed_prefixes = (
|
consumed_prefixes = (
|
||||||
"--port=",
|
"--port=",
|
||||||
"--data-dir=",
|
"--data-dir=",
|
||||||
|
"--idle-timeout=",
|
||||||
"--remote-debugging-port=",
|
"--remote-debugging-port=",
|
||||||
"--remote-debugging-address=",
|
"--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])
|
config["port"] = int(arg.split("=", 1)[1])
|
||||||
elif arg.startswith("--data-dir="):
|
elif arg.startswith("--data-dir="):
|
||||||
config["data_dir"] = arg.split("=", 1)[1]
|
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":
|
elif arg == "--headless=false" or arg == "--headless=False":
|
||||||
config["headless"] = False
|
config["headless"] = False
|
||||||
passthrough.append(arg)
|
passthrough.append(arg)
|
||||||
@@ -749,6 +843,7 @@ def main() -> None:
|
|||||||
default_seed=config["default_seed"],
|
default_seed=config["default_seed"],
|
||||||
default_locale=config["default_locale"],
|
default_locale=config["default_locale"],
|
||||||
default_timezone=config["default_timezone"],
|
default_timezone=config["default_timezone"],
|
||||||
|
idle_timeout=config["idle_timeout"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.31"
|
__version__ = "0.3.32"
|
||||||
|
|||||||
+123
-86
@@ -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 .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||||
from .download import ensure_binary
|
from .download import ensure_binary
|
||||||
from .human.config import HumanConfigOverrides, HumanPreset
|
from .human.config import HumanConfigOverrides, HumanPreset
|
||||||
|
from .widevine import seed_widevine_hint
|
||||||
|
|
||||||
logger = logging.getLogger("cloakbrowser")
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
@@ -30,6 +31,79 @@ logger = logging.getLogger("cloakbrowser")
|
|||||||
_VIEWPORT_UNSET = object()
|
_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:
|
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||||
"""Accept both timezone and timezone_id — either works, no warning."""
|
"""Accept both timezone and timezone_id — either works, no warning."""
|
||||||
if "timezone_id" in kwargs:
|
if "timezone_id" in kwargs:
|
||||||
@@ -40,6 +114,15 @@ def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | Non
|
|||||||
return timezone
|
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):
|
class _ProxySettingsRequired(TypedDict):
|
||||||
server: str
|
server: str
|
||||||
|
|
||||||
@@ -60,7 +143,6 @@ def launch(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: HumanConfigOverrides | None = None,
|
||||||
@@ -85,10 +167,6 @@ def launch(
|
|||||||
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
|
||||||
GeoLite2-City database on first use. Explicit timezone/locale
|
GeoLite2-City database on first use. Explicit timezone/locale
|
||||||
always override geoip results.
|
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).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -105,7 +183,9 @@ def launch(
|
|||||||
>>> print(page.title())
|
>>> print(page.title())
|
||||||
>>> browser.close()
|
>>> 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()
|
binary_path = ensure_binary()
|
||||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
@@ -140,6 +220,12 @@ def launch(
|
|||||||
|
|
||||||
browser.close = _close_with_cleanup
|
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
|
# Human-like behavioral patching
|
||||||
if humanize:
|
if humanize:
|
||||||
from .human import patch_browser
|
from .human import patch_browser
|
||||||
@@ -158,7 +244,6 @@ async def launch_async( # noqa: C901
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
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.
|
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.
|
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
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).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -198,7 +282,9 @@ async def launch_async( # noqa: C901
|
|||||||
>>>
|
>>>
|
||||||
>>> asyncio.run(main())
|
>>> 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()
|
binary_path = ensure_binary()
|
||||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
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
|
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)
|
# Human-like behavioral patching (async variant)
|
||||||
if humanize:
|
if humanize:
|
||||||
from .human import patch_browser_async
|
from .human import patch_browser_async
|
||||||
@@ -254,7 +344,6 @@ def launch_persistent_context(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: HumanConfigOverrides | None = None,
|
||||||
@@ -285,7 +374,6 @@ def launch_persistent_context(
|
|||||||
Default: None (uses Chromium default, which is 'light').
|
Default: None (uses Chromium default, which is 'light').
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
Requires ``pip install cloakbrowser[geoip]``.
|
Requires ``pip install cloakbrowser[geoip]``.
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
|
||||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -302,7 +390,9 @@ def launch_persistent_context(
|
|||||||
>>> page.goto("https://protected-site.com")
|
>>> page.goto("https://protected-site.com")
|
||||||
>>> ctx.close() # Profile is saved; re-use path next run to restore state.
|
>>> 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)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
@@ -326,15 +416,13 @@ def launch_persistent_context(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
pw = sync_playwright().start()
|
pw = sync_playwright().start()
|
||||||
context = pw.chromium.launch_persistent_context(
|
context = pw.chromium.launch_persistent_context(
|
||||||
@@ -380,7 +468,6 @@ async def launch_persistent_context_async(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: HumanConfigOverrides | None = None,
|
||||||
@@ -408,7 +495,6 @@ async def launch_persistent_context_async(
|
|||||||
timezone: IANA timezone (e.g. 'America/New_York').
|
timezone: IANA timezone (e.g. 'America/New_York').
|
||||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
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).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -430,7 +516,9 @@ async def launch_persistent_context_async(
|
|||||||
>>>
|
>>>
|
||||||
>>> asyncio.run(main())
|
>>> 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)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
@@ -454,15 +542,13 @@ async def launch_persistent_context_async(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
pw = await async_playwright().start()
|
pw = await async_playwright().start()
|
||||||
context = await pw.chromium.launch_persistent_context(
|
context = await pw.chromium.launch_persistent_context(
|
||||||
@@ -507,7 +593,6 @@ def launch_context(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: HumanConfigOverrides | None = None,
|
||||||
@@ -533,7 +618,6 @@ def launch_context(
|
|||||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
Default: None (uses Chromium default, which is 'light').
|
Default: None (uses Chromium default, which is 'light').
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
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).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -542,6 +626,8 @@ def launch_context(
|
|||||||
Returns:
|
Returns:
|
||||||
Playwright BrowserContext object.
|
Playwright BrowserContext object.
|
||||||
"""
|
"""
|
||||||
|
_check_removed_kwargs(kwargs)
|
||||||
|
|
||||||
timezone = _resolve_timezone(timezone, kwargs)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
# 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.
|
# so it applies to ALL contexts, not just the default one.
|
||||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
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] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = browser.new_context(**context_kwargs)
|
context = browser.new_context(**context_kwargs)
|
||||||
@@ -608,7 +690,6 @@ async def launch_context_async(
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
|
||||||
humanize: bool = False,
|
humanize: bool = False,
|
||||||
human_preset: HumanPreset = "default",
|
human_preset: HumanPreset = "default",
|
||||||
human_config: HumanConfigOverrides | None = None,
|
human_config: HumanConfigOverrides | None = None,
|
||||||
@@ -635,7 +716,6 @@ async def launch_context_async(
|
|||||||
timezone: IANA timezone (e.g. 'America/New_York').
|
timezone: IANA timezone (e.g. 'America/New_York').
|
||||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
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).
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
human_config: Custom humanize config mapping to override preset values.
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
@@ -663,6 +743,8 @@ async def launch_context_async(
|
|||||||
>>>
|
>>>
|
||||||
>>> asyncio.run(main())
|
>>> asyncio.run(main())
|
||||||
"""
|
"""
|
||||||
|
_check_removed_kwargs(kwargs)
|
||||||
|
|
||||||
timezone = _resolve_timezone(timezone, kwargs)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
|
# 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.
|
# so it applies to ALL contexts, not just the default one.
|
||||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
# 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,
|
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] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
||||||
# triggers browser cleanup — otherwise the underlying Chromium process
|
# triggers browser cleanup — otherwise the underlying Chromium process
|
||||||
@@ -723,47 +801,6 @@ async def launch_context_async(
|
|||||||
return context
|
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
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+22
-6
@@ -25,6 +25,19 @@ PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
|||||||
"windows-x64": "146.0.7680.177.5",
|
"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.
|
# Playwright default args to suppress — these leak automation signals.
|
||||||
# --enable-automation: exposes navigator.webdriver = true
|
# --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
|
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||||
return base + ["--fingerprint-platform=macos"]
|
return base + ["--fingerprint-platform=macos"]
|
||||||
|
|
||||||
# Linux/Windows: Windows fingerprint profile
|
# Linux/Windows: Windows fingerprint profile.
|
||||||
# Hardware concurrency, device memory, screen, window size, and GPU are
|
# Screen and window size come from the real display, not this flag (verified:
|
||||||
# auto-generated by the binary from the seed (v14+).
|
# 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"]
|
return base + ["--fingerprint-platform=windows"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
||||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
# the page tracks the real window). Headless has no window chrome, so a fixed
|
||||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
# 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}
|
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||||
|
|
||||||
|
|||||||
+163
-18
@@ -23,6 +23,7 @@ import httpx
|
|||||||
|
|
||||||
from ._version import __version__ as _wrapper_version
|
from ._version import __version__ as _wrapper_version
|
||||||
from .config import (
|
from .config import (
|
||||||
|
BINARY_SIGNING_PUBKEYS,
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
GITHUB_API_URL,
|
GITHUB_API_URL,
|
||||||
@@ -162,9 +163,11 @@ def _download_and_extract(version: str | None = None) -> None:
|
|||||||
)
|
)
|
||||||
_download_file(fallback_url, tmp_path)
|
_download_file(fallback_url, tmp_path)
|
||||||
|
|
||||||
# Verify checksum before extraction
|
# Verify the download before extraction. On the official path this is a
|
||||||
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
|
# mandatory, non-bypassable Ed25519 signature check (see
|
||||||
_verify_download_checksum(tmp_path, version)
|
# _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)
|
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||||
_show_welcome()
|
_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:
|
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."""
|
"""Verify the downloaded archive's integrity and authenticity.
|
||||||
checksums = _fetch_checksums(version)
|
|
||||||
|
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()
|
tarball_name = get_archive_name()
|
||||||
|
|
||||||
if checksums is None:
|
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
# 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
|
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)
|
expected = checksums.get(tarball_name)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
|
raise RuntimeError(
|
||||||
return
|
f"Signature-verified SHA256SUMS has no entry for {tarball_name} — "
|
||||||
|
f"cannot confirm binary integrity."
|
||||||
|
)
|
||||||
_verify_checksum(file_path, expected)
|
_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:
|
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||||
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
||||||
v = version or get_chromium_version()
|
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]:
|
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 = {}
|
result = {}
|
||||||
for line in text.strip().splitlines():
|
for line in text.strip().splitlines():
|
||||||
line = line.strip()
|
parts = line.strip().split(None, 1)
|
||||||
if not line:
|
if len(parts) != 2:
|
||||||
continue
|
continue
|
||||||
parts = line.split(None, 1)
|
hash_val, filename = parts
|
||||||
if len(parts) == 2:
|
hash_val = hash_val.lower()
|
||||||
hash_val, filename = parts
|
if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val):
|
||||||
filename = filename.lstrip("*")
|
continue
|
||||||
result[filename] = hash_val.lower()
|
result[filename.lstrip("*")] = hash_val
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
|
|||||||
Default Playwright typically scores 0.1-0.3.
|
Default Playwright typically scores 0.1-0.3.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import re
|
||||||
|
|
||||||
from cloakbrowser import launch
|
from cloakbrowser import launch
|
||||||
|
|
||||||
@@ -13,19 +13,20 @@ print("Launching stealth browser...", flush=True)
|
|||||||
browser = launch(headless=True)
|
browser = launch(headless=True)
|
||||||
page = browser.new_page()
|
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.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
|
||||||
page.wait_for_load_state("networkidle")
|
|
||||||
|
|
||||||
# Click to trigger reCAPTCHA scoring
|
# The score renders only after an async token + backend-verify round-trip,
|
||||||
button = page.query_selector("button")
|
# which can finish *after* "networkidle". Wait for the actual result text
|
||||||
if button:
|
# instead of a proxy signal, or the screenshot races the scoring.
|
||||||
button.click()
|
page.wait_for_function(
|
||||||
time.sleep(3)
|
"() => document.body.innerText.includes('Received response from our backend')",
|
||||||
|
timeout=20000,
|
||||||
|
)
|
||||||
|
|
||||||
# Extract score from page
|
# Extract score from the rendered response
|
||||||
content = page.content()
|
match = re.search(r'"score":\s*([0-9.]+)', page.inner_text("body"))
|
||||||
print("Page loaded. Check the score in the response.")
|
print(f"reCAPTCHA v3 score: {match.group(1) if match else 'not found'}")
|
||||||
print(f"URL: {page.url}")
|
print(f"URL: {page.url}")
|
||||||
|
|
||||||
# Take screenshot as proof
|
# Take screenshot as proof
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
|||||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
| `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
|
## Migrate From Playwright
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.31",
|
"version": "0.3.32",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.31",
|
"version": "0.3.32",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tar": "^7.0.0"
|
"tar": "^7.0.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"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.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+22
-6
@@ -37,6 +37,19 @@ export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
|||||||
"windows-x64": "146.0.7680.177.5",
|
"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
|
// Platform detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -200,9 +213,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Default stealth arguments
|
// Default stealth arguments
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
// Default viewport — used for HEADLESS only (headed launches use no viewport so
|
||||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
// the page tracks the real window). Headless has no window chrome, so a fixed
|
||||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
// 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 const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||||
|
|
||||||
export function getDefaultStealthArgs(): string[] {
|
export function getDefaultStealthArgs(): string[] {
|
||||||
@@ -219,8 +234,9 @@ export function getDefaultStealthArgs(): string[] {
|
|||||||
return [...base, "--fingerprint-platform=macos"];
|
return [...base, "--fingerprint-platform=macos"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux/Windows: spoof as Windows desktop
|
// Linux/Windows: spoof as Windows desktop.
|
||||||
// Hardware concurrency, device memory, screen, window size, and GPU are
|
// Screen and window size come from the real display, not this flag (verified:
|
||||||
// auto-generated by the binary from the seed (v14+).
|
// 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"];
|
return [...base, "--fingerprint-platform=windows"];
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-14
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
@@ -14,6 +14,7 @@ import { extract as tarExtract } from "tar";
|
|||||||
|
|
||||||
import type { BinaryInfo } from "./types.js";
|
import type { BinaryInfo } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
BINARY_SIGNING_PUBKEYS,
|
||||||
DOWNLOAD_BASE_URL,
|
DOWNLOAD_BASE_URL,
|
||||||
GITHUB_API_URL,
|
GITHUB_API_URL,
|
||||||
GITHUB_DOWNLOAD_BASE_URL,
|
GITHUB_DOWNLOAD_BASE_URL,
|
||||||
@@ -195,10 +196,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
|||||||
await downloadFile(fallbackUrl, tmpPath);
|
await downloadFile(fallbackUrl, tmpPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checksum before extraction
|
// Verify the download before extraction. On the official path this is a
|
||||||
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
|
// mandatory, non-bypassable Ed25519 signature check (see
|
||||||
await verifyDownloadChecksum(tmpPath, version);
|
// verifyDownloadChecksum); the skip flag only applies to custom
|
||||||
}
|
// self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
|
||||||
|
await verifyDownloadChecksum(tmpPath, version);
|
||||||
|
|
||||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||||
showWelcome();
|
showWelcome();
|
||||||
@@ -210,22 +212,176 @@ async function downloadAndExtract(version?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
/** @internal Exported for testing only. */
|
||||||
const checksums = await fetchChecksums(version);
|
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||||
const tarballName = getArchiveName();
|
const tarballName = getArchiveName();
|
||||||
|
|
||||||
if (!checksums) {
|
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
|
||||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
// 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;
|
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);
|
const expected = checksums.get(tarballName);
|
||||||
if (!expected) {
|
if (!expected) {
|
||||||
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
|
throw new Error(
|
||||||
return;
|
`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. */
|
/** @internal Exported for testing only. */
|
||||||
@@ -425,11 +581,16 @@ async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
|||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
// 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", [
|
execFileSync("powershell", [
|
||||||
"-NoProfile", "-Command",
|
"-NoProfile", "-Command",
|
||||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
|
||||||
], { timeout: 120_000 });
|
], {
|
||||||
|
timeout: 120_000,
|
||||||
|
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-2
@@ -10,6 +10,7 @@ import { buildArgs } from "./args.js";
|
|||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { resolveProxyConfig } from "./proxy.js";
|
import { resolveProxyConfig } from "./proxy.js";
|
||||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.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. */
|
/** @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 {
|
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
|
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
||||||
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
* 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(
|
export function buildContextOptions(
|
||||||
options: LaunchContextOptions = {}
|
options: LaunchContextOptions = {}
|
||||||
): BrowserContextOptions {
|
): 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 {
|
return {
|
||||||
// contextOptions first — explicit wrapper fields below override it.
|
// contextOptions first — explicit wrapper fields below override it.
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
...filterStealthCtxOptions(options.contextOptions),
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
} as BrowserContextOptions;
|
} as BrowserContextOptions;
|
||||||
}
|
}
|
||||||
@@ -126,10 +155,33 @@ export async function humanizeBrowser(
|
|||||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
const browser = await chromium.launch(await buildLaunchOptions(options));
|
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);
|
await humanizeBrowser(browser, options);
|
||||||
return browser;
|
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.
|
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
* Closing the context also closes the browser.
|
* Closing the context also closes the browser.
|
||||||
@@ -160,7 +212,9 @@ export async function launchContext(
|
|||||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||||
// so it applies to ALL contexts, not just the default one.
|
// so it applies to ALL contexts, not just the default one.
|
||||||
// locale and timezone are set via binary flags only — no CDP emulation.
|
// 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;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +281,8 @@ export async function launchPersistentContext(
|
|||||||
}
|
}
|
||||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||||
|
|
||||||
|
seedWidevineHint(options.userDataDir, binaryPath);
|
||||||
|
|
||||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||||
|
|||||||
+24
-1
@@ -6,11 +6,30 @@
|
|||||||
|
|
||||||
import type { Browser } from "puppeteer-core";
|
import type { Browser } from "puppeteer-core";
|
||||||
import type { LaunchOptions } from "./types.js";
|
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 { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.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. */
|
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
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,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
|
defaultViewport: resolveDefaultViewport(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
await applyPostLaunch(browser, options, proxyAuth);
|
await applyPostLaunch(browser, options, proxyAuth);
|
||||||
@@ -155,6 +175,8 @@ export async function launchPersistentContext(
|
|||||||
const { binaryPath, args } = await resolveArgs(options);
|
const { binaryPath, args } = await resolveArgs(options);
|
||||||
const proxyAuth = resolveProxy(options, args);
|
const proxyAuth = resolveProxy(options, args);
|
||||||
|
|
||||||
|
seedWidevineHint(options.userDataDir, binaryPath);
|
||||||
|
|
||||||
const browser = await puppeteer.default.launch({
|
const browser = await puppeteer.default.launch({
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
executablePath: binaryPath,
|
executablePath: binaryPath,
|
||||||
@@ -162,6 +184,7 @@ export async function launchPersistentContext(
|
|||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
userDataDir: options.userDataDir,
|
userDataDir: options.userDataDir,
|
||||||
|
defaultViewport: resolveDefaultViewport(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
await applyPostLaunch(browser, options, proxyAuth);
|
await applyPostLaunch(browser, options, proxyAuth);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,32 @@ describe("composable Playwright launch helpers", () => {
|
|||||||
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
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 () => {
|
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
||||||
const freshConfig = await import("../src/config.js");
|
const freshConfig = await import("../src/config.js");
|
||||||
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
|||||||
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
|
|||||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
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 () => {
|
it("adds --proxy-server for string proxy", async () => {
|
||||||
const { launch } = await import("../src/puppeteer.js");
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
await launch({ proxy: "http://proxy:8080" });
|
await launch({ proxy: "http://proxy:8080" });
|
||||||
@@ -212,6 +259,22 @@ describe("puppeteer launchPersistentContext", () => {
|
|||||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
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 () => {
|
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
||||||
const config = await import("../src/config.js");
|
const config = await import("../src/config.js");
|
||||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -418,7 +418,7 @@ describe("humanType mixed text with CDP", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
|
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 { raw } = buildRawKeyboard();
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
const cdpCalls: Array<[string, any]> = [];
|
const cdpCalls: Array<[string, any]> = [];
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Assert the persistent-context launchers actually invoke seedWidevineHint,
|
||||||
|
// so accidental removal of the wiring fails CI (parity with the Python
|
||||||
|
// test_persistent_context_seeds_widevine tests).
|
||||||
|
|
||||||
|
vi.mock("../src/widevine.js", () => ({
|
||||||
|
seedWidevineHint: vi.fn(),
|
||||||
|
resolveWidevineCdmDir: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../src/download.js", () => ({
|
||||||
|
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
|
||||||
|
}));
|
||||||
|
vi.mock("../src/geoip.js", () => ({
|
||||||
|
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||||
|
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||||
|
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
|
||||||
|
}));
|
||||||
|
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
|
||||||
|
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
|
||||||
|
|
||||||
|
describe("persistent context seeds Widevine (integration)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||||
|
const pw = await import("playwright-core");
|
||||||
|
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
|
||||||
|
close: vi.fn(),
|
||||||
|
pages: () => [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||||
|
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||||
|
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||||
|
|
||||||
|
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
|
||||||
|
const pptr = await import("puppeteer-core");
|
||||||
|
vi.mocked(pptr.default.launch).mockResolvedValue({
|
||||||
|
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
|
||||||
|
close: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { seedWidevineHint } = await import("../src/widevine.js");
|
||||||
|
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||||
|
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||||
|
|
||||||
|
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
|
||||||
|
|
||||||
|
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const origPlatform = process.platform;
|
||||||
|
|
||||||
|
function tmpDir(prefix: string): string {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
tempDirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCdm(dir: string): string {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake chrome binary path inside its own dir. */
|
||||||
|
function fakeBinary(): string {
|
||||||
|
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
|
||||||
|
fs.mkdirSync(bdir, { recursive: true });
|
||||||
|
return path.join(bdir, "chrome");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlatform(value: string) {
|
||||||
|
Object.defineProperty(process, "platform", { value, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE;
|
||||||
|
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
|
||||||
|
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveWidevineCdmDir", () => {
|
||||||
|
it("returns env-var dir when it has manifest.json", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when dir lacks manifest.json", () => {
|
||||||
|
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
|
||||||
|
fs.mkdirSync(bogus, { recursive: true });
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||||
|
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to <binary dir>/WidevineCdm", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
|
||||||
|
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
|
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
|
||||||
|
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty env var is exclusive — no fallback to binary dir", () => {
|
||||||
|
const binary = fakeBinary();
|
||||||
|
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
|
||||||
|
expect(resolveWidevineCdmDir(binary)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seedWidevineHint", () => {
|
||||||
|
it("writes the hint file with the absolute CDM path", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
|
||||||
|
const hint = path.join(profile, HINT);
|
||||||
|
expect(fs.existsSync(hint)).toBe(true);
|
||||||
|
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when no CDM present", () => {
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE = "0";
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
|
||||||
|
fs.realpathSync(cdm),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops on non-Linux", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty userDataDir (no CWD pollution)", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
seedWidevineHint("", fakeBinary());
|
||||||
|
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on write failure", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||||
|
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
|
||||||
|
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites a mismatched existing hint", () => {
|
||||||
|
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
|
||||||
|
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
|
||||||
|
const profile = tmpDir("cloak-prof-");
|
||||||
|
const hint = path.join(profile, HINT);
|
||||||
|
fs.mkdirSync(path.dirname(hint), { recursive: true });
|
||||||
|
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
|
||||||
|
seedWidevineHint(profile, fakeBinary());
|
||||||
|
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -51,11 +51,11 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"playwright>=1.40",
|
"playwright>=1.40",
|
||||||
"httpx>=0.24",
|
"httpx>=0.24",
|
||||||
|
"cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||||
patchright = ["patchright>=1.40"]
|
|
||||||
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Shared test fixtures."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_backend_env(monkeypatch):
|
|
||||||
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
|
|
||||||
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""Unit tests for backend resolution (_resolve_backend)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cloakbrowser.browser import _resolve_backend
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_default():
|
|
||||||
"""No param, no env var → 'playwright'."""
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
assert _resolve_backend(None) == "playwright"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_explicit_playwright():
|
|
||||||
assert _resolve_backend("playwright") == "playwright"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_explicit_patchright():
|
|
||||||
assert _resolve_backend("patchright") == "patchright"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_env_var():
|
|
||||||
"""CLOAKBROWSER_BACKEND env var used when no param."""
|
|
||||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
|
||||||
assert _resolve_backend(None) == "patchright"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_param_beats_env():
|
|
||||||
"""Explicit param overrides env var."""
|
|
||||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
|
||||||
assert _resolve_backend("playwright") == "playwright"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_invalid_raises():
|
|
||||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
|
||||||
_resolve_backend("bogus")
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_backend_invalid_env_raises():
|
|
||||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
|
|
||||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
|
||||||
_resolve_backend(None)
|
|
||||||
+251
-1
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib.machinery
|
import importlib.machinery
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -24,6 +25,8 @@ parse_connection_params = _mod.parse_connection_params
|
|||||||
parse_cli_args = _mod.parse_cli_args
|
parse_cli_args = _mod.parse_cli_args
|
||||||
ChromePool = _mod.ChromePool
|
ChromePool = _mod.ChromePool
|
||||||
_default_data_dir = _mod._default_data_dir
|
_default_data_dir = _mod._default_data_dir
|
||||||
|
_external_host = _mod._external_host
|
||||||
|
_ws_scheme = _mod._ws_scheme
|
||||||
SAFE_SEED_RE = _mod.SAFE_SEED_RE
|
SAFE_SEED_RE = _mod.SAFE_SEED_RE
|
||||||
RESERVED_SEEDS = _mod.RESERVED_SEEDS
|
RESERVED_SEEDS = _mod.RESERVED_SEEDS
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ class TestParseCliArgs:
|
|||||||
assert config["port"] == 9222
|
assert config["port"] == 9222
|
||||||
assert config["headless"] is True
|
assert config["headless"] is True
|
||||||
assert config["data_dir"] is not None
|
assert config["data_dir"] is not None
|
||||||
|
assert config["idle_timeout"] == 0.0
|
||||||
assert passthrough == []
|
assert passthrough == []
|
||||||
|
|
||||||
def test_custom_port(self):
|
def test_custom_port(self):
|
||||||
@@ -128,6 +132,31 @@ class TestParseCliArgs:
|
|||||||
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
||||||
assert not any(a.startswith("--data-dir=") for a in passthrough)
|
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)
|
@patch("os.path.exists", return_value=True)
|
||||||
def test_default_data_dir_docker(self, _mock):
|
def test_default_data_dir_docker(self, _mock):
|
||||||
assert _default_data_dir() == "/tmp/cloakserve"
|
assert _default_data_dir() == "/tmp/cloakserve"
|
||||||
@@ -138,6 +167,141 @@ class TestParseCliArgs:
|
|||||||
assert result.endswith(".cloakbrowser/cloakserve")
|
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)
|
# URL rewriting logic (pure string manipulation, extracted from handlers)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -293,12 +457,21 @@ class TestHandlerURLRewriting:
|
|||||||
class TestConnectionTracking:
|
class TestConnectionTracking:
|
||||||
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
||||||
|
|
||||||
def _make_pool(self):
|
def _make_pool(self, idle_timeout: float = 0.0):
|
||||||
return ChromePool(
|
return ChromePool(
|
||||||
binary="/fake/chrome",
|
binary="/fake/chrome",
|
||||||
global_args=[],
|
global_args=[],
|
||||||
headless=True,
|
headless=True,
|
||||||
data_dir="/tmp/test-cloakserve",
|
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):
|
def test_connect_increments(self):
|
||||||
@@ -335,6 +508,83 @@ class TestConnectionTracking:
|
|||||||
assert pool._connections["a"] == 1
|
assert pool._connections["a"] == 1
|
||||||
assert pool._connections["b"] == 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)
|
# Seed validation (CVE fix — path traversal via fingerprint param)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from cloakbrowser import launch
|
|||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary")
|
@patch("cloakbrowser.browser.ensure_binary")
|
||||||
@patch("cloakbrowser.browser._import_sync_playwright")
|
@patch("playwright.sync_api.sync_playwright")
|
||||||
def test_extension_loading(mock_playwright_import, mock_ensure_binary):
|
def test_extension_loading(mock_sync_playwright, mock_ensure_binary):
|
||||||
mock_ensure_binary.return_value = "/fake/chrome"
|
mock_ensure_binary.return_value = "/fake/chrome"
|
||||||
|
|
||||||
mock_browser = MagicMock()
|
mock_browser = MagicMock()
|
||||||
@@ -14,10 +14,7 @@ def test_extension_loading(mock_playwright_import, mock_ensure_binary):
|
|||||||
mock_pw = MagicMock()
|
mock_pw = MagicMock()
|
||||||
mock_pw.chromium.launch.return_value = mock_browser
|
mock_pw.chromium.launch.return_value = mock_browser
|
||||||
|
|
||||||
mock_pw_manager = MagicMock()
|
mock_sync_playwright.return_value.start.return_value = mock_pw
|
||||||
mock_pw_manager.return_value.start.return_value = mock_pw
|
|
||||||
|
|
||||||
mock_playwright_import.return_value = mock_pw_manager
|
|
||||||
|
|
||||||
launch(extension_paths=["./ext"])
|
launch(extension_paths=["./ext"])
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -1,10 +1,33 @@
|
|||||||
"""Basic launch tests for cloakbrowser."""
|
"""Basic launch tests for cloakbrowser."""
|
||||||
|
|
||||||
import pytest
|
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
|
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():
|
def test_binary_info():
|
||||||
"""binary_info() returns expected structure."""
|
"""binary_info() returns expected structure."""
|
||||||
info = binary_info()
|
info = binary_info()
|
||||||
|
|||||||
@@ -33,6 +33,82 @@ def test_default_viewport(mock_launch, _mock_bin):
|
|||||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
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.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.launch")
|
@patch("cloakbrowser.browser.launch")
|
||||||
def test_custom_viewport(mock_launch, _mock_bin):
|
def test_custom_viewport(mock_launch, _mock_bin):
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
|||||||
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
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.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
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]
|
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||||
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
||||||
assert "timezone_id" not in call_kwargs
|
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")
|
||||||
|
|||||||
@@ -259,9 +259,8 @@ class TestIssueRegressions:
|
|||||||
def test_add_init_script_with_proxy(self, browser):
|
def test_add_init_script_with_proxy(self, browser):
|
||||||
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
|
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
|
||||||
|
|
||||||
Patchright bug: add_init_script breaks proxy auth. This test guards
|
Uses context-level proxy to avoid launching a separate browser
|
||||||
against regression if/when the upstream fix lands. Uses context-level
|
(event loop conflict).
|
||||||
proxy to avoid launching a separate browser (event loop conflict).
|
|
||||||
"""
|
"""
|
||||||
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
|
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
|
||||||
if not proxy:
|
if not proxy:
|
||||||
@@ -276,11 +275,6 @@ class TestIssueRegressions:
|
|||||||
val = page.evaluate("window.__cloaktest")
|
val = page.evaluate("window.__cloaktest")
|
||||||
assert val == 99, f"init_script value wrong: {val}"
|
assert val == 99, f"init_script value wrong: {val}"
|
||||||
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
|
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:
|
finally:
|
||||||
page.close()
|
page.close()
|
||||||
ctx.close()
|
ctx.close()
|
||||||
|
|||||||
+238
-1
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
from cloakbrowser.config import (
|
from cloakbrowser.config import (
|
||||||
CHROMIUM_VERSION,
|
CHROMIUM_VERSION,
|
||||||
@@ -22,10 +24,14 @@ from cloakbrowser.download import (
|
|||||||
_check_wrapper_update,
|
_check_wrapper_update,
|
||||||
_download_and_extract,
|
_download_and_extract,
|
||||||
_fetch_checksums,
|
_fetch_checksums,
|
||||||
|
_fetch_signed_manifest,
|
||||||
_get_latest_chromium_version,
|
_get_latest_chromium_version,
|
||||||
_parse_checksums,
|
_parse_checksums,
|
||||||
|
_parse_manifest_version,
|
||||||
_should_check_for_update,
|
_should_check_for_update,
|
||||||
_verify_checksum,
|
_verify_checksum,
|
||||||
|
_verify_download_checksum,
|
||||||
|
_verify_signature,
|
||||||
_write_version_marker,
|
_write_version_marker,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
clear_cache,
|
clear_cache,
|
||||||
@@ -486,7 +492,6 @@ class TestDownloadFallback:
|
|||||||
with patch.dict(os.environ, {
|
with patch.dict(os.environ, {
|
||||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||||
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
||||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
|
||||||
}):
|
}):
|
||||||
urls_called = []
|
urls_called = []
|
||||||
|
|
||||||
@@ -497,7 +502,10 @@ class TestDownloadFallback:
|
|||||||
# GitHub fallback succeeds
|
# GitHub fallback succeeds
|
||||||
dest.write_bytes(b"fake")
|
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), \
|
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._extract_archive"), \
|
||||||
patch("cloakbrowser.download._show_welcome"):
|
patch("cloakbrowser.download._show_welcome"):
|
||||||
_download_and_extract()
|
_download_and_extract()
|
||||||
@@ -548,3 +556,232 @@ class TestDownloadFallback:
|
|||||||
result = _fetch_checksums()
|
result = _fetch_checksums()
|
||||||
|
|
||||||
assert result is None
|
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
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cloakbrowser import widevine
|
||||||
|
from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint
|
||||||
|
|
||||||
|
_HINT = "WidevineCdm/latest-component-updated-widevine-cdm"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _force_linux(monkeypatch):
|
||||||
|
"""Run as if on Linux unless a test overrides it (seeding is Linux-only)."""
|
||||||
|
monkeypatch.setattr(widevine.platform, "system", lambda: "Linux")
|
||||||
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False)
|
||||||
|
monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cdm(dirpath):
|
||||||
|
"""Create a fake WidevineCdm dir with a manifest.json."""
|
||||||
|
dirpath.mkdir(parents=True, exist_ok=True)
|
||||||
|
(dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}')
|
||||||
|
return dirpath
|
||||||
|
|
||||||
|
|
||||||
|
def _binary(tmp_path):
|
||||||
|
"""Return a fake chrome binary path inside its own dir."""
|
||||||
|
bdir = tmp_path / "bin"
|
||||||
|
bdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return bdir / "chrome"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_hint_next_to_binary(tmp_path):
|
||||||
|
"""CDM in <binary dir>/WidevineCdm -> hint file written with abs Path."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
cdm = _make_cdm(binary.parent / "WidevineCdm")
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
|
||||||
|
hint = profile / _HINT
|
||||||
|
assert hint.is_file()
|
||||||
|
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeds_hint_from_env_var(tmp_path, monkeypatch):
|
||||||
|
"""CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
|
||||||
|
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cdm_no_file(tmp_path):
|
||||||
|
"""No CDM present -> nothing written, no exception."""
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_switch_disables(tmp_path, monkeypatch):
|
||||||
|
"""CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists."""
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm")))
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0")
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent(tmp_path, monkeypatch):
|
||||||
|
"""Seeding twice leaves the same correct content and doesn't error."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
seed_widevine_hint(profile, binary)
|
||||||
|
assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_on_non_linux(tmp_path, monkeypatch):
|
||||||
|
"""On non-Linux, seeding is a no-op even with a CDM present."""
|
||||||
|
monkeypatch.setattr(widevine.platform, "system", lambda: "Windows")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm")))
|
||||||
|
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path))
|
||||||
|
assert not (profile / _HINT).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_requires_manifest(tmp_path, monkeypatch):
|
||||||
|
"""A WidevineCdm dir without manifest.json is not treated as a CDM."""
|
||||||
|
bogus = tmp_path / "custom_cdm"
|
||||||
|
bogus.mkdir()
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||||
|
assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||||
|
"""An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||||
|
bogus = tmp_path / "bogus"
|
||||||
|
bogus.mkdir() # set but no manifest.json
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus))
|
||||||
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_env_var_is_exclusive(tmp_path, monkeypatch):
|
||||||
|
"""An empty (but set) CLOAKBROWSER_WIDEVINE_CDM is exclusive — no binary-dir fallback."""
|
||||||
|
binary = _binary(tmp_path)
|
||||||
|
_make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "")
|
||||||
|
monkeypatch.chdir(tmp_path) # so a stray ./manifest.json can't match
|
||||||
|
assert resolve_widevine_cdm_dir(binary) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_user_data_dir_skips(tmp_path, monkeypatch):
|
||||||
|
"""Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
seed_widevine_hint("", _binary(tmp_path))
|
||||||
|
assert not (tmp_path / "WidevineCdm").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_raises_on_write_failure(tmp_path, monkeypatch):
|
||||||
|
"""A write failure (hint dir path is a file) must not raise — launch must not break."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
profile.mkdir()
|
||||||
|
# Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
|
||||||
|
(profile / "WidevineCdm").write_text("not a dir")
|
||||||
|
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch):
|
||||||
|
"""A non-UTF8 / mismatched existing hint is overwritten, without raising."""
|
||||||
|
cdm = _make_cdm(tmp_path / "custom_cdm")
|
||||||
|
monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm))
|
||||||
|
profile = tmp_path / "profile"
|
||||||
|
hint = profile / "WidevineCdm" / _HINT.split("/")[-1]
|
||||||
|
hint.parent.mkdir(parents=True)
|
||||||
|
hint.write_bytes(b"\xff\xfe not valid utf-8")
|
||||||
|
|
||||||
|
seed_widevine_hint(profile, _binary(tmp_path)) # must not raise
|
||||||
|
|
||||||
|
# corrupt content replaced with a valid hint pointing at the CDM
|
||||||
|
assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())
|
||||||
Reference in New Issue
Block a user