Compare commits

...
Author SHA1 Message Date
CloakHQ db9eb4bbf0 chore: prepare 0.4.0 — version bump, Pro tier changelog + README + license v1.1 2026-06-22 03:08:27 +02:00
CloakHQ 10f492e95b feat: add Pro tier license validation and download routing 2026-06-21 04:08:45 +02:00
CloakHQ 660b6bf58c feat(security): verify binaries with pinned Ed25519 signature on SHA256SUMS
Replace the same-origin checksum with a detached Ed25519 signature
(SHA256SUMS.sig) verified against a pinned public key before extraction,
closing #308: a compromised download mirror can no longer certify a
tampered binary. The signed manifest also binds the release version,
rejecting a forced downgrade to an older signed build.

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

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

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

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

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


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

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

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

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

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

* fix: guard against blank forwarded host

---------

Co-authored-by: honor2030 <19909783+honor2030@users.noreply.github.com>
2026-05-26 23:13:10 +02:00
CloakHQ 0caa14bf7b release: v0.3.31 — proxy credential routing, humanize iframe fixes 2026-05-26 20:30:28 +02:00
CloakHQ 2a99081850 docs: add recommended config to quick-start, FPJS troubleshooting, update contributors
- Add production-ready snippet (proxy, geoip, headless, humanize) near top of both READMEs
- Add "Detected by FingerprintJS?" troubleshooting with verified flags:
  noise=false, screen dimensions, storage quota, geoip, residential proxy
- Add @sparanoid to contributors (Docker Xvfb lock fix)
- Update @eofreternal credit (iframe pointer-events fix)
- Fix stale "48 patches" in JS README
2026-05-26 18:46:13 +02:00
CloakHQ 7fc577e5c6 fix(humanize): port #303 iframe pointer-events fix to Python
Mirror the JS fix from #303 in the sync and async Python actionability
checks: compute and apply the iframe coordinate offset before
elementFromPoint, and fail open when the check itself cannot run. Add
fail-open regression tests for both Python and JS.
2026-05-25 01:17:05 +02:00
EternalandGitHub 12d02c3547 fix(humanize): correct iframe coordinate offset in pointer-events check (#303)
The ElementHandle pointer-events check passed page-space click coordinates to elementFromPoint, which runs inside the target element's frame. For elements in an iframe the coordinate spaces differ, so the check looked at the wrong point and wrongly reported the element as covered. Now the iframe offset is computed and applied. Also fails open when the check itself cannot run.

Thanks @eofreternal for the fix.
2026-05-25 01:15:09 +02:00
243c1385a0 feat(js): export buildContextOptions helper (#262)
Co-authored-by: 이민재 <19909783+honor2030@users.noreply.github.com>
2026-05-25 00:15:27 +02:00
CloakHQ 0f3dc7201b chore(deps): bump JS dev dependencies
puppeteer-core 21→25 (fixes CVEs in tar-fs, ws),
typescript 5→6, @types/node 20→25, playwright-core 1.58→1.60.
Vitest stays on v1 (v4 breaks dynamic import mocking).
2026-05-24 23:57:47 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
34d2f78e87 chore(deps): bump the actions group across 1 directory with 3 updates (#309)
Bumps the actions group with 3 updates in the / directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/login-action](https://github.com/docker/login-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 23:40:28 +02:00
CloakHQ 41be4e0e30 chore(ci): add pip and npm ecosystems to Dependabot 2026-05-24 23:17:45 +02:00
CloakHQ 58ccdb683c fix(humanize): use shared deadline for timeout budget in frame and ElementHandle methods (#307)
Frame-level methods (click, dblclick, hover, dragAndDrop) passed the raw
timeout to each sequential operation independently, causing 3x actual
wait time when elements don't exist. ElementHandle methods had a similar
2x issue between actionability and pointer-events checks.

Port the deadline + remainingMs() pattern already used by page-level
methods. Also fix bot detection test selector after site added a hidden
duplicate submit button.
2026-05-24 23:12:07 +02:00
CloakHQ 8028ddefef feat: route HTTP proxy credentials through --proxy-server
Bypass Playwright's CDP Fetch.authRequired interceptor for authenticated
HTTP proxies by passing inline credentials via Chrome's --proxy-server
flag. Chrome sends Proxy-Authorization preemptively, avoiding the 407
round-trip that breaks on some proxies and Google domains (#182).

Gated on platform (linux-x64, windows-x64) and binary version >= 146.0.7680.177.5.
Unsupported platforms fall back to Playwright's proxy dict.
Puppeteer falls back to page.authenticate() on unsupported platforms.
2026-05-21 05:51:03 +02:00
sparanoidandGitHub 864cae2493 fix(docker): clean up stale Xvfb lock so container survives restarts (#284)
On `docker restart`, `/tmp` is preserved across container instances, so the
previous Xvfb's `/tmp/.X99-lock` survives into the new container. The new
Xvfb sees the existing lock and refuses to start, leaving the container
with no X server. Every Chrome launch then dies with "Missing X server or
$DISPLAY", and `cloakserve` returns 502 from `/json/version` forever.

Any orchestrator that restarts on unhealthy (the README-recommended
healthcheck + `restart: always`, autoheal sidecars, etc.) then enters a
permanent restart loop because every restart hits the same broken state.

This is silent for first-time users: the container appears to start
successfully (Xvfb did launch *once*), then degrades only after the first
restart. The fix is one line in the entrypoint: remove the stale lock
before starting Xvfb.

Fixes #283
2026-05-21 05:27:49 +02:00
CloakHQ 7e626ee7a1 release: v0.3.30 — binary 146.0.7680.177.5, rendering consistency fixes 2026-05-21 05:09:10 +02:00
CloakHQ b91274cc98 release: v0.3.29 — extension loading, composable JS helpers, cloakserve origin guard 2026-05-20 08:26:04 +02:00
CloakHQ 7a9a61d4de feat(js): add launchPersistentContext to Puppeteer wrapper (#261)
Expose userDataDir support via launchPersistentContext() for
cloakbrowser/puppeteer, matching the existing Playwright API.
Includes proxy auth, geoip, and humanize support.
2026-05-18 18:32:45 +02:00
34bc095b65 fix: guard cloakserve websocket origins (#240)
Co-authored-by: 이민재 <19909783+honor2030@users.noreply.github.com>
2026-05-17 19:40:44 +02:00
a23268c9e9 feat(js): export composable launch helpers (#244)
Co-authored-by: 이민재 <19909783+honor2030@users.noreply.github.com>
2026-05-17 19:15:39 +02:00
CloakHQ 0437a3f1f5 docs: update contributors and add extension_paths examples 2026-05-15 21:18:49 +02:00
zackandGitHub 8fdaa5a2d3 feat: add extension_paths parameter for loading Chrome extensions (#210)
Add `extension_paths` parameter to all launch functions (Python + JS) for loading Chrome extensions.

Resolves paths to absolute, injects `--load-extension` and `--disable-extensions-except` flags via `build_args()`.

Note: Extensions require a persistent context (`launch_persistent_context`) to function — this is a Chromium limitation.

Co-authored-by: zackycodes <75211659+zackycodes@users.noreply.github.com>
2026-05-15 21:08:42 +02:00
Cloak-HQandGitHub b0ea580cba feat(humanize): add Playwright-style actionability checks (#228)
* feat(humanize): add Playwright-style actionability checks to all interaction methods

Humanized locator/page methods now perform pre-action validation matching
Playwright's native behavior: attached, visible, enabled, editable, stable,
and receives-pointer-events checks with retry loop and backoff.

- New error hierarchy: ActionabilityError base with ElementNotAttachedError,
  ElementNotVisibleError, ElementNotStableError, ElementNotEnabledError,
  ElementNotEditableError, ElementNotReceivingEventsError
- force=True parameter skips all actionability checks (matches Playwright)
- Shared deadline across all steps (checks + scroll + stable + pointer)
- Post-scroll stability check only runs when scroll actually happened
- Chained methods (type/fill/check/uncheck/press) skip inner click checks
  but still run pointer-events check at actual click coordinates
- Frame methods now forward kwargs (force, timeout, human_config)
- Locator patches forward force via _forward_kwargs
- Python sync + async, JS/TS implementation

* fix(humanize): forward human_config in all chained methods, use evaluate args in handle pointer checks

- Add human_config=kwargs.get("human_config") to check/uncheck/select_option/press inner calls (sync+async+JS)
- Convert check_pointer_events_handle from f-string interpolation to evaluate args pattern (sync+async+JS)

* fix(humanize): strip custom kwargs before forwarding to Playwright select_option

originals.select_option(**kwargs) passes human_config/force to Playwright
which rejects unknown kwargs with TypeError.
2026-05-15 20:57:17 +02:00
CloakHQ 6f4f92e7c7 fix(security): add URL validation and SSRF protection to Lambda handler (#233)
Restrict Lambda handler to http/https URLs, block private/internal IPs,
remove caller-controlled extra_args and wait_for_function, re-validate
URL after navigation to catch redirect-based SSRF.
2026-05-13 18:55:07 +02:00
Sergey ZaborovskyandGitHub ad4d946ca6 Add flake.nix for Nix / NixOS (#220)
* feat: add flake.nix

* refactor: improve code style and add more information to flake.nix

* chore(nix): ignore build result symlink

* chore(nix): use unversioned pytest packages
2026-05-12 23:52:55 +02:00
@aaronjmarsandGitHub 95a98b6747 fix(security): isolate workflow_dispatch input to avoid shell injection in attest-release (#223)
Security hardening: route workflow_dispatch input through env var to prevent shell injection in attest-release workflow.
2026-05-12 15:53:31 +02:00
23f1d4098c fix(security): bump tar + transitive deps via npm audit fix (#222)
Detected by Aeon + osv-scanner.
Severity: high (runtime tar) / high+moderate (dev deps)

Patches 8 of 14 CVEs flagged by osv-scanner — all that can be fixed
within current semver ranges via `npm audit fix --package-lock-only`.
The remaining 6 are gated on a puppeteer-core/vitest major-version
bump (out of scope for this PR).

Runtime (shipped to users):
- tar 7.5.9 -> 7.5.15
  - GHSA-9ppj-qmqm-q256 HIGH: Symlink Path Traversal via Drive-Relative Linkpath
  - GHSA-qffp-2rhf-9h96 HIGH: Hardlink Path Traversal via Drive-Relative Linkpath
  - Reachable in js/src/download.ts (extractTar) — the existing filter() rejects
    absolute paths and "..", but does not inspect linkpath, so a malicious
    Chromium tarball could write outside the cache dir on Windows.

Dev (build-time only):
- basic-ftp 5.2.0 -> 5.3.1 (4 HIGH: CRLF injection x2, DoS x2)
- ip-address 10.1.0 -> 10.2.0 (1 MOD: XSS in Address6 HTML methods)
- postcss 8.5.6 -> 8.5.14 (1 MOD: XSS via unescaped </style>)

Lockfile metadata side-effects (npm-regenerated, not editorial):
- name@version block synced from package.json (0.3.23 -> 0.3.28)
- devDependencies + peerDependencies version ranges synced to current
  package.json (the lockfile was stale relative to head package.json)

Verification:
- `npm test` -> 320 passed / 11 skipped / 0 failed (9 test files)
- `npm run typecheck` -> clean
- osv-scanner before: 14 CVEs; after: 6 (those 6 need a breaking
  major-version bump to land — happy to follow up if you want it)

Co-authored-by: Aeon <aeon@aaronjmars.eth>
2026-05-12 15:47:26 +02:00
Novi Kurnia HutapeaandGitHub d45d7de9a9 chore(js): sync package-lock metadata (#219) 2026-05-12 15:39:22 +02:00
CloakHQ db0b5f1946 release: v0.3.28 — cloakserve path traversal fix, GeoIP timeout guard, humanize iframe scope 2026-05-11 21:43:14 +02:00
CloakHQ babef04e07 fix(cloakserve): sanitize fingerprint seed to prevent path traversal (#217)
Validate seed format with strict regex, add path containment check
before rmtree, and bind to 127.0.0.1 by default on bare metal.
2026-05-11 21:36:09 +02:00
CloakHQ f8026a7b39 chore: clean up GeoIP timeout follow-up (#213)
Remove dead null checks, document CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS
env var, credit contributor.

Fix review findings:
- Use timeout-bounded resolve_proxy_exit_ip in _resolve_webrtc_args
- Add missing timeout handler on tunneled HTTPS request in JS
- Reject nan/inf in Python timeout parsing (parity with JS)
- Recompute deadline after CONNECT succeeds in JS proxy tunnel
2026-05-11 21:15:36 +02:00
manaskarraandGitHub 71f57d00d1 fix: bound GeoIP resolution so launch cannot hang (#213)
* Fix geoip resolution timeout

* fix: keep GeoIP timeout inside resolution path
2026-05-11 20:55:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e9735392e8 chore(deps): bump sigstore/cosign-installer in the actions group (#214)
Bumps the actions group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 4.1.1 to 4.1.2
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 20:55:41 +02:00
CloakHQ 0d41a4f023 refactor(js): extract HumanActionOptions type, fix frame check/uncheck error handling, align SOCKS5 log level
- Extract HumanActionOptions type alias to replace ~40 inline copies
- Frame check/uncheck: let isChecked errors propagate instead of silently clicking non-checkbox elements
- SOCKS5 credential log: console.debug → console.info (parity with Python logger.info)
- Add contributors to README
2026-05-11 00:23:31 +02:00
EternalandCloakHQ 80d9f7c14e feat(js): add types to humanized method options (#205)
Replace `any` with proper TypeScript types on all humanized method options
(Playwright, Puppeteer, ElementHandle, Frame). Support flat per-call config
overrides alongside existing `human_config` style. Internalize `humanIdle`
duration computation with backward-compatible overloads.

Co-authored-by: Eternal <chasezou09@gmail.com>
2026-05-10 23:50:01 +02:00
114b3c826b fix(js): preserve iframe scope in humanized frame actions (#201)
fix(js): preserve iframe scope in humanized frame actions

Frame actions (click, type, fill, etc.) now resolve selectors through frame.locator() instead of delegating to page.* methods, fixing iframe-scoped interactions.

Closes #184

Co-authored-by: manaskarra <manas.karra@gmail.com>
2026-05-10 18:13:29 +02:00
YouhaiandGitHub c07c2b6b4a fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157) (#209)
* fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157)

Auto URL-encoding of SOCKS5 credentials (added in v0.3.26 to fix Chromium's
'=' truncation bug) currently happens silently. Users debugging connectivity
have no way to know the wrapper rewrote their proxy URL — the original #157
thread took 8 round-trips to surface this exact ambiguity.

Emit a log when re-encoding actually changes the URL: INFO on Python's
'cloakbrowser' logger, console.debug in JavaScript. Stays silent on
already-encoded inputs and credential-less URLs to avoid false-positive
noise. Credentials are not included in the log message.

Tests: 3 new cases per language (Python caplog, JS vi.spyOn console.debug)
covering trigger / silent-when-encoded / silent-when-no-creds.

* fix(proxy): gate log on credential change, not full URL diff

Per Copilot review on #209: urlparse cosmetically lowercases scheme and
hostname, so comparing the full reconstructed URL to the input would emit
"Auto URL-encoded SOCKS5..." even for inputs like
`socks5://USER:pass@HOST.com:1080` where no credential encoding happened.

Compare raw vs encoded user/password substrings instead. Mirror the same
condition in JS for parity (JS's manual parser preserves case today, but the
credential-level compare is more robust against future changes).

Adds one regression test per language.
2026-05-10 18:09:46 +02:00
CloakHQ 13b1b98b68 fix(js): bump playwright-core minimum to >=1.53.0 (#200)
playwright-core <=1.52.0 injects __pwInitScripts into window, which
deviceandbrowserinfo.com detects as isPlaywright:true. Fixed in 1.53.0.
2026-05-07 17:31:07 +02:00
CloakHQ 0d6ce76b1d release: v0.3.27 — per-call human_config, humanize timeout fix, scrollIntoViewIfNeeded 2026-05-06 18:32:41 +02:00
CloakHQ f01902025a fix: align humanize timeout default with Playwright's 30s auto-retry (#172)
The humanize layer hardcoded timeout=2000ms for element lookups, causing
locator.click() and page.click() to fail instantly instead of retrying
for 30s like standard Playwright. Aligned all defaults to 30000ms across
Python sync/async, JS Playwright, and JS Puppeteer paths. Bumped the
outer retry sleep from 200ms to 500ms for DOM mutation settle time.
2026-05-01 20:48:06 +02:00
CloakHQ 2df8c7e2d1 fix(js): correct issue references #137#172 in humanize comments 2026-04-28 20:37:09 +02:00
lilosandGitHub 661b873dad feat: per-call human_config, timeout forwarding, humanized scrollIntoViewIfNeeded (#183) 2026-04-28 20:34:17 +02:00
CloakHQ ee346a6a57 release: v0.3.26 — Windows x64 upgraded to Chromium 146, SOCKS5 credential encoding, Lambda integration 2026-04-28 05:38:06 +02:00
CloakHQ 3e699f554c fix(docker): add emoji and extended font packages to resolve Kasada/Akamai canvas blocks (#179)
Dockerfile: add fonts-noto-color-emoji, fonts-freefont-ttf, fonts-unifont,
fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf.
README: separate anti-bot font fix (apt packages) from CreepJS font
enumeration (Windows fonts + --fingerprint-fonts-dir).
2026-04-28 04:11:19 +02:00
Alex StepanskyandGitHub 9eb90da012 feat(lambda): cold-start hardening + handler-side retry orchestration (#180)
* feat(lambda): cold-start hardening + handler-side retry orchestration

Two related improvements based on benchmarking the integration at scale
(3454-site sample, multiple iterations).

Cold-start hardening (lambda-entrypoint.sh + lambda_handler.py):
  - Clean stale Xvfb lock file before starting the X server. We observed
    that under cold-start storms, a previous Xvfb sometimes died and left
    /tmp/.X99-lock + /tmp/.X11-unix/X99 behind, so the next start failed
    with "Server is already active for display 99". Removing both files
    makes Xvfb start cleanly every time.
  - Replace `sleep 0.5` with a poll-for-X11-socket loop (up to 10s) plus
    a 200ms post-socket buffer for listen()/accept() to settle. The
    fixed sleep lost the race during concurrent cold inits, surfacing as
    "Looks like you launched a headed browser without having a XServer
    running" failures (~10% rate at 100-concurrent cold-start storm).
  - Add _launch_with_retry helper in the handler: 3 attempts with linear
    backoff (0.3s, 0.6s) on launch_context_async failures. Belt-and-
    suspenders for whatever the entrypoint fix doesn't catch — a retry on
    a now-warm container almost always succeeds.

Handler-side retry orchestration (lambda_handler.py):
  - Add _classify_error() — maps Playwright errors to retry-strategy
    overrides:
      ERR_CERT_*                -> --ignore-certificate-errors + 60s goto
      Timeout exceeded          -> 90s goto + 25s smart_wait cap
      ERR_CONNECTION_TIMED_OUT  -> same as Timeout
    Returns None for unrecoverable site issues (DNS, SSL, refused, HTTP
    4xx/5xx) — those bail immediately without burning a retry slot.
  - Add _attempt_scrape() — extracted scrape body so the retry loop can
    call it with overridden event dicts. Each attempt relaunches the
    browser; uniform behavior across strategies.
  - Rewrite _run() as a retry loop: first attempt uses event verbatim;
    on a classifiable failure, merge the strategy's overrides into the
    event and retry. Bounded by the new `retries` event field (default 1;
    set to 0 to disable retry).
  - Add _raise_with_history() — surfaces a final failure with a
    retry_history block embedded in the error message so callers see
    exactly what was tried before bailing. Successful invocations return
    the standard response shape unchanged — no surprise fields.

INSTRUCTIONS.md updates:
  - Bump function timeout recommendation from 60-120s to 120-180s. Under
    retry, a Timeout-class first failure (30s) plus a longer-budget retry
    (90s) plus cleanup can total ~120-130s; 180s leaves headroom.
  - Document the new `retries` event field in the schema.
  - Add a "Retry orchestration" subsection covering both layers (launch
    retries and strategy retries) with the full strategy table.

Bench results on the 3454-site sample (seed=1):
  v1 baseline (no fixes, c=100):           13.5% failure rate, $1.07
  v2 (entrypoint Xvfb poll only, c=100):    9.9% failure rate, $1.11
  v3 (cold-start fix + bench-side retry):   3.3% failure rate, $1.32
  This change (handler retry, c=250):       2.1% failure rate, $1.13

The remaining 2.1% are all genuinely unrecoverable: DNS doesn't exist,
broken SSL, connection refused, 4xx/5xx responses, payload >6MB Lambda
limit. No retry logic can fix those.

* fix(lambda): merge extra_args on strategy retry instead of clobbering

A flat dict spread replaced caller-supplied extra_args (e.g.
--proxy-server=...) with the strategy's extra_args on a cert retry.
Append both lists so caller flags survive the merge.
2026-04-28 03:33:04 +02:00
CloakHQ 6b8d8b6378 docs: add Font Setup on Linux section to README (#179) 2026-04-28 00:23:08 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
252e79b17d chore(deps): bump the actions group across 1 directory with 3 updates (#178)
Bumps the actions group with 3 updates in the / directory: [actions/setup-node](https://github.com/actions/setup-node), [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `actions/setup-node` from 6.3.0 to 6.4.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e...cef221092ed1bacb1cc03d23a2d87d1d172e277b)

Updates `docker/build-push-action` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/d08e5c354a6adb9ed34480a06d141179aa583294...bcafcacb16a39f128d818304e6c9c0c18556b85f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 17:32:05 +02:00
CloakHQ 0ccdc71e47 docs: add @AlexTech314 to contributors, add Deployment Integrations section (#177) 2026-04-27 17:23:24 +02:00
Alex StepanskyandGitHub 74b1ff64db feat(lambda): add AWS Lambda integration in examples/integrations/aws_lambda/ (#177)
feat(lambda): add AWS Lambda one-shot scrape integration

Self-contained example in examples/integrations/aws_lambda/ — Dockerfile,
entrypoint, handler, and docs for running CloakBrowser stealth scrapes in
AWS Lambda (container image). Includes smart_wait DOM-stability polling,
Xvfb headed mode, and Lambda-specific Chromium flags.

Contributed by @AlexTech314.
2026-04-27 17:20:02 +02:00
CloakHQ a9a0ba13ba fix(proxy): auto URL-encode SOCKS5 credentials in string URLs (#157)
Chromium's --proxy-server parser truncates passwords at '=' and other
special chars, causing SOCKS5 auth to silently fail and fall back to
direct connection. The dict path already encoded creds; now the string
path does too. Idempotent: pre-encoded input stays encoded.
2026-04-25 23:01:16 +02:00
CloakHQ b04ad6ec2a docs: credit @eofreternal for humanConfig type fix (#151) 2026-04-16 23:12:57 +02:00
CloakHQ 4459f66593 release: v0.3.25 — Chromium 146.0.7680.177.3, launch_context_async, contextOptions 2026-04-16 22:24:36 +02:00
CloakHQ ce8b92ba4f feat: add launch_context_async() + JS contextOptions escape hatch (#141)
Python: add async counterpart to launch_context(). Forwards all kwargs to
browser.new_context() — enables storage_state, permissions, extra_http_headers,
etc. without needing a persistent profile folder.

JS: launchContext() and launchPersistentContext() silently dropped unknown
options. New contextOptions field in LaunchContextOptions is spread into
newContext() to forward arbitrary Playwright context options (e.g.
storageState, permissions, geolocation).
2026-04-16 21:30:40 +02:00
CloakHQ 4e1027847e fix: bump CHROMIUM_VERSION display constant to .2 (#157) 2026-04-15 18:20:01 +02:00
EternalandGitHub f164c1c874 fix(types): type humanConfig properly (#151) 2026-04-12 22:58:48 +02:00
lilosandGitHub f5e242a160 Update CHANGELOG for version 0.3.24 (#139)
Wrong username :(
2026-04-11 01:03:29 +02:00
CloakHQ 935beef980 docs: add recommended anti-bot config and SOCKS5 tips to troubleshooting
Based on recurring GitHub issue patterns (#117, #78, #130, #131).
2026-04-10 23:47:42 +02:00
CloakHQ c6d3469e4c release: v0.3.24 — SOCKS5 proxy support, arm64 146 upgrade, ElementHandle humanize 2026-04-10 22:42:29 +02:00
CloakHQ cb0b87873e feat: native SOCKS5 proxy support in proxy= parameter
Route SOCKS5/SOCKS5h proxies via --proxy-server Chrome arg instead of
Playwright's proxy dict (which rejects SOCKS5 with credentials).
Handles string URLs, Playwright dicts, IPv6, bypass lists.

SOCKS5 geoip exit IP resolution uses socks-proxy-agent (optional peer
dep). Falls back to DNS if not installed.
2026-04-10 22:20:16 +02:00
lilosandGitHub 2be8cdcc03 feat(humanize): add Playwright ElementHandle support and fix async tests (#133) 2026-04-10 22:18:14 +02:00
CloakHQ be9a98db67 fix(test): correct cloakserve passthrough test for --fingerprint parsing 2026-04-10 21:40:20 +02:00
CloakHQ 9b004bbd85 docs: clarify humanize requires wrapper import over CDP (#126) 2026-04-09 21:08:48 +02:00
CloakHQ 5b2981c4c1 release: v0.3.23 — Puppeteer humanize, CDP humanize export, cloakserve locale fix 2026-04-09 20:56:16 +02:00
lilosandGitHub 7afe59435e feat: Add Puppeteer humanize support and fix Playwright humanize gaps (#129)
- Add full Puppeteer humanize implementation (page, frame, element handle patching)
- Fix critical Playwright gaps: page.pressSequentially, page.tap, page.clear
- Fix frame-level patching: frame.pressSequentially, frame.tap
- Add comprehensive stealth tests for Puppeteer
- Update SLOW test suite to use correct humanize: true API
- Add 4 new tests validating fixed Playwright methods
2026-04-09 20:49:20 +02:00
CloakHQ 1cef71133d fix(test): clear CLOAKBROWSER_BINARY_PATH in puppeteer mock tests
Env var override takes precedence over ensureBinary mock, causing
test to fail in Docker where the var is always set.
2026-04-09 20:45:16 +02:00
CloakHQ 7a0937cc54 feat(js): expose humanize module for CDP-connected browsers (#126)
Add ./human export path to package.json so users can import patchBrowser,
patchPage, and resolveConfig to humanize CDP-connected Playwright instances.
2026-04-09 18:06:29 +02:00
CloakHQ 5b00ff0325 fix(cloakserve): route locale/timezone/seed CLI args through build_args()
CLI args like --fingerprint-locale were passed as raw passthrough args
to Chrome, missing the companion --lang flag that build_args() normally
adds. Caused Intl API to default to en-US while navigator.language
showed the correct locale — a detectable mismatch.

Fixes #130
2026-04-09 17:58:47 +02:00
CloakHQ 5dd44298ee ci: use Node 24 for npm publish (Node 22.22.2 has broken npm)
Node 22.22.2's bundled npm 10.9.7 is missing promise-retry, breaking
npm install -g. Node 24 ships npm 11.11.0 with native OIDC support.

Ref: nodejs/node#62425, actions/runner-images#13883
2026-04-09 04:52:12 +02:00
CloakHQ 54d8442f20 release: v0.3.22 — Chromium 146 upgrade (linux-x64) 2026-04-09 04:36:42 +02:00
CloakHQ a01adbe26c ci: restore npm upgrade for OIDC publishing (pin to npm@11)
Node 22 ships npm v10 which lacks OIDC support. The upgrade step was
removed in 02359f6 but is required for provenance-based publishing.
Pin to npm@11 instead of @latest to avoid future breakage.
2026-04-07 07:31:08 +02:00
CloakHQ 06d77e7261 refactor: remove dead stealth args, let binary handle GPU diversity
Remove --disable-blink-features=AutomationControlled (dead, binary handles
navigator.webdriver at source level) and hardcoded GPU vendor/renderer flags.
Binary auto-generates diverse GPU profiles from fingerprint seed. Improves
fingerprint diversity -- previously every user shared the same GPU string.

Bump to v0.3.21.
2026-04-07 07:16:22 +02:00
CloakHQ 211bd93d3e fix(docker): install geoip2 in Docker image
geoip=True raised ImportError inside the container because geoip2
was not installed. Added [geoip] extra to pip install.
2026-04-07 06:37:14 +02:00
CloakHQandkitiho 1060772734 fix: allow null viewport in Python wrapper (mirrors #107)
viewport=None now disables viewport emulation via Playwright's
no_viewport=True, matching the JS wrapper's viewport: null behavior.
Uses a sentinel to distinguish "not provided" from explicit None.

Co-authored-by: kitiho <51785099+kitiho@users.noreply.github.com>
2026-04-07 05:14:01 +02:00
kitihoandGitHub 8eb2e4b905 fix: allow null viewport to disable viewport emulation (#107)
fix: allow null viewport to disable viewport emulation
2026-04-07 05:11:44 +02:00
CloakHQ 216a7d6a6a fix(examples): enable geoip in stealth test to fix FingerprintJS detection 2026-04-06 02:38:26 +02:00
CloakHQ 02359f69c8 ci: remove npm self-upgrade step — Node 22 ships with compatible npm 2026-04-06 02:08:44 +02:00
CloakHQ a0c7704c4b release: v0.3.20 — 48 patches, WebRTC IP spoofing, proxy signal removal 2026-04-06 01:54:35 +02:00
lilos ccda93669e feat(humanize): implement CDP Isolated Worlds and trusted keyboard events (fixes #110) 2026-04-06 01:43:46 +02:00
CloakHQ eb4efef329 feat: add --fingerprint-webrtc-ip flag with auto-resolve support
Two ways to spoof WebRTC ICE candidate IPs:

1. --fingerprint-webrtc-ip=auto in args: resolves proxy exit IP via
   HTTP call through the proxy (ipify.org). No extra deps needed.

2. geoip=True: auto-injects the flag for free (exit IP already
   resolved during timezone/locale lookup, zero extra network cost).

Explicit IP (--fingerprint-webrtc-ip=1.2.3.4) also supported.
User-provided values always take precedence.

Python + JS wrappers, README docs, tests.
2026-04-06 01:16:10 +02:00
CloakHQ 25d34dcea3 feat(cloakserve): add connection tracking, configurable data dir, better status endpoint
- Move `import websockets` to top-level (guaranteed by [serve] extra)
- Add --data-dir flag with smart default (Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve)
- Store launch params (tz/locale/proxy) on ChromeProcess for conflict logging
- Enhance GET / to return per-process detail (pid, port, seed, connections, config)
- Add connection refcounting in WS handlers for status visibility
- Add first-launch-wins note to README
- Add tests for data-dir, Docker detection, and connection tracking
2026-04-05 22:41:33 +02:00
CloakHQ c9e4f58353 feat: rewrite cloakserve as CDP multiplexer with per-connection fingerprint seeds
Spawns a separate Chrome process per unique fingerprint seed, all behind
a single port (9222). Clients specify seeds and fingerprint params via
query string on the CDP URL:

  connect_over_cdp("http://host:9222?fingerprint=12345&timezone=Asia/Tokyo")

Supports all --fingerprint-* flags as query params, geoip=true for
auto timezone/locale from proxy IP, and proxy= for per-process proxies.

- Rewrite bin/cloakserve from 57-line wrapper to aiohttp CDP multiplexer
- Add ChromePool with per-seed process management and port allocation
- Bidirectional WebSocket proxy for CDP traffic
- URL rewriting for /json/version, /json/list, and WS paths
- Rename _build_args -> build_args, _maybe_resolve_geoip -> maybe_resolve_geoip
- Add aiohttp + websockets to serve optional deps
- Dockerfile installs .[serve] extras
- Add 20 unit tests for cloakserve (param parsing, CLI args, URL rewriting)
2026-04-05 22:30:18 +02:00
CloakHQ c58b691f1c chore(deps): bump actions/checkout, docker/setup-qemu, docker/setup-buildx, docker/login-action; group Dependabot PRs 2026-04-05 22:26:14 +02:00
CloakHQ 1b91a33e51 chore: update integration examples and gitignore
- browser_use: migrate to BrowserSession + bundled ChatOpenAI API
- crawl4ai: add browser_mode="cdp" param
- gitignore: add captures/
2026-04-02 01:32:08 +02:00
CloakHQ 1bfd5ca036 docs: update patch count to 42, add --fingerprint-noise flag, bump version refs 2026-03-30 20:46:16 +02:00
CloakHQ f46f8e9364 release: v0.3.19 — upgrade Linux x64 binary to 145.0.7632.159.8 (42 patches) 2026-03-30 19:17:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
592b3d5661 chore(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 (#90)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:19 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a0a8210e35 chore(deps): bump actions/setup-python from 5.6.0 to 6.2.0 (#89)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
468964ff30 chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#88)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:15 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1b93e634b chore(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.1 (#87)
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.9.1 to 4.1.1.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/398d4b0eeef1380460a10c8013a76f728fb906ac...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5ccb4a32a5 chore(deps): bump actions/attest-build-provenance from 2.4.0 to 4.1.0 (#86)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.4.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/e8998f949152b193b063cb0ec769d69d929409be...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:10 +02:00
CloakHQ 49d80d3b57 security: pin GitHub Actions to commit SHAs, add Dependabot
Pin all 22 action references across ci.yml, publish.yml, and
attest-release.yml to immutable commit SHAs. Mutable tags can be
force-pushed by attackers (cf. TeamPCP supply chain campaign).

Add Dependabot for github-actions to get weekly PRs when pinned
actions have new versions.
2026-03-27 21:20:08 +01:00
CloakHQ 2813b3dc4c docs: warn that ElementHandle bypasses humanize patches 2026-03-18 07:51:07 +01:00
dgtlmoonandGitHub 6550f3ad6c fix: ensure pw.stop() runs even if browser.close() raises or is cancelled (#60) 2026-03-15 17:51:03 +01:00
CloakHQ 132cafe13c release: v0.3.18 — fix welcome banner polluting stdout (fixes #59) 2026-03-15 17:19:44 +01:00
CloakHQ 6c94b9e985 feat: add GitHub issue template for bug reports 2026-03-15 07:11:33 +01:00
CloakHQ fdc1ae0484 fix: add --ignore-gpu-blocklist to cloakserve for Docker WebGL support
cloakserve bypasses the wrapper and launches Chrome directly, missing
the GPU blocklist fix from 1380c86. Fixes #58.
2026-03-15 06:02:47 +01:00
CloakHQ 2ded0c1866 docs: add Crawlee integration example 2026-03-15 05:42:23 +01:00
CloakHQ f91700c4a4 release: v0.3.17 — Windows x64 binary upgrade to 145.0.7632.159.7
- Bump wrapper version to 0.3.17 (Python + JS)
- Update PLATFORM_CHROMIUM_VERSIONS: windows-x64 109.2 → 159.7
- Update patch counts in platform tables (Linux 33, Windows 33)
- Add Linux arm64 to JS README platform table
- Update CHANGELOG with all changes since v0.3.16
2026-03-15 02:42:13 +01:00
CloakHQ 1380c86847 fix: auto-inject --ignore-gpu-blocklist for headed mode and Windows
Headed mode (all platforms): Chromium's GPU blocklist disables WebGL on
software GPUs in Docker/VNC/Xvfb. Flag lets SwiftShader serve WebGL.
Harmless on real GPUs. Headless unaffected. Ref #56.

Windows (all modes): GPU blocklist also blocks WebGPU for the Microsoft
Basic Render Driver. Dawn's adapter_blocklist bypass alone isn't enough.
2026-03-15 02:23:48 +01:00
CloakHQ 83e3b30117 feat: add 8 framework integration examples + README integrations section
Add examples/integrations/ with tested examples for browser-use, Crawl4AI,
Scrapling, LangChain, Selenium, undetected-chromedriver, and agent-browser.
Add js/examples/stagehand.ts for Stagehand (TypeScript).

README: new "Framework Integrations" subsection with two integration
patterns (direct binary launch vs CDP connect) and table linking all 8 examples.
2026-03-14 20:56:25 +01:00
CloakHQ 5649620545 release: v0.3.16 — Linux ARM64 binary, multi-arch Docker, donate link
- Add linux-arm64 to PLATFORM_CHROMIUM_VERSIONS (Python + JS)
- Multi-arch Docker build (linux/amd64 + linux/arm64) via QEMU in CI
- Add ko-fi donate link to welcome banner (Python + JS)
- Version bump to 0.3.16
2026-03-14 00:42:06 +01:00
CloakHQ d2a42fc86b release: v0.3.15 — upgrade Linux binary to .159.7, StorageBuckets normalization
Binary:
- Upgrade Linux x64 build to Chromium 145.0.7632.159.7 (33 C++ patches)
- StorageBuckets API quota normalization — closes last storage-based incognito detection vector

Wrapper:
- Fix non-ASCII character support in humanized typing (Cyrillic, CJK, emoji)
- Document storage quota tradeoff for persistent contexts
- Add Ko-fi funding link
2026-03-13 18:59:18 +01:00
lilos 1af25d67bc fix: support non-ASCII characters (Cyrillic, CJK, emoji) in humanized typing 2026-03-13 00:34:01 +01:00
CloakHQ 1bef989404 test: add download fallback tests for primary → GitHub failover
Verify that HTTP errors (429, 503, etc.) from cloakbrowser.dev
correctly trigger GitHub Releases fallback for both binary and
checksum downloads. Also test that custom CLOAKBROWSER_DOWNLOAD_URL
disables fallback, and both-sources-fail returns gracefully.
2026-03-12 19:47:49 +01:00
CloakHQ 0aa4ea56bd docs: add Browser Profile Manager section to README 2026-03-12 01:58:26 +01:00
CloakHQ c0ba21faa1 release: v0.3.14 — upgrade Linux binary to .159.6, add binary management CLI
Binary:
- Upgrade Linux build to 145.0.7632.159.6 (32 patches)
- Fix persistent context fingerprint consistency
- Storage quota normalization for persistent context profiles
- Fix window dimension calculation for non-incognito contexts

Wrapper:
- Add CLI for binary management with visible download progress (closes #43)
- Python: python -m cloakbrowser install|info|update|clear-cache
- JavaScript: npx cloakbrowser install|info|update|clear-cache
2026-03-11 23:44:41 +01:00
CloakHQ b501d8f158 chore: gitignore browser profile manager directory 2026-03-11 18:13:19 +01:00
CloakHQ 6007a6e511 feat: add CLI for binary management (Python + JavaScript)
Adds install, info, update, and clear-cache subcommands with visible
download progress. Python: `python -m cloakbrowser install`. JavaScript:
`npx cloakbrowser install`. Useful for Dockerfiles where silent
first-use downloads are hard to debug. Closes #43.
2026-03-11 04:33:37 +01:00
CloakHQ 96c55352e0 ci: remove deployment environments from publish workflow to hide actor identity 2026-03-11 00:22:57 +01:00
CloakHQ 5d35fb9e4c release: v0.3.13 — suppress SwiftShader default arg, upgrade Linux binary to .159.5 2026-03-10 23:11:12 +01:00
CloakHQ c966e046e7 docs: add Docker signature verification, deployment environments, improve troubleshooting
- Add cosign verify command to README Security section for Docker image verification
- Add GitHub deployment environments (pypi, npm, docker) to publish workflow for sidebar status tracking
- Simplify downgrade instructions: version-pinned pip/npm/docker instead of manual binary paths
- Improve troubleshooting section with headings and dividers for readability
- Update Latest section to v0.3.12 with new binary features
2026-03-10 07:27:18 +01:00
CloakHQ 767eb16a82 release: v0.3.12 — locale spoofing patch, WebGPU hardening, binary flags for tz/locale
Binary: 145.0.7632.159.4 (linux), 32 patches.
Wrapper: bare proxy format, ANGLE GPU strings, README updates.
2026-03-10 06:27:16 +01:00
CloakHQ 04255cf412 fix: use binary flags for timezone/locale instead of detectable CDP emulation
- Remove locale and timezone_id from Playwright context kwargs (CDP)
- Pass timezone via --fingerprint-timezone binary flag (process-wide)
- Pass locale via --lang + --fingerprint-locale binary flags
- Accept both timezone and timezone_id param names silently (no deprecation)
- Update all wrapper tests to verify binary args, not CDP context params
2026-03-10 03:56:57 +01:00
CloakHQ 1fb554e061 fix: support bare proxy format (user:pass@host:port) without scheme
Normalize bare proxy strings by prepending http:// before parsing when
@ is present but :// is absent. Tests added for Python and JS.
2026-03-09 19:35:07 +01:00
CloakHQ 748013bf83 fix: use ANGLE-wrapped GPU strings for realistic WebGL fingerprint
Bare vendor/renderer strings are detectable — real Chrome reports
ANGLE-wrapped values through WebGL's getParameter API.
2026-03-09 02:25:33 +01:00
CloakHQ eeea366047 ci: upgrade npm for OIDC trusted publishing (requires npm >= 11.5.1) 2026-03-08 23:54:18 +01:00
109 changed files with 24714 additions and 1915 deletions
+5
View File
@@ -0,0 +1,5 @@
# Never let signing material enter a Docker build context / image.
# The test image (test-infra/Dockerfile.test) uses selective COPY today, but
# this is defense-in-depth against a future `COPY . .`.
test-infra/signing/
*.pem
+1
View File
@@ -0,0 +1 @@
ko_fi: cloakhq
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug Report
about: Report a bug or detection issue
labels: bug
---
Description: <!-- What happened? What did you expect? -->
CloakBrowser version: <!-- pip show cloakbrowser / npm list cloakbrowser -->
Wrapper: <!-- Python or JavaScript -->
Environment: <!-- OS, Docker y/n, base image, architecture -->
Launch options:
Tested with a different IP or proxy? <!-- Yes (same result) / Yes (works with different IP) / No -->
Works outside Docker / on host machine? <!-- Yes / No / Not using Docker -->
Steps to reproduce:
Error output / screenshots:
Dockerfile (if applicable):
Additional notes:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+28
View File
@@ -0,0 +1,28 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
python:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/js"
schedule:
interval: "weekly"
groups:
javascript:
patterns:
- "*"
+3 -2
View File
@@ -16,12 +16,13 @@ jobs:
contents: write # Download release assets
steps:
- name: Download release binaries
run: gh release download ${{ github.event.inputs.tag }} --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
run: gh release download "$RELEASE_TAG" --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.inputs.tag }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
cloakbrowser-*.tar.gz
+4 -4
View File
@@ -10,8 +10,8 @@ jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install dependencies
@@ -22,8 +22,8 @@ jobs:
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install and build
+19 -17
View File
@@ -24,15 +24,15 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Python tests
run: |
pip install -e ".[dev]" pytest pytest-asyncio
pytest tests/ -v -m "not slow"
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: JavaScript tests
@@ -42,8 +42,8 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Check tag matches package versions
@@ -62,8 +62,8 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Build
@@ -71,7 +71,7 @@ jobs:
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1
publish-npm:
needs: [test, validate-version]
@@ -80,10 +80,10 @@ jobs:
permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
registry-url: 'https://registry.npmjs.org'
- name: Build
run: cd js && npm ci && npm run build
@@ -100,32 +100,34 @@ jobs:
attestations: write
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Extract version
run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
cloakhq/cloakbrowser:${{ env.VERSION }}
cloakhq/cloakbrowser:latest
provenance: true
sbom: true
- uses: sigstore/cosign-installer@v3
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: index.docker.io/cloakhq/cloakbrowser
subject-digest: ${{ steps.build.outputs.digest }}
+12
View File
@@ -46,6 +46,7 @@ js/dist/
*.whl
AGENTS.md
.beads
result
# Private docs (launch posts, strategy)
docs/
@@ -56,9 +57,20 @@ test-infra/
# Website (deployed separately)
site/
# Browser profile manager (deployed separately)
manager/
# Release scripts
publish.sh
deploy.sh
.env
debug
publish-docker.sh
captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key
.antigravitycli
+8 -2
View File
@@ -1,6 +1,6 @@
# CloakBrowser Binary License
**Version 1.0February 2026**
**Version 1.1June 2026**
Copyright (c) 2026 CloakHQ. All rights reserved.
@@ -14,7 +14,13 @@ The Binary is built on Chromium, which is open-source software by The Chromium A
## Grant of Use
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes. No fees are required.
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes, subject to the Version-Specific Terms below.
## Version-Specific Terms
Starting with Chromium 148, downloading the **latest major** Binary version requires an active CloakBrowser Pro subscription. Previous **major** versions (v146 and earlier) remain available at no cost under this license. Each time a new **major** Chromium version is released, the **prior major version** becomes available for free download. Minor and patch updates to the latest major version are part of the Pro subscription.
Pro subscription details and pricing: https://cloakbrowser.dev
## Restrictions
+188
View File
@@ -6,6 +6,194 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
---
## [0.4.0] — 2026-06-22
- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev
- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected.
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
- **[binary]** **CloakBrowser Pro — first Pro build**: Chromium `148.0.7778.215.2` for linux-x64, linux-arm64, and windows-x64 — 59 source-level fingerprint patches (up from 58 on 146). macOS builds to follow. Available to Pro subscribers at cloakbrowser.dev; v146 remains free on GitHub Releases.
- **[binary]** Rebased the full patch set across two Chromium major versions — 146 → 147 → 148 — re-applying and adapting every patch to current Chromium internals
- **[binary]** Cross-API fingerprint consistency improvements for Chromium 148 profiles
- **[binary]** WebRTC fingerprint hardening — network signals matched to real Chrome
- **[binary]** Font metric alignment for Windows profiles via the opt-in `--fingerprint-windows-font-metrics` flag — requires Windows fonts installed (see README "Font Setup on Linux")
## [0.3.32] — 2026-06-20
- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`)
- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding
- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234)
- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352)
- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374)
- **[meta]** Bump GitHub Actions in the actions group (#358)
## [0.3.31] — 2026-05-26
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
## [0.3.30] — 2026-05-21
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
- **[binary]** Rendering consistency improvements across Linux and Windows — corrected GPU, display, and graphics parameters to match stock Chrome 146 profiles
- **[binary]** Windows: native GPU/rendering values now pass through directly instead of being spoofed, matching real hardware behavior
- **[binary]** Storage normalization fix for Windows
- **[binary]** HTTP proxy inline credential support at the network layer
- **[wrapper]** Update `PLATFORM_CHROMIUM_VERSIONS` for linux-x64 and windows-x64 to 146.0.7680.177.5
## [0.3.29] — 2026-05-20
- **[wrapper]** **Security**: `cloakserve` — guard WebSocket origins to prevent browser-origin CSRF via CDP proxy (thanks [@0xlally](https://github.com/0xlally) for the report, [@honor2030](https://github.com/honor2030) for the fix, #239, #240)
- **[wrapper]** **Security**: Lambda example — add URL scheme validation, SSRF protection, post-navigation re-validation, remove unsafe caller-controlled options (#233)
- **[wrapper]** **Security**: CI — isolate `workflow_dispatch` input to avoid shell injection in attest-release (thanks [@aaronjmars](https://github.com/aaronjmars), #223)
- **[wrapper]** **Security**: JS — bump tar + transitive deps via npm audit fix (thanks [@aaronjmars](https://github.com/aaronjmars), #222)
- **[wrapper]** Add `extension_paths` parameter for loading Chrome extensions in all launch functions (thanks [@zackycodes](https://github.com/zackycodes), #210)
- **[wrapper]** Humanize: add Playwright-style actionability checks — auto-wait for visible, enabled, stable elements before humanized actions (#228)
- **[wrapper]** JS: export composable launch helpers — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations (thanks [@honor2030](https://github.com/honor2030), #244)
- **[wrapper]** JS: add `launchPersistentContext()` to Puppeteer wrapper (#261)
- **[wrapper]** Add `flake.nix` for Nix/NixOS (thanks [@Seryiza](https://github.com/Seryiza), #220)
- **[meta]** JS: sync package-lock metadata (thanks [@245678000000](https://github.com/245678000000), #219)
## [0.3.28] — 2026-05-11
- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217)
- **[wrapper]** Fix GeoIP resolution hanging indefinitely — bounded with 10s timeout so `launch()` cannot stall (thanks [@manaskarra](https://github.com/manaskarra), #213)
- **[wrapper]** JS: preserve iframe scope in humanized frame actions — `check()`, `uncheck()`, `selectOption()` now execute in the correct frame (thanks [@manaskarra](https://github.com/manaskarra), #201)
- **[wrapper]** JS: add TypeScript types to humanized method options — `HumanActionOptions` type for `human_config` and `timeout` overrides (thanks [@eofreternal](https://github.com/eofreternal), #205)
- **[wrapper]** Log when SOCKS5 credential auto-encoding rewrites a proxy URL (thanks [@Youhai020616](https://github.com/Youhai020616), #209)
- **[wrapper]** JS: bump `playwright-core` peer dependency minimum to >=1.53.0 (#200)
- **[meta]** Bump sigstore/cosign-installer in CI (#214)
## [0.3.27] — 2026-05-06
- **[wrapper]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183)
- **[wrapper]** Humanized `scrollIntoViewIfNeeded` — auto-scrolls with human-like behavior when `humanize=True` (#183)
- **[wrapper]** Forward `timeout` parameter through humanized Playwright methods (#183)
- **[wrapper]** Fix humanize timeout default to align with Playwright's 30s auto-retry instead of custom 2s (#172)
## [0.3.26] — 2026-04-28
- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.180.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database
- **[wrapper]** Auto URL-encode SOCKS5 credentials containing special characters in string URLs (#157)
- **[wrapper]** AWS Lambda integration example with cold-start hardening and handler-side retry orchestration (#177, thanks [@AlexTech314](https://github.com/AlexTech314))
- **[docker]** Add emoji and extended font packages to resolve Kasada/Akamai canvas fingerprint blocks (#179)
- **[docs]** Add Font Setup on Linux section to README (#179)
- **[docs]** Add Deployment Integrations section to README (#177)
- **[meta]** Bump GitHub Actions dependencies (#178)
## [0.3.25] — 2026-04-16
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
## [0.3.24] — 2026-04-10
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
## [0.3.23] — 2026-04-09
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
## [0.3.22] — 2026-04-09
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
## [0.3.21] — 2026-04-07
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
## [0.3.20] — 2026-04-06
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
- **[binary]** WebGL rendering accuracy improvements for headed mode
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
- **[meta]** Bump GitHub Actions dependencies
## [0.3.19] — 2026-03-30
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
- **[binary]** 9 new fingerprint patches covering additional browser APIs and cross-platform consistency
- **[binary]** New `--fingerprint-noise` flag — disable noise injection while keeping deterministic fingerprint seed active
- **[binary]** Improved fingerprint noise reliability and determinism across all patched APIs
- **[binary]** Expanded platform-aware fingerprint spoofing for more realistic cross-platform profiles
- **[binary]** Font rendering and detection accuracy improvements for Windows profiles
- **[binary]** Removed experimental patches that caused compatibility issues with certain anti-bot systems
- **[binary]** Docker/VNC environment compatibility improvements
- **[wrapper]** Fix Playwright cleanup — `pw.stop()` now runs even if `browser.close()` raises or is cancelled (fixes #60, thanks [@dgtlmoon](https://github.com/dgtlmoon))
- **[meta]** Pin GitHub Actions to commit SHAs, add Dependabot for automated dependency updates
## [0.3.18] — 2026-03-15
- **[wrapper]** Fix welcome banner printing to stdout — now writes to stderr so it won't corrupt JSON output in programmatic usage (fixes #59)
- **[wrapper]** Fix `cloakserve` Docker WebGL by adding `--ignore-gpu-blocklist` flag
- **[docs]** Add Crawlee integration example
- **[meta]** Add GitHub issue template for bug reports
## [0.3.17] — 2026-03-15
- **[binary]** Windows x64 build upgraded to 145.0.7632.159.7 — 33 source-level C++ patches, matching Linux
- **[wrapper]** Auto-inject GPU blocklist bypass for headed mode and Windows — fixes WebGL/WebGPU on software GPUs in Docker/VNC (fixes #56)
- **[wrapper]** Add 8 framework integration examples (Scrapy, Crawlee, BrowserBase, etc.) and README integrations section
## [0.3.16] — 2026-03-14
- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported
- **[wrapper]** Add donate link to first-launch welcome banner
## [0.3.15] — 2026-03-13
- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches
- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector
- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123))
## [0.3.14] — 2026-03-12
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
- **[binary]** Storage quota normalization for persistent context profiles
- **[binary]** Fix outerHeight calculation for non-incognito contexts
- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43)
## [0.3.13] — 2026-03-10
- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly
- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles
## [0.3.12] — 2026-03-10
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
## [0.3.11] — 2026-03-08
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
+3 -1
View File
@@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxcb1 libxext6 libxshmfence1 \
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
fonts-noto-color-emoji fonts-unifont fonts-freefont-ttf \
fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf \
xvfb xdotool \
curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
@@ -20,7 +22,7 @@ WORKDIR /app
# Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir ".[serve,geoip]"
# JS wrapper
COPY js/ js/
+529 -81
View File
@@ -11,6 +11,11 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p>
<p align="center">
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
</p>
<br>
@@ -35,7 +40,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p>
- **31 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -44,40 +49,67 @@ Same API, same code — just swap the import. <strong>3 lines of code, 30 second
- **Free and open source** — no subscriptions, no usage limits
**Try it now** — no install needed:
```bash
docker run --rm cloakhq/cloakbrowser cloaktest
```
**Python:**
```python
from cloakbrowser import launch
browser = launch()
page = browser.new_page()
page.goto("https://protected-site.com") # no more blocks
page.goto("https://example.com")
browser.close()
```
**JavaScript (Playwright):**
```javascript
import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
await browser.close();
```
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
**For sites with anti-bot protection**, add a residential proxy and these flags:
```python
browser = launch(
proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter
geoip=True, # match timezone + locale to proxy IP
headless=False, # some sites detect headless even with C++ patches
humanize=True, # human-like mouse, keyboard, scroll
)
```
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA).
## Install
**Python:**
```bash
pip install cloakbrowser
```
**JavaScript / Node.js:**
```bash
# With Playwright
npm install cloakbrowser playwright-core
@@ -89,6 +121,7 @@ npm install cloakbrowser puppeteer-core
On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally).
**Optional:** Auto-detect timezone/locale from proxy IP:
```bash
pip install cloakbrowser[geoip]
```
@@ -109,16 +142,25 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.11 (Chromium 145.0.7632.159.2)
---
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **`cloakserve` CDP server** — no longer requires socat. Chrome binds directly to `0.0.0.0:9222` via native flag support
- **31 fingerprint patches** (Linux x64) — 5 new patches since v0.3.10, plus GPU fingerprint accuracy fixes for NVIDIA and Apple Silicon profiles
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
## Latest: v0.4.0 — CloakBrowser Pro (Chromium 148.0.7778.215.2)
- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.2, 59 source-level patches) is now available to Pro subscribers; v146 stays free forever. Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
- **HTTP proxy inline credentials** — new network-layer support for proxies with inline authentication
- **`extension_paths`** — load Chrome extensions in all launch functions
- **Humanize actionability** — auto-wait for visible, enabled, stable elements before humanized actions
- **Per-call `human_config`** — override humanize settings on individual method calls
- **Composable JS helpers** — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -129,13 +171,35 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
- **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, Scrapling, Stagehand, LangChain, Selenium, and more. See [integrations](#framework-integrations).
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
## CloakBrowser Pro
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
free-release model:
- **Free (v146)** — the previous binary, on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Goes stale within weeks as detection evolves.
- **Pro (latest, Chromium 148.0.7778.215.2)** — the newest patches and Chromium upgrades first, so the [results below](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
Anti-bot detection updates constantly, and an older binary degrades fast.
Pro keeps you on the build that's actively maintained against it.
Use Pro if CloakBrowser is part of production scraping, QA, monitoring, or
automation where stale browser fingerprints cost you time or blocked runs.
Activate with your license key (env var, `license_key=` param, or `~/.cloakbrowser/license.key`):
```bash
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
```
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
## Test Results
All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---|
@@ -150,7 +214,7 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
| `navigator.webdriver` | `true` | **`false`** | Source-level patch |
| `navigator.plugins.length` | 0 | **5** | Real plugin list |
| `window.chrome` | `undefined` | **`object`** | Present like real Chrome |
| UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| UA string | `HeadlessChrome` | **`Chrome/146.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
| | | **Tested against 30+ detection sites** | |
@@ -199,15 +263,15 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Chromium 145)
2. **First launch** → binary auto-downloads for your platform (Chromium 146)
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 31 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
The binary includes 58 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
Binary downloads are verified with SHA-256 checksums to ensure integrity.
Binary downloads are verified against a pinned Ed25519 signature on the published checksums before extraction, so the download is confirmed authentic (genuinely ours) and not just intact. A compromised mirror cannot serve a tampered or downgraded binary.
## API
@@ -222,8 +286,12 @@ browser = launch()
# Headed mode (see the browser window)
browser = launch(headless=False)
# With proxy
# Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
browser = launch(license_key="cb_xxxxxxxx")
# With proxy (HTTP or SOCKS5)
browser = launch(proxy="http://user:pass@proxy:8080")
browser = launch(proxy="socks5://user:pass@proxy:1080")
# With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
@@ -231,15 +299,23 @@ browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com",
# With extra Chrome args
browser = launch(args=["--disable-gpu"])
# With timezone and locale (sets both binary flags and Playwright context)
# With timezone and locale (sets binary flags — no detectable CDP emulation)
browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost)
# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com)
browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"])
# Explicit WebRTC IP (no network call)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"])
# Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True)
@@ -286,15 +362,49 @@ page.goto("https://protected-site.com")
context.close()
```
Extra kwargs are forwarded to Playwright's `browser.new_context()` — use this for `storage_state`, `permissions`, `extra_http_headers`, etc. without needing a persistent profile folder:
```python
from cloakbrowser import launch_context
# Restore a saved session (cookies, localStorage) from a JSON file
context = launch_context(storage_state="state.json")
page = context.new_page()
page.goto("https://example.com")
# Save state back for next run
context.storage_state(path="state.json")
context.close()
```
### `launch_context_async()`
Async counterpart to `launch_context()`. Same signature and kwargs forwarding:
```python
import asyncio
from cloakbrowser import launch_context_async
async def main():
ctx = await launch_context_async(storage_state="state.json")
page = await ctx.new_page()
await page.goto("https://example.com")
await ctx.storage_state(path="state.json")
await ctx.close()
asyncio.run(main())
```
### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts)
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm))
```python
from cloakbrowser import launch_persistent_context
@@ -307,12 +417,61 @@ ctx.close() # profile saved
# Next run — cookies, localStorage restored automatically
ctx = launch_persistent_context("./my-profile", headless=False)
# Load Chrome extensions
ctx = launch_persistent_context(
"./my-profile",
headless=False,
extension_paths=["./my-extension"],
)
```
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`.
Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`, `extension_paths`.
Async version: `launch_persistent_context_async()`.
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
```python
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
```
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|---|---|---|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### Widevine / DRM
The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support as a real-browser signal.
```python
from cloakbrowser import launch_persistent_context
# WidevineCdm sideloaded next to the binary -> Widevine works on first launch
ctx = launch_persistent_context("./my-profile", headless=False)
```
- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there.
- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`.
### CLI
Pre-download the binary or check installation status from the command line:
```bash
python -m cloakbrowser install # Download binary with progress output
python -m cloakbrowser info # Show version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
```
### Utility Functions
```python
@@ -320,7 +479,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status
print(binary_info())
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
# {'version': '146.0.7680.177.5', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download
clear_cache()
@@ -341,6 +500,9 @@ import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// Basic
const browser = await launch();
// Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var)
const browser = await launch({ licenseKey: 'cb_xxxxxxxx' });
// With options
const browser = await launch({
headless: false,
@@ -402,7 +564,7 @@ clearCache();
## Human Behavior
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls `page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, and the full Locator API are automatically replaced with human-like equivalents. No code changes needed.
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed.
```python
browser = launch(humanize=True)
@@ -413,6 +575,14 @@ page.locator("button[type=submit]").click() # Bézier curve, realistic aim
```
```javascript
// Playwright
import { launch } from 'cloakbrowser';
const browser = await launch({ humanize: true });
```
```javascript
// Puppeteer
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch({ humanize: true });
```
@@ -461,7 +631,11 @@ const browser = await launch({
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
> **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve.
>
> **Note (Puppeteer):** Both selector-based methods (`page.click()`, `page.type()`) and ElementHandle methods (`el.click()`, `el.type()`) are fully humanized. `page.$()`, `page.$$()`, and `page.waitForSelector()` return patched handles automatically.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright and Puppeteer API coverage.
## Configuration
@@ -471,7 +645,10 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Only applies to a custom `CLOAKBROWSER_DOWNLOAD_URL`: set to `true` to skip its checksum check. Signature verification on the official download path is mandatory and cannot be skipped. |
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
## Fingerprint Management
@@ -488,9 +665,11 @@ The binary is **stealthy by default** — no flags needed. It auto-generates a r
The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly.
> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor:
>
> ```python
> browser = launch(args=["--fingerprint=12345"])
> ```
>
> ```javascript
> const browser = await launch({ args: ['--fingerprint=12345'] });
> ```
@@ -503,14 +682,10 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|------|--------------|---------------|----------|
| `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects |
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
The binary auto-generates everything else from the seed: GPU, hardware concurrency, device memory, and screen dimensions. Each seed produces a unique, consistent fingerprint. Override with explicit flags if needed.
> **Using the binary directly?** It works out of the box with zero flags the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
> **Using the binary directly?** It works out of the box with zero flags -- the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
### Additional Flags
@@ -518,6 +693,8 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| Flag | Controls |
|------|----------|
| `--fingerprint-gpu-vendor` | WebGL `UNMASKED_VENDOR_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-gpu-renderer` | WebGL `UNMASKED_RENDERER_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
@@ -527,12 +704,43 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) |
| `--fingerprint-windows-font-metrics` | Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
### Font Setup on Linux
**Required for aggressive anti-bot sites (Kasada, Akamai).** These systems render emoji on a hidden canvas and hash the pixel output. Minimal Linux environments (Docker, cloud VMs) often lack emoji and extended fonts, producing hashes that don't match any real browser. Install standard font packages to fix this:
```bash
sudo apt install -y fonts-noto-color-emoji fonts-freefont-ttf fonts-unifont \
fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf
```
The Docker image (`cloakhq/cloakbrowser`) ships with these pre-installed. If you run the binary directly on a Linux server or in a custom Docker image, install them manually.
**Optional: Windows fonts for CreepJS font enumeration.** The packages above fix anti-bot canvas checks but won't improve your CreepJS font score. For that, you need actual Windows fonts (Segoe UI, Calibri, Bahnschrift, etc.) from a Windows machine's `C:\Windows\Fonts\` directory — `ttf-mscorefonts-installer` only has old XP-era fonts and isn't enough.
```bash
mkdir -p ~/.local/share/fonts/windows
cp /path/to/windows/fonts/*.ttf ~/.local/share/fonts/windows/
cp /path/to/windows/fonts/*.TTF ~/.local/share/fonts/windows/
fc-cache -f # mandatory for manually copied fonts
```
```python
browser = launch(
args=["--fingerprint-fonts-dir=/home/user/.local/share/fonts/windows"],
)
```
### Examples
```python
@@ -543,11 +751,9 @@ browser = launch(args=["--fingerprint=42069"])
browser = launch(stealth_args=False, args=[
"--fingerprint=42069",
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
])
# Override GPU to look like a different machine
# Override GPU to look like a specific machine
browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.",
"--fingerprint-gpu-renderer=Intel Iris OpenGL Engine",
@@ -557,6 +763,7 @@ browser = launch(args=[
## Examples
**Python** — see [`examples/`](examples/):
- [`basic.py`](examples/basic.py) — Launch and load a page
- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence
- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score
@@ -564,18 +771,63 @@ browser = launch(args=[
- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS
**JavaScript** — see [`js/examples/`](js/examples/):
- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
### Framework Integrations
CloakBrowser works with any framework that uses Playwright or Chromium:
```python
# Option 1: Framework launches our binary directly (Selenium, Stagehand, UC)
from cloakbrowser.download import ensure_binary
from cloakbrowser.config import get_default_stealth_args
binary_path = ensure_binary() # auto-downloads if needed
stealth_args = get_default_stealth_args() # all fingerprint flags
# Option 2: CloakBrowser launches first, framework connects via CDP (browser-use, Crawl4AI, Scrapling)
from cloakbrowser import launch_async
browser = await launch_async(args=["--remote-debugging-port=9242"])
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set
# Note: humanize requires the wrapper (see below)
```
> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization:
>
> ```js
> import { patchBrowser, resolveConfig } from 'cloakbrowser/human';
> patchBrowser(browser, resolveConfig('default'));
> ```
| Framework | Stars | Language | Example |
|-----------|-------|----------|---------|
| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) |
| [Crawl4AI](https://github.com/unclecode/crawl4ai) | 58K | Python | [`crawl4ai_example.py`](examples/integrations/crawl4ai_example.py) |
| [Crawlee](https://github.com/apify/crawlee-python) | 8.6K | Python | [`crawlee_example.py`](examples/integrations/crawlee_example.py) |
| [Scrapling](https://github.com/D4Vinci/Scrapling) | 21K | Python | [`scrapling_example.py`](examples/integrations/scrapling_example.py) |
| [Stagehand](https://github.com/browserbase/stagehand) | 21K | TypeScript | [`stagehand.ts`](js/examples/stagehand.ts) |
| [LangChain](https://github.com/langchain-ai/langchain) | 100K+ | Python | [`langchain_loader.py`](examples/integrations/langchain_loader.py) |
| [Selenium](https://github.com/SeleniumHQ/selenium) | — | Python | [`selenium_example.py`](examples/integrations/selenium_example.py) |
| [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) | 12K | Python | [`undetected_chromedriver.py`](examples/integrations/undetected_chromedriver.py) |
| [agent-browser](https://github.com/nichochar/agent-browser) | — | Shell | [`agent_browser.sh`](examples/integrations/agent_browser.sh) |
### Deployment Integrations
| Platform | Example |
|----------|---------|
| [AWS Lambda](https://aws.amazon.com/lambda/) | [`aws_lambda/`](examples/integrations/aws_lambda/) — One-shot scrapes in Lambda (container image) |
## Platforms
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 31 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
| Linux x86_64 | 146 | 58 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 146 | 58 | ✅ Latest |
The wrapper auto-downloads the correct binary for your platform.
@@ -585,6 +837,8 @@ The wrapper auto-downloads the correct binary for your platform.
Pre-built image on Docker Hub — no install, no setup.
> **Pro:** the image ships with the free binary. Set `CLOAKBROWSER_LICENSE_KEY` (e.g. `-e CLOAKBROWSER_LICENSE_KEY=cb_xxx`, or in Compose) and the latest binary downloads at runtime.
### Quick test
```bash
@@ -639,6 +893,26 @@ print(page.title())
browser.close()
```
If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact:
```bash
curl http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/devtools/browser/<browser-id>
curl 'http://localhost:9222/json/version?fingerprint=11111' | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/fingerprint/11111/devtools/browser/<browser-id>
```
When `cloakserve` runs behind a reverse proxy or TLS terminator, forward the public host/protocol headers so generated WebSocket URLs use the address clients can actually reach:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
```
With those headers, `/json/version` returns public endpoints such as `wss://cdp.example.com/fingerprint/11111/devtools/browser/<browser-id>` instead of an internal container host.
Pass extra flags to the browser:
```bash
@@ -649,6 +923,10 @@ docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
# Reap disconnected per-seed browser processes after 5 minutes
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --idle-timeout=300
```
Stop the server:
@@ -679,7 +957,30 @@ services:
start_period: 10s
```
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`).
**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint:
```python
# Each seed gets unique canvas noise, client rects, and other browser signals
b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111")
b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222")
# Full identity control via query params
b3 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=33333"
"&timezone=Asia/Tokyo&locale=ja-JP&platform=macos"
"&hardware-concurrency=4&device-memory=8"
)
# Auto-detect timezone/locale from proxy exit IP
b4 = pw.chromium.connect_over_cdp(
"http://localhost:9222?fingerprint=44444"
"&proxy=http://proxy:8080&geoip=true"
)
```
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:
@@ -705,6 +1006,15 @@ COPY your_script.py /app/
CMD ["python", "your_script.py"]
```
**Building your own image from pip** — use `python -m cloakbrowser install` to download the binary during build with visible progress:
```dockerfile
FROM python:3.12-slim
RUN pip install cloakbrowser && python -m cloakbrowser install
COPY your_script.py /app/
CMD ["python", "/app/your_script.py"]
```
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
```bash
@@ -717,7 +1027,9 @@ CloakBrowser works identically local, in Docker, and on VPS. No environment-spec
## Troubleshooting
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
---
### Still getting blocked on aggressive sites (DataDome, Turnstile)?
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
@@ -740,9 +1052,97 @@ page.goto("https://heavily-protected-site.com") # passes DataDome, etc.
browser.close()
```
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combine with the recommended config below for maximum stealth.
**Sites challenge fresh sessions but work after first visit**
---
### Recommended config for anti-bot sites
Most blocks come from missing one of these three things, not from browser fingerprint detection:
```python
browser = launch(
proxy="http://your-residential-proxy:port", # residential IP — datacenter IPs get blocked by reputation alone
geoip=True, # matches timezone + locale to proxy exit IP (without this: UTC + en-US = bot signal)
headless=False, # headed mode — some sites detect headless even with C++ patches
humanize=True, # human-like mouse, keyboard, scroll behavior
)
```
```javascript
const browser = await launch({
proxy: 'http://your-residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
If your proxy supports SOCKS5, use it for better compatibility — SOCKS5 tunnels raw TCP, avoiding HTTP CONNECT issues that some proxies have with HTTP/2:
```python
browser = launch(proxy="socks5://user:pass@proxy:1080", geoip=True, headless=False, humanize=True)
```
If you're still blocked after this, check the font setup below.
---
### Detected by FingerprintJS?
FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause:
| Detection | Cause | Fix |
|-----------|-------|-----|
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (requires Windows fonts installed) |
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
```python
browser = launch(
headless=False,
proxy="http://user:pass@residential-proxy:port",
geoip=True,
args=[
"--fingerprint-noise=false", # prevents tampering detection
"--fingerprint-windows-font-metrics", # align font metrics (requires Windows fonts)
],
)
```
```javascript
const browser = await launch({
headless: false,
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
args: [
'--fingerprint-noise=false',
'--fingerprint-windows-font-metrics', // align font metrics (requires Windows fonts)
],
});
```
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. With quota alone you can't satisfy both — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
**Resolving the tradeoff (Linux):** Sideloading the Widevine CDM lets a persistent context pass FPJS at a higher quota, so you can satisfy both at once. See [Widevine / DRM](#widevine--drm).
---
### Blocked on Kasada / Akamai sites despite correct config?
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
Install the font packages listed in [Font Setup on Linux](#font-setup-on-linux) above.
---
### Sites challenge fresh sessions but work after first visit
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
@@ -774,74 +1174,87 @@ await ctx.close();
ctx = await launchPersistentContext({ userDataDir: './profile' });
```
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. If your proxy supports SOCKS5, use `proxy="socks5://user:pass@host:port"` instead — SOCKS5 bypasses HTTP CONNECT entirely.
---
### Something not working? Make sure you're on the latest version
**Something not working? Make sure you're on the latest version**
Older versions may use outdated stealth args or download an older binary:
```bash
pip install -U cloakbrowser # Python
npm install cloakbrowser@latest # JavaScript
docker pull cloakhq/cloakbrowser:latest # Docker
```
**Binary download fails / timeout**
---
### Binary download fails / timeout
Set a custom download URL or use a local binary:
```bash
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
```
**New update broke something? Roll back to the previous version**
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
---
### New update broke something? Roll back to the previous version
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
pip install cloakbrowser==0.3.21 # Python
npm install cloakbrowser@0.3.21 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.21 # Docker
```
**macOS: "App is damaged" or Gatekeeper blocks launch**
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
---
### macOS: "App is damaged" or Gatekeeper blocks launch
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
```bash
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
```
**"playwright install" vs CloakBrowser binary**
---
### "playwright install" vs CloakBrowser binary
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash
playwright install-deps chromium
```
**macOS: Blocked on some sites that pass on Linux**
---
### macOS: Blocked on some sites that pass on Linux
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
**Site detects incognito / private browsing mode**
---
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
### Site detects incognito / private browsing mode
By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence:
```python
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
```
```javascript
import { launchPersistentContext } from 'cloakbrowser';
If the site still flags incognito, raise the storage quota to appear as a regular browsing session. See the [storage quota tradeoff](#launch_persistent_context) for details on how this affects different detection services.
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
---
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)**
### reCAPTCHA v3 scores are low (0.10.3)
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
@@ -863,16 +1276,18 @@ await new Promise(r => setTimeout(r, 3000));
```
Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management))
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
```python
page.type("#email", "user@example.com", delay=50)
```
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
## FAQ
@@ -880,6 +1295,15 @@ Other tips for maximizing reCAPTCHA scores:
**Q: Is this legal?**
A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
**Q: Is CloakBrowser free?**
A: The wrapper (Python + JS) is MIT and free forever. The binary uses a delayed free-release model: the previous Chromium major version (currently v146) is free on GitHub Releases with unlimited sessions; the latest major version is for [Pro subscribers](https://cloakbrowser.dev). Each new major release rolls the prior major version down to free.
**Q: Do I need a license key for the free version?**
A: No. The free binary downloads automatically with no key. A license key only unlocks the latest (Pro) binary.
**Q: What happens if I cancel Pro?**
A: Your subscription stays active until the end of the current billing period — cancelling doesn't cut you off immediately. After it ends, the wrapper stops pulling new Pro versions and falls back to the free binary on its next license check (cached ~24h). You just stop getting new versions.
**Q: How is this different from Camoufox?**
A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready.
@@ -887,15 +1311,15 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
**Q: Can I use my own proxy?**
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
## Roadmap
| Feature | Status |
|---------|--------|
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
| Linux x64 — Chromium 146 (58 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 146 (58 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned |
@@ -907,25 +1331,35 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- 📧 **Contact** — cloakhq@pm.me
- **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
- 📧 **Contact** — <cloakhq@pm.me>
## Security
All binary releases are GPG-signed and include GitHub artifact attestations for supply chain verification.
The wrapper automatically verifies every binary download against a pinned Ed25519 signature on the published checksums before extraction — a compromised mirror cannot serve a tampered or downgraded binary. Releases are additionally signed for manual supply chain verification:
```bash
# Verify GPG signature
# Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.3
git verify-tag chromium-v146.0.7680.177.5
# Verify binary attestation
# Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
# Verify Docker image signature (Cosign/Sigstore)
cosign verify \
--certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
cloakhq/cloakbrowser:latest
```
## License
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
- **CloakBrowser binary** (compiled Chromium):
- **v146 and earlier** — free for personal and commercial use, no redistribution (OEM/SaaS license required to serve third parties).
- **v148+ (latest)** — requires an active [CloakBrowser Pro](https://cloakbrowser.dev) subscription to download.
- See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms.
## Contributing
@@ -935,3 +1369,17 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening
- [@dgtlmoon](https://github.com/dgtlmoon) — graceful pw.stop() cleanup
- [@zackycodes](https://github.com/zackycodes) — Chrome extension loading
- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps)
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
- [@245678000000](https://github.com/245678000000) — package-lock sync
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, CDP WebSocket URL rewrite, composable JS launch helpers
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
- [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
+857 -27
View File
@@ -1,31 +1,56 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium.
Spawns a separate Chrome process per unique fingerprint seed, routing CDP
connections through a single port. Each seed gets its own browser identity.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
cloakserve # default, backward compat
cloakserve --port=9222 # custom port
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
Client:
browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345")
browser = pw.chromium.connect_over_cdp(
"http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US"
)
"""
import signal
from __future__ import annotations
import asyncio
import ipaddress
import json
import logging
import os
import random
import re
import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from urllib.parse import parse_qs, urlparse
from cloakbrowser.config import get_default_stealth_args
from pathlib import Path
import aiohttp
import websockets
from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args, _normalize_socks_string_url
from cloakbrowser.download import ensure_binary
PORT = 9222
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("cloakserve")
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=0.0.0.0",
# Sane defaults for running Chrome directly (outside Playwright)
# Args for running Chrome directly (outside Playwright).
# Playwright normally adds its own version of these.
BASE_CHROME_ARGS = [
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
@@ -33,21 +58,826 @@ chrome_args = [
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
"--ignore-gpu-blocklist",
]
chrome = subprocess.Popen(chrome_args)
BASE_CDP_PORT = 5100
time.sleep(2)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
RESERVED_SEEDS = {"__default__"}
TRUSTED_WS_ORIGINS = {"devtools://devtools", "chrome-devtools://devtools"}
def cleanup(sig, frame):
chrome.terminate()
sys.exit(0)
def _host_port_from_netloc(netloc: str, default_port: int) -> tuple[str, int] | None:
"""Return a normalized (host, port) pair for an Origin/Host netloc."""
if "," in netloc:
return None
try:
parsed = urlparse(f"//{netloc.strip()}")
authority = parsed.netloc.rsplit("@", 1)[-1]
if (
not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or authority.endswith(":")
or parsed.path
or parsed.params
or parsed.query
or parsed.fragment
):
return None
return (parsed.hostname.lower(), parsed.port if parsed.port is not None else default_port)
except ValueError:
return None
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
def _is_loopback_host(hostname: str) -> bool:
"""Return True for localhost and loopback IP literals."""
hostname = hostname.strip("[]").rstrip(".").lower()
if hostname == "localhost":
return True
try:
return ipaddress.ip_address(hostname).is_loopback
except ValueError:
return False
chrome.wait()
def _origin_is_allowed(
origin: str | None,
host: str | None,
request_scheme: str = "http",
) -> bool:
"""Return True when a WebSocket Origin is safe to proxy to local CDP."""
if origin is None:
# Playwright/Puppeteer and other non-browser CDP clients commonly omit
# Origin. Keep those clients working while rejecting browser-origin CSRF.
return True
origin = origin.strip()
if not origin or origin.lower() == "null":
return False
if origin in TRUSTED_WS_ORIGINS:
return True
try:
parsed = urlparse(origin)
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.path or parsed.params or parsed.query or parsed.fragment:
return False
origin_default_port = 443 if parsed.scheme == "https" else 80
request_scheme = request_scheme.split(",", 1)[0].strip().lower()
request_default_port = 443 if request_scheme in ("https", "wss") else 80
origin_host = _host_port_from_netloc(parsed.netloc, origin_default_port)
request_host = _host_port_from_netloc(host or "", request_default_port)
if origin_host is None or request_host is None:
return False
if not _is_loopback_host(request_host[0]):
return False
return origin_host == request_host
def _reject_untrusted_origin(request: web.Request) -> web.Response | None:
"""Reject browser-origin WebSocket upgrades that would expose local CDP."""
origin = request.headers.get("Origin")
host = request.headers.get("Host")
scheme = request.headers.get("X-Forwarded-Proto", getattr(request, "scheme", "http"))
if _origin_is_allowed(origin, host, request_scheme=scheme):
return None
logger.warning("Rejected CDP WebSocket from untrusted Origin %r for Host %r", origin, host)
return web.Response(status=403, text="Forbidden: untrusted WebSocket origin\n")
# ---------------------------------------------------------------------------
# ChromeProcess — one running Chrome instance
# ---------------------------------------------------------------------------
@dataclass
class ChromeProcess:
seed: str
process: subprocess.Popen
cdp_port: int
user_data_dir: str
timezone: str | None = None
locale: str | None = None
proxy: str | None = None
# ---------------------------------------------------------------------------
# ChromePool — manages multiple Chrome processes keyed by seed
# ---------------------------------------------------------------------------
class ChromePool:
def __init__(
self,
binary: str,
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
idle_timeout: float = 0.0,
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._idle_timeout = idle_timeout
self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {}
self._next_port = BASE_CDP_PORT
# Connection refcounting for status reporting
self._connections: dict[str, int] = {}
self._idle_tasks: dict[str, asyncio.Task] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
self._locks[seed] = asyncio.Lock()
return self._locks[seed]
def _safe_rmtree(self, path: str) -> None:
resolved = Path(path).resolve()
data_resolved = Path(self._data_dir).resolve()
if resolved == data_resolved or not resolved.is_relative_to(data_resolved):
logger.error("Refusing to delete path outside data_dir: %s", resolved)
return
shutil.rmtree(path, True)
def _allocate_port(self) -> int:
"""Find a free port starting from _next_port."""
for _ in range(100):
port = self._next_port
self._next_port += 1
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError("No free ports available for Chrome CDP")
def connect(self, seed_key: str) -> None:
"""Increment connection refcount for a seed."""
self._cancel_idle_cleanup(seed_key)
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
def disconnect(self, seed_key: str) -> None:
"""Decrement connection refcount for a seed."""
count = self._connections.get(seed_key, 0) - 1
if count <= 0:
self._connections.pop(seed_key, None)
self._schedule_idle_cleanup(seed_key)
else:
self._connections[seed_key] = count
def _cancel_idle_cleanup(self, seed_key: str) -> None:
task = self._idle_tasks.pop(seed_key, None)
if task is None or task.done():
return
try:
current_task = asyncio.current_task()
except RuntimeError:
current_task = None
if task is not current_task:
task.cancel()
def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None:
if self._idle_tasks.get(seed_key) is task:
self._idle_tasks.pop(seed_key, None)
def _schedule_idle_cleanup(self, seed_key: str) -> None:
if self._idle_timeout <= 0 or seed_key not in self._processes:
return
self._cancel_idle_cleanup(seed_key)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(
self._cleanup_after_idle(seed_key, self._idle_timeout),
name=f"cloakserve-idle-cleanup-{seed_key}",
)
self._idle_tasks[seed_key] = task
task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task))
async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None:
try:
await asyncio.sleep(timeout)
if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes:
return
logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key)
await self._cleanup_process(seed_key)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Idle cleanup failed for seed=%s", seed_key)
async def get_or_launch(
self,
seed: str | None,
extra_args: list[str] | None = None,
timezone: str | None = None,
locale: str | None = None,
proxy: str | None = None,
geoip: bool = False,
) -> ChromeProcess:
"""Get existing or launch new Chrome process for a seed."""
# Apply CLI defaults when query params don't provide values
if seed is None and self._default_seed:
seed = self._default_seed
if locale is None:
locale = self._default_locale
if timezone is None:
timezone = self._default_timezone
# No seed = default shared process
if seed is None:
seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999))
else:
if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS:
raise web.HTTPBadRequest(
text=json.dumps({"error": "Invalid fingerprint seed"}),
content_type="application/json",
)
seed_key = seed
actual_seed = seed
lock = self._get_lock(seed_key)
async with lock:
# Check if already running (including default fast-path)
if seed_key in self._processes:
proc = self._processes[seed_key]
if proc.process.poll() is None:
if seed_key in self._idle_tasks:
self._schedule_idle_cleanup(seed_key)
if any([extra_args, timezone, locale, proxy, geoip]):
logger.warning(
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
"ignoring new params (first-launch wins)",
seed_key, proc.cdp_port,
proc.timezone, proc.locale, proc.proxy,
)
return proc
# Dead — clean up
await self._cleanup_process(seed_key)
# Resolve geoip if requested
exit_ip = None
if geoip and proxy:
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
# Build Chrome args via shared logic
fp_extra = [f"--fingerprint={actual_seed}"]
if extra_args:
fp_extra.extend(extra_args)
if proxy:
fp_extra.append(f"--proxy-server={_normalize_socks_string_url(proxy)}")
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
fp_extra = list(fp_extra or [])
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(
stealth_args=True,
extra_args=fp_extra,
timezone=timezone,
locale=locale,
headless=self._headless,
)
# Allocate port and user data dir
port = self._allocate_port()
user_data_dir = os.path.join(self._data_dir, seed_key)
os.makedirs(user_data_dir, exist_ok=True)
full_args = (
[self._binary]
+ BASE_CHROME_ARGS
+ chrome_args
+ self._global_args
+ [
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={user_data_dir}",
]
)
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
process = subprocess.Popen(
full_args,
stdout=subprocess.DEVNULL,
)
# Wait for CDP to be ready
if not await self._wait_for_cdp(port):
process.kill()
await asyncio.to_thread(process.wait, timeout=5)
await asyncio.to_thread(self._safe_rmtree, user_data_dir)
raise web.HTTPBadGateway(
text=json.dumps({"error": "Chrome failed to start"}),
content_type="application/json",
)
cp = ChromeProcess(
seed=actual_seed,
process=process,
cdp_port=port,
user_data_dir=user_data_dir,
timezone=timezone,
locale=locale,
proxy=proxy,
)
self._processes[seed_key] = cp
if seed is None:
self._default = cp
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
return cp
async def _cleanup_process(self, key: str) -> None:
"""Terminate a Chrome process and clean up."""
self._cancel_idle_cleanup(key)
proc = self._processes.pop(key, None)
if not proc:
return
if proc.process.poll() is None:
proc.process.terminate()
try:
await asyncio.to_thread(proc.process.wait, timeout=5)
except subprocess.TimeoutExpired:
proc.process.kill()
await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
if self._default is proc:
self._default = None
self._locks.pop(key, None)
self._connections.pop(key, None)
async def shutdown(self) -> None:
"""Terminate all Chrome processes."""
idle_tasks = list(self._idle_tasks.values())
self._idle_tasks.clear()
for task in idle_tasks:
if not task.done():
task.cancel()
if idle_tasks:
await asyncio.gather(*idle_tasks, return_exceptions=True)
for key in list(self._processes.keys()):
await self._cleanup_process(key)
logger.info("All Chrome processes terminated")
@staticmethod
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
"""Poll Chrome's /json/version until ready."""
deadline = time.monotonic() + timeout
delay = 0.1
session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=1)
)
try:
while time.monotonic() < deadline:
try:
async with session.get(
f"http://127.0.0.1:{port}/json/version"
) as resp:
if resp.status == 200:
return True
except Exception:
pass
await asyncio.sleep(delay)
delay = min(delay * 2, 1.0)
return False
finally:
await session.close()
# ---------------------------------------------------------------------------
# Query param parsing
# ---------------------------------------------------------------------------
# Params that need special handling (not simple --fingerprint-{name}= mapping)
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
def parse_connection_params(query_string: str) -> dict:
"""Parse query params into connection config."""
qs = parse_qs(query_string, keep_blank_values=False)
result: dict = {
"seed": None,
"timezone": None,
"locale": None,
"proxy": None,
"geoip": False,
"extra_args": [],
}
for key, values in qs.items():
val = values[0]
if key == "fingerprint":
result["seed"] = val
elif key == "timezone":
result["timezone"] = val
elif key == "locale":
result["locale"] = val
elif key == "proxy":
result["proxy"] = val
elif key == "geoip":
result["geoip"] = val.lower() in ("true", "1", "yes")
elif key not in SPECIAL_PARAMS:
# Generic fingerprint param: map to --fingerprint-{key}={val}
result["extra_args"].append(f"--fingerprint-{key}={val}")
return result
# ---------------------------------------------------------------------------
# HTTP handlers
# ---------------------------------------------------------------------------
def _ws_scheme(request: web.Request) -> str:
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
proto = proto.split(",", 1)[0].strip().lower()
return "wss" if proto == "https" else "ws"
def _external_host(request: web.Request) -> str:
"""Return the public host to use in rewritten CDP WebSocket URLs."""
fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}"
forwarded_host = request.headers.get("X-Forwarded-Host")
if forwarded_host:
public_host = forwarded_host.split(",", 1)[0].strip()
if public_host:
return public_host
return fallback_host
async def handle_root(request: web.Request) -> web.Response:
"""Health check / process status."""
pool: ChromePool = request.app["pool"]
processes = {}
for key, proc in pool._processes.items():
if proc.process.poll() is None:
processes[key] = {
"pid": proc.process.pid,
"port": proc.cdp_port,
"seed": proc.seed,
"connections": pool._connections.get(key, 0),
"idle_cleanup_pending": key in pool._idle_tasks,
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
}
return web.json_response({
"status": "ok",
"active": len(processes),
"idle_timeout": pool._idle_timeout,
"processes": processes,
})
async def handle_json_version(request: web.Request) -> web.Response:
"""Proxy /json/version with optional per-seed routing."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/version",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as 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)
# Rewrite webSocketDebuggerUrl to route through our multiplexer
host = _external_host(request)
seed_key = params["seed"]
if seed_key:
ws_path = f"fingerprint/{seed_key}/devtools/browser"
else:
ws_path = "devtools/browser"
# Extract the browser GUID from Chrome's original URL
orig_ws = data.get("webSocketDebuggerUrl", "")
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
scheme = _ws_scheme(request)
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
return web.json_response(data)
async def handle_json_list(request: web.Request) -> web.Response:
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
pool: ChromePool = request.app["pool"]
params = parse_connection_params(request.query_string)
cp = await pool.get_or_launch(
seed=params["seed"],
extra_args=params["extra_args"] or None,
timezone=params["timezone"],
locale=params["locale"],
proxy=params["proxy"],
geoip=params["geoip"],
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://127.0.0.1:{cp.cdp_port}/json/list",
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
data = await resp.json()
except Exception as 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)
host = _external_host(request)
scheme = _ws_scheme(request)
seed_key = params["seed"]
for entry in data:
if "webSocketDebuggerUrl" in entry:
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
if seed_key:
entry["webSocketDebuggerUrl"] = (
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
)
else:
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
return web.json_response(data)
# ---------------------------------------------------------------------------
# WebSocket proxy
# ---------------------------------------------------------------------------
async def proxy_cdp_websocket(
client_ws: web.WebSocketResponse,
target_url: str,
label: str,
) -> None:
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
try:
async with websockets.connect(
target_url, max_size=None, ping_interval=None, ping_timeout=None,
) as cdp_ws:
logger.info("%s: connected to %s", label, target_url)
async def client_to_cdp():
try:
async for msg in client_ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await cdp_ws.send(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await cdp_ws.send(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
break
except Exception as exc:
logger.debug("%s [c->cdp]: %s", label, exc)
async def cdp_to_client():
try:
async for msg in cdp_ws:
if isinstance(msg, str):
await client_ws.send_str(msg)
else:
await client_ws.send_bytes(msg)
except Exception as exc:
logger.debug("%s [cdp->c]: %s", label, exc)
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
done, pending = await asyncio.wait(
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
logger.info("%s: disconnected", label)
except Exception as exc:
logger.error("%s error: %s", label, exc)
async def handle_ws_default(request: web.Request) -> web.StreamResponse:
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
rejected = _reject_untrusted_origin(request)
if rejected is not None:
return rejected
pool: ChromePool = request.app["pool"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=None)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect("__default__")
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
finally:
pool.disconnect("__default__")
return ws
async def handle_ws_seed(request: web.Request) -> web.StreamResponse:
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
rejected = _reject_untrusted_origin(request)
if rejected is not None:
return rejected
pool: ChromePool = request.app["pool"]
seed = request.match_info["seed"]
path = request.match_info.get("path", "")
cp = await pool.get_or_launch(seed=seed)
ws = web.WebSocketResponse()
await ws.prepare(request)
pool.connect(seed)
try:
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
finally:
pool.disconnect(seed)
return ws
async def on_shutdown(app: web.Application) -> None:
await app["pool"].shutdown()
# ---------------------------------------------------------------------------
# CLI arg parsing
# ---------------------------------------------------------------------------
def _default_data_dir() -> str:
"""Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "/tmp/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]]:
"""Parse cloakserve-specific args, return (config, passthrough_args).
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
extracted into config defaults so they route through build_args()
(e.g. locale needs both --lang and --fingerprint-locale).
Query-string params override these defaults per-connection.
"""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
"default_seed": None,
"default_locale": None,
"default_timezone": None,
"idle_timeout": _default_idle_timeout(),
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--idle-timeout=",
"--remote-debugging-port=",
"--remote-debugging-address=",
)
for arg in argv:
if arg.startswith("--port="):
config["port"] = int(arg.split("=", 1)[1])
elif arg.startswith("--data-dir="):
config["data_dir"] = arg.split("=", 1)[1]
elif arg.startswith("--idle-timeout="):
config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1])
elif arg == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
elif arg.startswith(consumed_prefixes):
pass # Strip these silently
# Route through build_args() so companion flags are set correctly
elif arg.startswith("--fingerprint-locale="):
config["default_locale"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint-timezone="):
config["default_timezone"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint="):
config["default_seed"] = arg.split("=", 1)[1]
else:
passthrough.append(arg)
if config["data_dir"] is None:
config["data_dir"] = _default_data_dir()
return config, passthrough
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
binary = ensure_binary()
config, global_args = parse_cli_args(sys.argv[1:])
if config["default_seed"] and (
not SAFE_SEED_RE.match(config["default_seed"])
or config["default_seed"] in RESERVED_SEEDS
):
logger.error("Invalid --fingerprint seed: %s", config["default_seed"])
sys.exit(1)
pool = ChromePool(
binary=binary,
global_args=global_args,
headless=config["headless"],
data_dir=config["data_dir"],
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
idle_timeout=config["idle_timeout"],
)
app = web.Application()
app["pool"] = pool
app["port"] = config["port"]
# Routes
app.router.add_get("/", handle_root)
app.router.add_get("/json/version", handle_json_version)
app.router.add_get("/json/version/", handle_json_version)
app.router.add_get("/json/list", handle_json_list)
app.router.add_get("/json/list/", handle_json_list)
app.router.add_get("/json", handle_json_list)
app.router.add_get("/json/", handle_json_list)
# WebSocket routes — seed-specific (must be before default to match first)
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
# WebSocket routes — default (no seed)
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
app.on_shutdown.append(on_shutdown)
port = config["port"]
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
logger.info(
"Connect: playwright.chromium.connect_over_cdp("
"\"http://localhost:%d?fingerprint=<seed>\")",
port,
)
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
host = "0.0.0.0" if in_container else "127.0.0.1"
web.run_app(app, host=host, port=port, print=None)
if __name__ == "__main__":
main()
+8
View File
@@ -1,4 +1,12 @@
#!/bin/bash
# Clean up any stale Xvfb lock left behind by a previous container instance.
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
# existing lock — leaving the container with no X server, every Chrome
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
# returning 502 forever. See CloakHQ/CloakBrowser#283.
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
+7 -1
View File
@@ -11,9 +11,10 @@ Usage:
browser.close()
"""
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary
from .license import LicenseInfo, validate_license
from ._version import __version__
# Human-like behavioral layer (optional)
@@ -32,6 +33,7 @@ __all__ = [
"launch",
"launch_async",
"launch_context",
"launch_context_async",
"launch_persistent_context",
"launch_persistent_context_async",
"ensure_binary",
@@ -40,7 +42,11 @@ __all__ = [
"check_for_update",
"CHROMIUM_VERSION",
"get_default_stealth_args",
"build_args",
"maybe_resolve_geoip",
"ProxySettings",
"validate_license",
"LicenseInfo",
"HumanConfig",
"resolve_human_config",
"__version__",
+111
View File
@@ -0,0 +1,111 @@
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
Usage:
python -m cloakbrowser install # Download binary (with progress)
python -m cloakbrowser info # Show binary version, path, platform
python -m cloakbrowser update # Check for and download newer binary
python -m cloakbrowser clear-cache # Remove cached binaries
"""
from __future__ import annotations
import argparse
import logging
import sys
def _setup_logging() -> None:
"""Route cloakbrowser logger to stderr with clean output."""
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
stream=sys.stderr,
force=True,
)
# Suppress noisy HTTP request logs from httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
def cmd_install(args: argparse.Namespace) -> None:
from .download import ensure_binary
path = ensure_binary()
print(path)
def cmd_info(args: argparse.Namespace) -> None:
from .config import get_local_binary_override
from .download import binary_info
info = binary_info()
override = get_local_binary_override()
print(f"Version: {info['version']}")
print(f"Platform: {info['platform']}")
print(f"Binary: {info['binary_path']}")
print(f"Installed: {info['installed']}")
print(f"Cache: {info['cache_dir']}")
if override:
print(f"Override: {override} (CLOAKBROWSER_BINARY_PATH)")
def cmd_update(args: argparse.Namespace) -> None:
from .download import check_for_update
logger = logging.getLogger("cloakbrowser")
logger.info("Checking for updates...")
new_version = check_for_update()
if new_version:
print(f"Updated to Chromium {new_version}")
else:
print("Already up to date.")
def cmd_clear_cache(args: argparse.Namespace) -> None:
from .config import get_cache_dir
from .download import clear_cache
if not get_cache_dir().exists():
print("No cache to clear.")
return
clear_cache()
print("Cache cleared.")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cloakbrowser",
description="Manage the CloakBrowser stealth Chromium binary.",
)
sub = parser.add_subparsers(dest="command")
sub.add_parser("install", help="Download the Chromium binary")
sub.add_parser("info", help="Show binary version, path, and platform")
sub.add_parser("update", help="Check for and download a newer binary")
sub.add_parser("clear-cache", help="Remove all cached binaries")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(2)
_setup_logging()
commands = {
"install": cmd_install,
"info": cmd_info,
"update": cmd_update,
"clear-cache": cmd_clear_cache,
}
try:
commands[args.command](args)
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.11"
__version__ = "0.4.0"
+704 -152
View File
File diff suppressed because it is too large Load Diff
+58 -26
View File
@@ -15,15 +15,37 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version.
# ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.3"
CHROMIUM_VERSION = "146.0.7680.177.5"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.3",
"linux-x64": "146.0.7680.177.5",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
"windows-x64": "146.0.7680.177.5",
}
# ---------------------------------------------------------------------------
# Ed25519 public keys for verifying downloaded binaries.
#
# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig.
# The wrapper verifies that signature against the keys below before trusting
# any hash in the manifest, so the download origin alone cannot certify a
# tampered binary. Values are base64 of the 32-byte raw public key. Multiple
# entries are accepted to allow key rotation.
# ---------------------------------------------------------------------------
BINARY_SIGNING_PUBKEYS: list[str] = [
"MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=",
]
# ---------------------------------------------------------------------------
# Playwright default args to suppress — these leak automation signals.
# --enable-automation: exposes navigator.webdriver = true
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
# producing a distinctive renderer string that no real user browser has
# ---------------------------------------------------------------------------
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
# ---------------------------------------------------------------------------
# Default stealth arguments passed to the patched Chromium binary.
# These activate source-level fingerprint patches compiled into the binary.
@@ -39,32 +61,26 @@ def get_default_stealth_args() -> list[str]:
base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
f"--fingerprint={seed}",
]
if system == "Darwin":
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + [
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
]
return base + ["--fingerprint-platform=macos"]
# Linux/Windows: Windows fingerprint profile
# Hardware concurrency, device memory, screen, and window size are
# auto-generated by the binary from the seed (v14+).
return base + [
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
]
# Linux/Windows: Windows fingerprint profile.
# Screen and window size come from the real display, not this flag (verified:
# identical across seeds), so the wrapper must not emulate a viewport on top in
# headed mode — that would break outerWidth >= innerWidth coherence.
return base + ["--fingerprint-platform=windows"]
# ---------------------------------------------------------------------------
# Default viewport — realistic maximized Chrome on 1080p Windows
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
# the page tracks the real window). Headless has no window chrome, so a fixed
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
# ---------------------------------------------------------------------------
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
@@ -118,15 +134,16 @@ def get_cache_dir() -> Path:
return Path.home() / ".cloakbrowser"
def get_binary_dir(version: str | None = None) -> Path:
def get_binary_dir(version: str | None = None, pro: bool = False) -> Path:
"""Return the directory for a Chromium version binary."""
v = version or get_chromium_version()
return get_cache_dir() / f"chromium-{v}"
suffix = "-pro" if pro else ""
return get_cache_dir() / f"chromium-{v}{suffix}"
def get_binary_path(version: str | None = None) -> Path:
def get_binary_path(version: str | None = None, pro: bool = False) -> Path:
"""Return the expected path to the chrome executable."""
binary_dir = get_binary_dir(version)
binary_dir = get_binary_dir(version, pro=pro)
if platform.system() == "Darwin":
# macOS: Chromium.app bundle
@@ -156,15 +173,30 @@ def check_platform_available() -> None:
)
def get_effective_version() -> str:
def get_effective_version(pro: bool = False) -> str:
"""Return the best available version: auto-updated if available, else platform default.
Reads a platform-scoped marker file from the cache directory.
Returns the platform's hardcoded version if no update has been downloaded.
When pro=True, reads from the Pro-specific marker files.
"""
base = get_chromium_version()
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
cache = get_cache_dir()
if pro:
marker = cache / f"latest_pro_version_{get_platform_tag()}"
if marker.exists():
try:
version = marker.read_text().strip()
if version:
binary = get_binary_path(version, pro=True)
if binary.exists():
return version
except (ValueError, OSError):
pass
return base
# Free tier: try platform-scoped marker first, fall back to legacy marker
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
marker = cache / name
if marker.exists():
+431 -33
View File
@@ -12,6 +12,7 @@ import os
import platform
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
@@ -22,6 +23,7 @@ import httpx
from ._version import __version__ as _wrapper_version
from .config import (
BINARY_SIGNING_PUBKEYS,
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
@@ -43,6 +45,17 @@ from .config import (
logger = logging.getLogger("cloakbrowser")
class BinaryVerificationError(RuntimeError):
"""A downloaded binary could not be authenticated (bad/missing signature,
version mismatch, or checksum failure).
Distinct from transient download/network errors: a verification failure is
a tampering signal and MUST surface, never silently fall back to another
binary. The Pro routing in ensure_binary re-raises this rather than
downgrading to the free tier.
"""
# Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
@@ -55,13 +68,13 @@ def _show_welcome() -> None:
marker = get_cache_dir() / ".welcome_shown"
if marker.exists():
return
print()
print(" CloakBrowser — stealth Chromium for automation")
print(" https://github.com/CloakHQ/CloakBrowser")
print()
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
print(" Star us if CloakBrowser helps your project!")
print()
sys.stderr.write("\n")
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\n")
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("")
@@ -69,11 +82,14 @@ def _show_welcome() -> None:
pass
def ensure_binary() -> str:
def ensure_binary(license_key: str | None = None) -> str:
"""Ensure the stealth Chromium binary is available. Download if needed.
Returns the path to the chrome executable as a string.
Args:
license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var.
Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build.
"""
# Check for local override first
@@ -87,6 +103,39 @@ def ensure_binary() -> str:
logger.info("Using local binary override: %s", local_override)
return str(path)
# Pro license key check (custom download URL overrides Pro path)
from .license import resolve_license_key, validate_license
key = resolve_license_key(license_key)
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
key = None
if key:
info = validate_license(key)
if info and info.valid:
# A valid license is entitled to Pro, so Pro failures surface loudly
# rather than silently substituting the older free binary. (A blip
# during a routine update never reaches here: _ensure_pro_binary
# returns the cached Pro binary and updates in the background.)
try:
return _ensure_pro_binary(key)
except BinaryVerificationError:
# Authenticity could not be confirmed — surface verbatim.
raise
except Exception as e:
# Transient failure with no cached Pro binary to use — surface a
# clear error rather than silently downloading the free binary.
raise RuntimeError(
f"Pro binary unavailable: {e}. Your license is valid but the "
f"Pro binary could not be downloaded right now. Retry in a "
f"moment. To use the free binary instead, unset "
f"CLOAKBROWSER_LICENSE_KEY."
) from e
elif info:
logger.warning("License validation failed (plan=%s), using free tier", info.plan)
else:
logger.warning("License validation unavailable, using free tier")
# Fail fast if no binary available for this platform
check_platform_available()
@@ -161,9 +210,11 @@ def _download_and_extract(version: str | None = None) -> None:
)
_download_file(fallback_url, tmp_path)
# Verify checksum before extraction
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
_verify_download_checksum(tmp_path, version)
# Verify the download before extraction. On the official path this is a
# mandatory, non-bypassable Ed25519 signature check (see
# _verify_download_checksum); the skip flag only applies to custom
# self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
_verify_download_checksum(tmp_path, version)
_extract_archive(tmp_path, binary_dir, binary_path)
_show_welcome()
@@ -172,23 +223,308 @@ def _download_and_extract(version: str | None = None) -> None:
tmp_path.unlink(missing_ok=True)
def _ensure_pro_binary(license_key: str) -> str:
"""Ensure the Pro binary is downloaded and cached. Returns the binary path."""
from .license import get_pro_latest_version
effective = get_effective_version(pro=True)
binary_path = get_binary_path(effective, pro=True)
if binary_path.exists() and _is_executable(binary_path):
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective)
_show_welcome()
_maybe_trigger_pro_update_check(license_key)
return str(binary_path)
version = get_pro_latest_version()
if not version:
raise RuntimeError("Could not determine latest Pro version from server")
binary_path = get_binary_path(version, pro=True)
if binary_path.exists() and _is_executable(binary_path):
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
_show_welcome()
return str(binary_path)
logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag())
_download_pro_binary(version, license_key)
binary_path = get_binary_path(version, pro=True)
if not binary_path.exists():
raise RuntimeError(
f"Pro download completed but binary not found at: {binary_path}"
)
# Write Pro version marker (atomic)
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
try:
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
os.replace(str(tmp), str(marker))
except OSError:
pass
_show_welcome()
return str(binary_path)
def _download_pro_binary(version: str, license_key: str) -> None:
"""Download a Pro binary from cloakbrowser.dev with license key auth.
Requests the explicit version so the served archive matches the signed
manifest verified in _verify_pro_download.
"""
download_url = f"{DOWNLOAD_BASE_URL}/api/download/{version}"
binary_dir = get_binary_dir(version, pro=True)
binary_path = get_binary_path(version, pro=True)
platform_tag = get_platform_tag()
binary_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
_download_file(
download_url,
tmp_path,
headers={
"Authorization": f"Bearer {license_key}",
"X-Platform": platform_tag,
},
)
# Pro binaries come from cloakbrowser.dev — the same origin as free
# downloads — so the M1 attack the Ed25519 signature defends against
# applies equally. Verify with the same non-bypassable signature check;
# CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the
# official free path).
_verify_pro_download(tmp_path, version)
_extract_archive(tmp_path, binary_dir, binary_path)
finally:
tmp_path.unlink(missing_ok=True)
def _verify_pro_download(file_path: Path, version: str) -> None:
"""Verify a Pro archive with the same non-bypassable Ed25519 signature check
as official free downloads.
Pro binaries are served from cloakbrowser.dev (same origin as the free
tier), so a tampered same-origin SHA256SUMS could otherwise certify a
tampered binary (M1, #308). Fetch the Pro SHA256SUMS + detached
SHA256SUMS.sig, verify the signature against the pinned keys FIRST, bind the
manifest to the requested version, then verify the archive's SHA-256.
An invalid signature, checksum, or version mismatch raises
BinaryVerificationError (a tampering signal the router surfaces verbatim);
CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
transient — nothing was validated — and raises a plain RuntimeError. A
valid-license user is never silently downgraded to the free binary.
"""
base = f"{DOWNLOAD_BASE_URL}/releases/pro/chromium-v{version}"
try:
manifest_resp = httpx.get(
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
)
manifest_resp.raise_for_status()
sig_resp = httpx.get(
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
)
sig_resp.raise_for_status()
except Exception as exc:
# Fetch failure is transient, not tampering — raise a plain RuntimeError
# (the router reports it as "unavailable, retry") rather than a
# BinaryVerificationError (which it surfaces as a tampering signal).
raise RuntimeError(
f"Could not fetch the signed SHA256SUMS for Pro {version} ({exc})"
)
manifest_bytes = manifest_resp.content
# _verify_signature / _verify_checksum raise plain RuntimeError; convert to
# BinaryVerificationError so the Pro router treats them as tampering signals
# (re-raise) rather than transient failures (fall back to free).
try:
_verify_signature(manifest_bytes, sig_resp.content)
except RuntimeError as exc:
raise BinaryVerificationError(str(exc)) from exc
manifest_text = manifest_bytes.decode("utf-8")
# Version binding: same forced-downgrade defense as the official path.
declared = _parse_manifest_version(manifest_text)
if declared != version:
raise BinaryVerificationError(
f"Version mismatch in signed Pro SHA256SUMS: requested {version}, "
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
)
tarball_name = get_archive_name()
expected = _parse_checksums(manifest_text).get(tarball_name)
if expected is None:
raise BinaryVerificationError(
f"Signature-verified Pro SHA256SUMS has no entry for {tarball_name}"
f"cannot confirm binary integrity."
)
try:
_verify_checksum(file_path, expected)
except RuntimeError as exc:
raise BinaryVerificationError(str(exc)) from exc
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
checksums = _fetch_checksums(version)
"""Verify the downloaded archive's integrity and authenticity.
Official path (cloakbrowser.dev / GitHub Releases): fetch SHA256SUMS plus
its detached Ed25519 signature SHA256SUMS.sig, verify the signature against
the pinned public keys FIRST, then verify the archive's SHA-256 against the
now-authenticated manifest. Mandatory and non-bypassable — a same-origin
manifest can no longer certify a tampered binary (#308).
Custom self-hosted path (CLOAKBROWSER_DOWNLOAD_URL set): the pinned keys do
not apply to a third-party server, so fall back to the plain same-origin
SHA256SUMS check, which CLOAKBROWSER_SKIP_CHECKSUM may bypass.
"""
tarball_name = get_archive_name()
if checksums is None:
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
# Self-hosted mirror: signature scheme does not apply. Preserve the
# legacy same-origin checksum behavior, skippable as before.
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() == "true":
logger.warning(
"CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
)
return
checksums = _fetch_checksums(version)
if checksums is None:
logger.warning(
"SHA256SUMS not available from custom URL — skipping checksum verification"
)
return
expected = checksums.get(tarball_name)
if expected is None:
logger.warning(
"SHA256SUMS found but no entry for %s — skipping verification", tarball_name
)
return
_verify_checksum(file_path, expected)
return
# Official path: signature is the trust root and is non-bypassable.
manifest = _fetch_signed_manifest(version)
if manifest is None:
raise RuntimeError(
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) "
"for this release — refusing to use an unverified binary. "
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
)
manifest_bytes, sig_bytes = manifest
_verify_signature(manifest_bytes, sig_bytes)
manifest_text = manifest_bytes.decode("utf-8")
# Version binding: the signed manifest must declare the version we asked for.
# The signature proves "we made this manifest", not "this is the version you
# requested" — without this check a mirror could serve a genuinely-signed
# older release in place of the requested one (forced downgrade).
requested = version or get_chromium_version()
declared = _parse_manifest_version(manifest_text)
if declared != requested:
raise RuntimeError(
f"Version mismatch in signed SHA256SUMS: requested {requested}, "
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
)
checksums = _parse_checksums(manifest_text)
expected = checksums.get(tarball_name)
if expected is None:
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
return
raise RuntimeError(
f"Signature-verified SHA256SUMS has no entry for {tarball_name}"
f"cannot confirm binary integrity."
)
_verify_checksum(file_path, expected)
def _parse_manifest_version(text: str) -> str | None:
"""Read the 'version=<v>' line from a signed manifest. None if absent.
The line has no internal whitespace so older wrappers' SHA256SUMS parsers
ignore it (they only accept '<hash> <filename>' lines).
"""
for line in text.splitlines():
line = line.strip()
if line.startswith("version="):
return line[len("version="):].strip()
return None
def _fetch_signed_manifest(version: str | None = None) -> tuple[bytes, bytes] | None:
"""Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or None.
Both files are fetched from the SAME origin so the signature always matches
the exact manifest bytes it certifies. The primary origin is tried first,
then the GitHub Releases mirror. follow_redirects mirrors _fetch_checksums:
cloakbrowser.dev 301-redirects /chromium-v* to GitHub Releases.
"""
v = version or get_chromium_version()
bases = [
f"{DOWNLOAD_BASE_URL}/chromium-v{v}",
f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}",
]
for base in bases:
try:
manifest_resp = httpx.get(
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
)
manifest_resp.raise_for_status()
sig_resp = httpx.get(
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
)
sig_resp.raise_for_status()
return manifest_resp.content, sig_resp.content
except Exception:
continue
return None
def _verify_signature(manifest_bytes: bytes, sig_b64: bytes) -> None:
"""Verify a detached Ed25519 signature over the raw manifest bytes.
sig_b64 is the base64 of the 64-byte raw signature. Tries each pinned key
in BINARY_SIGNING_PUBKEYS; succeeds if any validates. Raises RuntimeError
if the signature is malformed or no pinned key validates it.
"""
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
try:
signature = base64.b64decode(sig_b64.strip(), validate=True)
except Exception as exc:
raise RuntimeError(f"Malformed SHA256SUMS.sig (not valid base64): {exc}")
for pubkey_b64 in BINARY_SIGNING_PUBKEYS:
try:
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64))
except Exception:
# Skip an unparseable pinned key (e.g. the placeholder) rather than
# aborting — another pinned key may still validate.
continue
try:
pub.verify(signature, manifest_bytes)
logger.info("SHA256SUMS signature verified: Ed25519 OK")
return
except Exception:
# InvalidSignature, or a malformed/wrong-length signature that makes
# verify raise something else — either way this key didn't match,
# so try the next pinned key (and ultimately fail closed below).
continue
raise RuntimeError(
"SHA256SUMS signature verification failed — no pinned key validated the "
"manifest. The binary's authenticity could not be confirmed. "
"Report at https://github.com/CloakHQ/cloakbrowser/issues"
)
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
v = version or get_chromium_version()
@@ -210,17 +546,22 @@ def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
def _parse_checksums(text: str) -> dict[str, str]:
"""Parse SHA256SUMS format: 'hash filename' per line."""
"""Parse SHA256SUMS format: '<64-hex sha256> filename' per line.
Only lines whose first token is a 64-character hex digest are accepted
(matches the JS parser); blank lines, the version= line, and any other
junk are ignored.
"""
result = {}
for line in text.strip().splitlines():
line = line.strip()
if not line:
parts = line.strip().split(None, 1)
if len(parts) != 2:
continue
parts = line.split(None, 1)
if len(parts) == 2:
hash_val, filename = parts
filename = filename.lstrip("*")
result[filename] = hash_val.lower()
hash_val, filename = parts
hash_val = hash_val.lower()
if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val):
continue
result[filename.lstrip("*")] = hash_val
return result
@@ -242,11 +583,11 @@ def _verify_checksum(file_path: Path, expected_hash: str) -> None:
logger.info("Checksum verified: SHA-256 OK")
def _download_file(url: str, dest: Path) -> None:
def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None) -> None:
"""Download a file with progress logging."""
logger.info("Downloading from %s", url)
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT) as response:
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT, headers=headers or {}) as response:
response.raise_for_status()
total = int(response.headers.get("content-length", 0))
@@ -400,17 +741,34 @@ def clear_cache() -> None:
def binary_info() -> dict:
"""Return info about the current binary installation."""
effective = get_effective_version()
binary_path = get_binary_path(effective)
"""Return info about the current binary installation.
tier reflects what is actually installed on disk, not merely whether a
license is cached — a cached license with no Pro binary downloaded yet is
still effectively running the free binary, and the active key may differ
from the cached one.
"""
# Prefer Pro only if a Pro binary actually exists on disk.
pro_version = get_effective_version(pro=True)
pro_path = get_binary_path(pro_version, pro=True)
pro = pro_path.exists() and _is_executable(pro_path)
if pro:
effective = pro_version
binary_path = pro_path
else:
effective = get_effective_version()
binary_path = get_binary_path(effective)
download_url = f"{DOWNLOAD_BASE_URL}/api/download/latest" if pro else get_download_url(effective)
return {
"version": effective,
"tier": "pro" if pro else "free",
"bundled_version": CHROMIUM_VERSION,
"platform": get_platform_tag(),
"binary_path": str(binary_path),
"installed": binary_path.exists(),
"cache_dir": str(get_binary_dir(effective)),
"download_url": get_download_url(effective),
"cache_dir": str(get_binary_dir(effective, pro=pro)),
"download_url": download_url,
}
@@ -575,3 +933,43 @@ def _maybe_trigger_update_check() -> None:
return
t = threading.Thread(target=_check_and_download_update, daemon=True)
t.start()
def _maybe_trigger_pro_update_check(license_key: str) -> None:
"""Fire-and-forget Pro binary update check in a daemon thread."""
check_file = get_cache_dir() / ".last_pro_update_check"
if check_file.exists():
try:
last_check = float(check_file.read_text().strip())
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
return
except (ValueError, OSError):
pass
def _check():
try:
from .license import get_pro_latest_version
check_file.parent.mkdir(parents=True, exist_ok=True)
check_file.write_text(str(time.time()))
latest = get_pro_latest_version()
if not latest:
return
if get_binary_path(latest, pro=True).exists():
return
logger.info("Newer Pro binary available: %s. Downloading in background...", latest)
_download_pro_binary(latest, license_key)
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
tmp = marker.with_suffix(".tmp")
tmp.write_text(latest)
os.replace(str(tmp), str(marker))
logger.info("Pro background update complete: %s ready. Will use on next launch.", latest)
except Exception:
logger.debug("Pro background update failed", exc_info=True)
t = threading.Thread(target=_check, daemon=True)
t.start()
+89 -12
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import ipaddress
import logging
import math
import os
import socket
import tempfile
import threading
@@ -27,6 +29,8 @@ GEOIP_DB_URL = (
)
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
DEFAULT_GEOIP_TIMEOUT_SECONDS = 5.0
GEOIP_TIMEOUT_ENV = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS"
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
COUNTRY_LOCALE_MAP: dict[str, str] = {
@@ -53,6 +57,18 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises.
"""
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
return tz, locale
def resolve_proxy_geo_with_ip(
proxy_url: str,
) -> tuple[str | None, str | None, str | None]:
"""Resolve timezone, locale, and exit IP from a proxy.
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
"""
try:
import geoip2.database # noqa: F811
except ImportError:
@@ -63,14 +79,19 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
db_path = _ensure_geoip_db()
if db_path is None:
return None, None
return None, None, None
timeout = _get_geoip_timeout_seconds()
deadline = _deadline_from_timeout(timeout)
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
if ip is None and not _deadline_expired(deadline):
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
return None, None
if ip is None or _deadline_expired(deadline):
if deadline is not None and _deadline_expired(deadline):
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
return None, None, None
try:
with geoip2.database.Reader(str(db_path)) as reader:
@@ -82,10 +103,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
"GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale,
)
return timezone, locale
return timezone, locale, ip
except Exception as exc:
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
return None, None, ip
# ---------------------------------------------------------------------------
@@ -120,7 +141,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
return ip
return None
except Exception as exc:
logger.debug("Failed to resolve proxy hostname: %s", exc)
logger.warning("Failed to resolve proxy hostname: %s", exc)
return None
@@ -140,22 +161,78 @@ _IP_ECHO_URLS = [
]
def _resolve_exit_ip(proxy_url: str) -> str | None:
def _get_geoip_timeout_seconds() -> float:
raw = os.getenv(GEOIP_TIMEOUT_ENV)
if not raw:
return DEFAULT_GEOIP_TIMEOUT_SECONDS
try:
timeout = float(raw)
except ValueError:
timeout = float("nan")
if not math.isfinite(timeout):
logger.warning(
"Invalid %s=%r; using %.1fs",
GEOIP_TIMEOUT_ENV,
raw,
DEFAULT_GEOIP_TIMEOUT_SECONDS,
)
return DEFAULT_GEOIP_TIMEOUT_SECONDS
return max(timeout, 0.0)
def _deadline_from_timeout(timeout: float) -> float | None:
if timeout <= 0:
return None
return time.monotonic() + timeout
def _remaining_seconds(deadline: float | None) -> float | None:
if deadline is None:
return None
return max(deadline - time.monotonic(), 0.0)
def _deadline_expired(deadline: float | None) -> bool:
return deadline is not None and time.monotonic() >= deadline
def resolve_proxy_exit_ip(proxy_url: str) -> str | None:
"""Resolve only the proxy exit IP, bounded by the GeoIP timeout."""
timeout = _get_geoip_timeout_seconds()
deadline = _deadline_from_timeout(timeout)
ip = _resolve_exit_ip(proxy_url, timeout=timeout)
if ip is None and _deadline_expired(deadline):
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
return ip
def _resolve_exit_ip(proxy_url: str, timeout: float | None = None) -> str | None:
"""Discover the proxy's actual exit IP by connecting through it."""
import httpx
deadline = _deadline_from_timeout(timeout or 0)
for url in _IP_ECHO_URLS:
try:
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
remaining = _remaining_seconds(deadline)
if remaining is not None and remaining <= 0:
return None
request_timeout = min(10.0, remaining) if remaining is not None else 10.0
resp = httpx.get(url, proxy=proxy_url, timeout=request_timeout)
resp.raise_for_status()
ip = resp.text.strip()
# Validate it looks like an IP
ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip)
return ip
except httpx.UnsupportedProtocol:
logger.warning(
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
)
return None
except Exception:
continue
logger.debug("Failed to discover exit IP through proxy")
logger.warning("Failed to discover exit IP through proxy")
return None
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
"""Playwright-style actionability checks for the humanize layer (sync).
Checks: attached, visible, stable, enabled, editable, receives pointer events.
Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, FrozenSet, Optional, Tuple
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Error hierarchy — all subclass RuntimeError for backward compat
# ---------------------------------------------------------------------------
class ActionabilityError(RuntimeError):
"""Base for all actionability failures."""
def __init__(self, selector: str, check: str, message: str):
self.selector = selector
self.check = check
super().__init__(f"Element {selector!r} failed {check} check: {message}")
class ElementNotAttachedError(ActionabilityError):
def __init__(self, selector: str):
super().__init__(selector, "attached", "element not found in DOM")
class ElementNotVisibleError(ActionabilityError):
def __init__(self, selector: str):
super().__init__(selector, "visible", "element is not visible")
class ElementNotStableError(ActionabilityError):
def __init__(self, selector: str):
super().__init__(selector, "stable", "element position is still changing")
class ElementNotEnabledError(ActionabilityError):
def __init__(self, selector: str):
super().__init__(selector, "enabled", "element is disabled")
class ElementNotEditableError(ActionabilityError):
def __init__(self, selector: str):
super().__init__(selector, "editable", "element is not editable")
class ElementNotReceivingEventsError(ActionabilityError):
def __init__(self, selector: str, covering_tag: str = "unknown"):
super().__init__(
selector,
"pointer_events",
f"element is covered by <{covering_tag}>",
)
# ---------------------------------------------------------------------------
# Check-set constants
# ---------------------------------------------------------------------------
CHECKS_CLICK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
CHECKS_HOVER: FrozenSet[str] = frozenset({"attached", "visible", "pointer_events"})
CHECKS_INPUT: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "editable", "pointer_events"})
CHECKS_FOCUS: FrozenSet[str] = frozenset({"attached", "visible", "enabled"})
CHECKS_CHECK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
_BACKOFF_MS = [100, 250, 500, 1000]
def _backoff_sleep(attempt: int) -> None:
idx = min(attempt, len(_BACKOFF_MS) - 1)
time.sleep(_BACKOFF_MS[idx] / 1000.0)
# ---------------------------------------------------------------------------
# Pre-scroll actionability: attached, visible, enabled, editable
# ---------------------------------------------------------------------------
def ensure_actionable(
page: Any,
selector: str,
checks: FrozenSet[str],
timeout: float = 30000,
force: bool = False,
) -> None:
"""Wait for element to pass actionability checks (pre-scroll).
Retries with backoff until *timeout* ms elapsed.
Raises a specific ``ActionabilityError`` subclass on failure.
If *force* is True, returns immediately.
"""
if force:
return
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_error: Optional[ActionabilityError] = None
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
if last_error is not None:
raise last_error
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
try:
loc = page.locator(selector).first
if "attached" in checks:
try:
loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "visible" in checks:
if not loc.is_visible():
raise ElementNotVisibleError(selector)
if "enabled" in checks:
if not loc.is_enabled():
raise ElementNotEnabledError(selector)
if "editable" in checks:
if not loc.is_editable():
raise ElementNotEditableError(selector)
return
except ActionabilityError as e:
last_error = e
if time.monotonic() >= deadline:
raise last_error
_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# Post-scroll stability check
# ---------------------------------------------------------------------------
def _boxes_differ(a: dict, b: dict) -> bool:
return (
abs(a["x"] - b["x"]) > 1
or abs(a["y"] - b["y"]) > 1
or abs(a["width"] - b["width"]) > 1
or abs(a["height"] - b["height"]) > 1
)
def ensure_stable(
page: Any,
selector: str,
timeout: float = 5000,
) -> None:
"""Wait for element position to stabilize (two samples 100ms apart).
Only call after scroll — skip if element was already in viewport.
"""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
raise ElementNotStableError(selector)
loc = page.locator(selector).first
box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
if box1 is None:
raise ElementNotAttachedError(selector)
time.sleep(0.1)
box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
if box2 is None:
raise ElementNotAttachedError(selector)
if not _boxes_differ(box1, box2):
return
if time.monotonic() >= deadline:
raise ElementNotStableError(selector)
_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# Pointer-events check (post-scroll, at actual click coordinates)
# ---------------------------------------------------------------------------
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
# is the iframe offset, needed to map page-space click coords into the frame's
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}"""
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}"""
def check_pointer_events(
page: Any,
selector: str,
x: float,
y: float,
stealth: Any = None,
timeout: float = 5000,
) -> None:
"""Check that elementFromPoint(x, y) hits the expected element.
Uses locator.evaluate() so all Playwright selector types work
(text=, role=, XPath, CSS, etc.). Retries with backoff for transient overlays.
"""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
try:
loc = page.locator(selector).first
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, covering)
_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# ElementHandle variant
# ---------------------------------------------------------------------------
def ensure_actionable_handle(
page: Any,
el: Any,
checks: FrozenSet[str],
timeout: float = 30000,
force: bool = False,
) -> None:
"""Actionability checks for ElementHandle (no selector needed).
Uses Playwright's wait_for_element_state where available.
"""
if force:
return
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_error: Optional[ActionabilityError] = None
label = "<ElementHandle>"
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
if last_error is not None:
raise last_error
raise ActionabilityError(label, "timeout", "timeout expired before first check")
try:
if "visible" in checks:
try:
el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotVisibleError(label)
if "enabled" in checks:
try:
el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotEnabledError(label)
if "editable" in checks:
try:
el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotEditableError(label)
return
except ActionabilityError as e:
last_error = e
if time.monotonic() >= deadline:
raise last_error
_backoff_sleep(attempt)
attempt += 1
def check_pointer_events_handle(
page: Any,
el: Any,
x: float,
y: float,
timeout: float = 5000,
) -> None:
"""Pointer-events check for ElementHandle."""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
try:
box = el.bounding_box()
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
except Exception:
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
_backoff_sleep(attempt)
attempt += 1
+250
View File
@@ -0,0 +1,250 @@
"""Playwright-style actionability checks for the humanize layer (async).
Async mirror of actionability.py — same logic, uses asyncio.sleep and await.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, FrozenSet, Optional
logger = logging.getLogger(__name__)
from .actionability import (
ActionabilityError,
ElementNotAttachedError,
ElementNotVisibleError,
ElementNotStableError,
ElementNotEnabledError,
ElementNotEditableError,
ElementNotReceivingEventsError,
_BACKOFF_MS,
_boxes_differ,
_POINTER_EVENTS_LOCATOR_JS,
_POINTER_EVENTS_HANDLE_JS,
)
async def _async_backoff_sleep(attempt: int) -> None:
idx = min(attempt, len(_BACKOFF_MS) - 1)
await asyncio.sleep(_BACKOFF_MS[idx] / 1000.0)
# ---------------------------------------------------------------------------
# Pre-scroll actionability
# ---------------------------------------------------------------------------
async def async_ensure_actionable(
page: Any,
selector: str,
checks: FrozenSet[str],
timeout: float = 30000,
force: bool = False,
) -> None:
if force:
return
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_error: Optional[ActionabilityError] = None
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
if last_error is not None:
raise last_error
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
try:
loc = page.locator(selector).first
if "attached" in checks:
try:
await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "visible" in checks:
if not await loc.is_visible():
raise ElementNotVisibleError(selector)
if "enabled" in checks:
if not await loc.is_enabled():
raise ElementNotEnabledError(selector)
if "editable" in checks:
if not await loc.is_editable():
raise ElementNotEditableError(selector)
return
except ActionabilityError as e:
last_error = e
if time.monotonic() >= deadline:
raise last_error
await _async_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# Post-scroll stability check
# ---------------------------------------------------------------------------
async def async_ensure_stable(
page: Any,
selector: str,
timeout: float = 5000,
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
raise ElementNotStableError(selector)
loc = page.locator(selector).first
box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
if box1 is None:
raise ElementNotAttachedError(selector)
await asyncio.sleep(0.1)
box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
if box2 is None:
raise ElementNotAttachedError(selector)
if not _boxes_differ(box1, box2):
return
if time.monotonic() >= deadline:
raise ElementNotStableError(selector)
await _async_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# Pointer-events check
# ---------------------------------------------------------------------------
async def async_check_pointer_events(
page: Any,
selector: str,
x: float,
y: float,
stealth: Any = None,
timeout: float = 5000,
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
try:
loc = page.locator(selector).first
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError(selector, covering)
await _async_backoff_sleep(attempt)
attempt += 1
# ---------------------------------------------------------------------------
# ElementHandle variant
# ---------------------------------------------------------------------------
async def async_ensure_actionable_handle(
page: Any,
el: Any,
checks: FrozenSet[str],
timeout: float = 30000,
force: bool = False,
) -> None:
if force:
return
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
last_error: Optional[ActionabilityError] = None
label = "<ElementHandle>"
while True:
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
if remaining_ms <= 0:
if last_error is not None:
raise last_error
raise ActionabilityError(label, "timeout", "timeout expired before first check")
try:
if "visible" in checks:
try:
await el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotVisibleError(label)
if "enabled" in checks:
try:
await el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotEnabledError(label)
if "editable" in checks:
try:
await el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotEditableError(label)
return
except ActionabilityError as e:
last_error = e
if time.monotonic() >= deadline:
raise last_error
await _async_backoff_sleep(attempt)
attempt += 1
async def async_check_pointer_events_handle(
page: Any,
el: Any,
x: float,
y: float,
timeout: float = 5000,
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
while True:
try:
box = await el.bounding_box()
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
except Exception:
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
if time.monotonic() >= deadline:
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
await _async_backoff_sleep(attempt)
attempt += 1
+66 -3
View File
@@ -10,7 +10,7 @@ import math
import random
import time
from dataclasses import dataclass, field
from typing import Literal, Tuple
from typing import Literal, Tuple, TypedDict
# ---------------------------------------------------------------------------
# Type alias
@@ -20,6 +20,50 @@ Range = Tuple[float, float]
HumanPreset = Literal["default", "careful"]
class HumanConfigOverrides(TypedDict, total=False):
typing_delay: float
typing_delay_spread: float
typing_pause_chance: float
typing_pause_range: Range
shift_down_delay: Range
shift_up_delay: Range
key_hold: Range
field_switch_delay: Range
mistype_chance: float
mistype_delay_notice: Range
mistype_delay_correct: Range
mouse_steps_divisor: float
mouse_min_steps: int
mouse_max_steps: int
mouse_wobble_max: float
mouse_overshoot_chance: float
mouse_overshoot_px: Range
mouse_burst_size: Range
mouse_burst_pause: Range
click_aim_delay_input: Range
click_aim_delay_button: Range
click_hold_input: Range
click_hold_button: Range
click_input_x_range: Range
idle_drift_px: float
idle_pause_range: Range
scroll_delta_base: Range
scroll_delta_variance: float
scroll_pause_fast: Range
scroll_pause_slow: Range
scroll_accel_steps: Range
scroll_decel_steps: Range
scroll_overshoot_chance: float
scroll_overshoot_px: Range
scroll_settle_delay: Range
scroll_target_zone: Range
scroll_pre_move_delay: Range
initial_cursor_x: Range
initial_cursor_y: Range
idle_between_actions: bool
idle_between_duration: Range
# ---------------------------------------------------------------------------
# Configuration dataclass
# ---------------------------------------------------------------------------
@@ -130,13 +174,13 @@ _PRESETS: dict[str, HumanConfig] = {
def resolve_config(
preset: HumanPreset = "default",
overrides: dict | None = None,
overrides: HumanConfigOverrides | None = None,
) -> HumanConfig:
"""Resolve a preset name + optional overrides into a full HumanConfig.
Args:
preset: 'default' or 'careful'.
overrides: Dict of field names to override values.
overrides: Typed mapping of HumanConfig field names to override values.
Returns:
A new HumanConfig instance.
@@ -157,6 +201,25 @@ def resolve_config(
return HumanConfig(**merged)
def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig:
"""Merge ``overrides`` (a dict of HumanConfig field names → values) on top of
``base``. Returns a new HumanConfig — ``base`` is never mutated.
Used by per-call overrides like ``page.type(sel, text, human_config={...})``
so the same page can use different timings for different inputs without
re-patching.
Unknown keys are ignored silently to keep this forgiving for callers.
"""
if not overrides:
return base
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
for k, v in overrides.items():
if k in base.__dataclass_fields__:
merged[k] = v
return HumanConfig(**merged)
# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------
+106 -21
View File
@@ -1,9 +1,14 @@
"""cloakbrowser-human — Human-like keyboard input."""
"""cloakbrowser-human — Human-like keyboard input.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
Falls back to page.evaluate when no CDP session is available.
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from typing import Any, Optional, Protocol
from .config import HumanConfig, rand, rand_range, sleep_ms
@@ -28,6 +33,25 @@ NEARBY_KEYS = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}
# CDP key code for each shift symbol's physical key.
_SHIFT_SYMBOL_CODES: dict[str, str] = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
}
# Windows virtual key codes for Input.dispatchKeyEvent.
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
}
def _get_nearby_key(ch: str) -> str:
"""Return a random adjacent key for the given character."""
@@ -39,9 +63,27 @@ def _get_nearby_key(ch: str) -> str:
return ch
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
def human_type(
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing.
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
sleep_ms(rand_range(cfg.key_hold))
raw.insert_text(ch)
if i < len(text) - 1:
_inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
_type_normal_char(raw, wrong, cfg)
@@ -54,7 +96,7 @@ def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None
if ch.isupper() and ch.isalpha():
_type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
_type_shift_symbol(page, raw, ch, cfg)
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
else:
_type_normal_char(raw, ch, cfg)
@@ -78,22 +120,65 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
raw.up("Shift")
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _type_shift_symbol(
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character.
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
sleep_ms(rand_range(cfg.key_hold))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch)
page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
def _inter_char_delay(cfg: HumanConfig) -> None:
+85 -20
View File
@@ -2,15 +2,19 @@
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
"""
from __future__ import annotations
import random
from typing import Any, Protocol
from typing import Any, Optional, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
class AsyncRawKeyboard(Protocol):
@@ -20,9 +24,27 @@ class AsyncRawKeyboard(Protocol):
async def insert_text(self, text: str) -> None: ...
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
async def async_human_type(
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing (async).
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii():
await async_sleep_ms(rand_range(cfg.key_hold))
await raw.insert_text(ch)
if i < len(text) - 1:
await _inter_char_delay(cfg)
continue
# Mistype chance — only for ASCII alphanumeric
if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch)
await _type_normal_char(raw, wrong, cfg)
@@ -35,7 +57,7 @@ async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: Hum
if ch.isupper() and ch.isalpha():
await _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS:
await _type_shift_symbol(page, raw, ch, cfg)
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
else:
await _type_normal_char(raw, ch, cfg)
@@ -59,22 +81,65 @@ async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: Hum
await raw.up("Shift")
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _type_shift_symbol(
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character (async).
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
await async_sleep_ms(rand_range(cfg.key_hold))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch)
await page.evaluate(
"""(key) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}""",
ch,
)
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
async def _inter_char_delay(cfg: HumanConfig) -> None:
+52 -16
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from typing import Any, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move
@@ -18,10 +18,15 @@ def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> boo
return top_edge >= zone_top and bottom_edge <= zone_bottom
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Optional[dict]:
"""Locate ``selector`` and return its bounding box.
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
so callers can extend it for slow-loading elements (#172).
"""
try:
el = page.locator(selector).first
return el.bounding_box(timeout=2000)
return el.bounding_box(timeout=max(1, timeout))
except Exception:
return None
@@ -39,13 +44,24 @@ def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
sleep_ms(rand(8, 20))
def scroll_to_element(
def human_scroll_into_view(
page: Any,
raw: RawMouse,
selector: str,
get_box: Callable[[], Optional[dict]],
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
) -> Tuple[dict, float, float, bool]:
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
instead of a CSS selector.
Used both by ``scroll_to_element`` (selector-based) and by
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
behavior runs everywhere.
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
when the element was already in the viewport.
"""
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
@@ -53,15 +69,12 @@ def scroll_to_element(
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = _get_element_box(page, selector)
box = get_box()
if box is None:
sleep_ms(200)
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
raise RuntimeError("Element not found while scrolling into view")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
return box, cursor_x, cursor_y, False
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
@@ -105,7 +118,7 @@ def scroll_to_element(
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = _get_element_box(page, selector)
box = get_box()
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
@@ -125,8 +138,31 @@ def scroll_to_element(
# Settle
sleep_ms(rand_range(cfg.scroll_settle_delay))
box = _get_element_box(page, selector)
box = get_box()
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
raise RuntimeError("Element lost after scrolling into view")
return box, cursor_x, cursor_y
return box, cursor_x, cursor_y, True
def scroll_to_element(
page: Any,
raw: RawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
timeout: float = 30000,
) -> Tuple[dict, float, float, bool]:
"""Selector-based humanized scroll.
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
(#172). Default matches Playwright's 30000ms when not specified.
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
"""
return human_scroll_into_view(
page, raw,
lambda: _get_element_box(page, selector, timeout),
cursor_x, cursor_y, cfg,
)
+51 -16
View File
@@ -8,17 +8,22 @@ from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from typing import Any, Awaitable, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
async def _get_element_box_async(
page: Any, selector: str, timeout: float = 30000,
) -> Optional[dict]:
"""Async variant. ``timeout`` is forwarded to Playwright's
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
elements (#172)."""
try:
el = page.locator(selector).first
return await el.bounding_box(timeout=2000)
return await el.bounding_box(timeout=max(1, timeout))
except Exception:
return None
@@ -36,13 +41,23 @@ async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig)
await async_sleep_ms(rand(8, 20))
async def async_scroll_to_element(
async def async_human_scroll_into_view(
page: Any,
raw: AsyncRawMouse,
selector: str,
get_box: Callable[[], Awaitable[Optional[dict]]],
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
) -> Tuple[dict, float, float, bool]:
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
Used by both ``async_scroll_to_element`` (selector-based) and the
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
\u2192 overshoot behavior.
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
when the element was already in the viewport.
"""
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
@@ -50,15 +65,12 @@ async def async_scroll_to_element(
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = await _get_element_box_async(page, selector)
box = await get_box()
if box is None:
await async_sleep_ms(200)
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
raise RuntimeError("Element not found while scrolling into view")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
return box, cursor_x, cursor_y, False
# Move cursor into scroll area
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
@@ -102,7 +114,7 @@ async def async_scroll_to_element(
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = await _get_element_box_async(page, selector)
box = await get_box()
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
@@ -122,8 +134,31 @@ async def async_scroll_to_element(
# Settle
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
box = await _get_element_box_async(page, selector)
box = await get_box()
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
raise RuntimeError("Element lost after scrolling into view")
return box, cursor_x, cursor_y
return box, cursor_x, cursor_y, True
async def async_scroll_to_element(
page: Any,
raw: AsyncRawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
timeout: float = 30000,
) -> Tuple[dict, float, float, bool]:
"""Selector-based humanized scroll (async).
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
(#172). Default matches Playwright's 30000ms when not specified.
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
"""
async def _get():
return await _get_element_box_async(page, selector, timeout)
return await async_human_scroll_into_view(
page, raw, _get, cursor_x, cursor_y, cfg,
)
+188
View File
@@ -0,0 +1,188 @@
"""License validation and caching for CloakBrowser Pro.
Handles license key resolution, server validation with local caching,
and Pro version checks.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import get_cache_dir
logger = logging.getLogger("cloakbrowser")
VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"
PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"
LICENSE_CACHE_TTL = 86400 # 24 hours
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
@dataclass
class LicenseInfo:
valid: bool
plan: str
expires: str | None
def resolve_license_key(license_key: str | None = None) -> str | None:
"""Resolve the license key: explicit param > env var > file > None."""
if license_key and license_key.strip():
return license_key.strip()
env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip()
if env_key:
return env_key
key_file = get_cache_dir() / "license.key"
try:
content = key_file.read_text().strip()
if content:
return content
except OSError:
pass
return None
def validate_license(license_key: str) -> LicenseInfo | None:
"""Validate a license key with the CloakBrowser server.
Checks a local file cache first (24h TTL). Falls back to stale
cache if the server is unreachable.
Returns LicenseInfo if validation succeeded, None on total failure.
"""
cache_path = get_cache_dir() / ".license_cache"
key_sha = hashlib.sha256(license_key.encode()).hexdigest()
cached = _read_cache(cache_path, key_sha)
if cached:
return cached
try:
resp = httpx.post(
VALIDATE_URL,
json={"license_key": license_key},
timeout=10.0,
)
resp.raise_for_status()
data = resp.json()
info = LicenseInfo(
valid=data.get("valid", False),
plan=data.get("plan", "solo"),
expires=data.get("expires"),
)
if info.valid:
_write_cache(cache_path, key_sha, info)
return info
except Exception as e:
logger.warning("License validation request failed: %s", e)
stale = _read_cache(cache_path, key_sha, ignore_ttl=True)
if stale:
logger.warning("Using cached license validation (server unreachable)")
return stale
return None
def get_pro_latest_version() -> str | None:
"""Get the latest Pro binary version from the server.
Rate-limited to 1 call per hour via a marker file.
"""
marker = get_cache_dir() / ".last_pro_version_check"
if marker.exists():
try:
age = time.time() - marker.stat().st_mtime
if age < PRO_VERSION_CHECK_INTERVAL:
content = marker.read_text().strip()
return content if content else None
except OSError:
pass
try:
resp = httpx.get(PRO_VERSION_URL, timeout=10.0)
resp.raise_for_status()
version = resp.json().get("version")
if not version:
return None
marker.parent.mkdir(parents=True, exist_ok=True)
tmp = marker.with_suffix(".tmp")
tmp.write_text(version)
os.replace(str(tmp), str(marker))
return version
except Exception as e:
logger.debug("Pro version check failed: %s", e)
return None
def _read_cache(
cache_path: Path, key_sha: str, ignore_ttl: bool = False
) -> LicenseInfo | None:
"""Read cached license validation if it exists and is fresh."""
try:
if not cache_path.exists():
return None
data = json.loads(cache_path.read_text())
if data.get("key_sha256") != key_sha:
return None
if not ignore_ttl:
validated_at = data.get("validated_at", 0)
if time.time() - validated_at > LICENSE_CACHE_TTL:
return None
expires = data.get("expires")
if expires:
try:
from datetime import datetime, timezone
exp_dt = datetime.fromisoformat(expires)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
if exp_dt < datetime.now(timezone.utc):
return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires)
except (ValueError, TypeError):
pass
return LicenseInfo(
valid=data.get("valid", False),
plan=data.get("plan", "solo"),
expires=expires,
)
except (json.JSONDecodeError, OSError, KeyError, TypeError):
# TypeError: a corrupted cache with a non-numeric validated_at. Treat any
# unreadable cache as absent rather than crashing the caller.
return None
def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None:
"""Write license validation result to local cache (atomic via tmp+rename)."""
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(".tmp")
tmp_path.write_text(json.dumps({
"key_sha256": key_sha,
"valid": info.valid,
"plan": info.plan,
"expires": info.expires,
"validated_at": time.time(),
}))
os.replace(str(tmp_path), str(cache_path))
except OSError as e:
logger.debug("Failed to write license cache: %s", e)
+112
View File
@@ -0,0 +1,112 @@
"""Widevine CDM hint-file seeding for persistent contexts.
CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM
is a proprietary Google binary we can't redistribute). Users sideload it by
copying a ``WidevineCdm/`` directory from a real Chrome install next to the
binary (see issue #96).
Chromium discovers a sideloaded CDM in two phases: an early-startup pass that
reads a "hint file" from the user-data-dir, and a later async component-updater
pass that writes that hint file. On a fresh profile the hint file doesn't exist
on the first launch, and Playwright passes ``--disable-component-update``, so the
updater never writes it — Widevine only works after a manual two-launch dance.
This module pre-seeds the hint file before launch so a sideloaded CDM works on
the very first launch. It never bundles, downloads, or copies the CDM itself —
it only writes the hint when a CDM the user provided is already present.
Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows
the CDM can't initialise (DRM host verification), and macOS uses a different CDM
layout, so seeding is a no-op there.
"""
from __future__ import annotations
import json
import logging
import os
import platform
from pathlib import Path
logger = logging.getLogger("cloakbrowser")
# Chromium reads this file from <user-data-dir>/WidevineCdm/ at early startup.
_HINT_FILENAME = "latest-component-updated-widevine-cdm"
def _seeding_disabled() -> bool:
"""True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch)."""
val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower()
return val in ("0", "false", "off", "no")
def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None:
"""Locate a sideloaded Widevine CDM directory, or None if absent.
Resolution:
- If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides
auto-detection). An invalid value (no ``manifest.json``) skips seeding.
- Otherwise, ``<dir of the chrome binary>/WidevineCdm`` — where a user
naturally drops it, and where it ends up for both downloaded and
CLOAKBROWSER_BINARY_PATH (local build / Docker mount) binaries.
A directory counts only if it contains ``manifest.json`` (so we don't seed a
hint pointing at a bogus path). The returned path is absolute and
symlink-resolved (``Path.resolve()``).
"""
custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM")
# `is not None` (not truthiness): a present-but-empty env var is "set" and
# used exclusively — it resolves to an invalid path and skips seeding.
cdm_dir = Path(custom) if custom is not None else Path(os.fspath(binary_path)).parent / "WidevineCdm"
return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None
def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None:
"""Write the Widevine CDM hint file into a persistent profile before launch.
``binary_path`` is the resolved chrome executable; the CDM is looked for next
to it. No-op on non-Linux platforms, when seeding is disabled via
CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises —
a failure here must not break the browser launch.
"""
if platform.system() != "Linux":
return
if _seeding_disabled():
logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE")
return
if not user_data_dir:
# Empty user_data_dir = Playwright's ephemeral profile (its own temp dir);
# a persistent hint can't be placed there, and "" would pollute the CWD.
return
# Everything below is best-effort and must never break the browser launch,
# so the whole body (resolution + write) is guarded.
try:
cdm_dir = resolve_widevine_cdm_dir(binary_path)
if cdm_dir is None:
if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None:
logger.warning(
"CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; "
"skipping Widevine hint seeding"
)
else:
logger.debug("No sideloaded Widevine CDM found; skipping hint seeding")
return
hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm"
hint_dir.mkdir(parents=True, exist_ok=True)
hint_file = hint_dir / _HINT_FILENAME
# cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False
# byte-match the JS wrapper's JSON.stringify (UTF-8) output.
content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False)
try:
if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content:
return # already seeded correctly
except Exception:
logger.warning("Existing Widevine hint unreadable; rewriting")
hint_file.write_text(content, encoding="utf-8")
logger.info("Seeded Widevine CDM hint -> %s", cdm_dir)
except Exception as e:
logger.warning("Failed to seed Widevine CDM hint file: %s", e)
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# agent-browser + CloakBrowser: AI browser agent with stealth fingerprints.
#
# agent-browser is a Node.js CLI for browser automation with session management.
# CloakBrowser provides the stealth Chromium binary.
#
# Requires: npm install -g agent-browser
# pip install cloakbrowser (to auto-download the binary)
#
# Note: agent-browser launches Chrome itself via env vars — it can't connect
# to an existing browser via CDP. So we pass the binary path and stealth args directly.
# Get CloakBrowser binary path (auto-downloads if needed)
BINARY_PATH=$(python3 -c "from cloakbrowser.download import ensure_binary; print(ensure_binary())")
# Get stealth args from our wrapper (comma-separated for agent-browser)
STEALTH_ARGS=$(python3 -c "from cloakbrowser.config import get_default_stealth_args; print(','.join(get_default_stealth_args()))")
# Point agent-browser at CloakBrowser
export AGENT_BROWSER_EXECUTABLE_PATH="$BINARY_PATH"
export AGENT_BROWSER_ARGS="$STEALTH_ARGS"
# Open a page
agent-browser --session stealth-test open "https://example.com"
# Get page title
agent-browser --session stealth-test eval "document.title"
# Check stealth
agent-browser --session stealth-test eval "JSON.stringify({webdriver: navigator.webdriver, plugins: navigator.plugins.length, platform: navigator.platform})"
@@ -0,0 +1,79 @@
# CloakBrowser on AWS Lambda — derived from the official CloakHQ image.
#
# `FROM cloakhq/cloakbrowser:<tag>` is an official distribution channel under
# the CloakBrowser Binary License — pulling it isn't redistribution. We just
# layer Lambda glue on top: the Lambda Runtime Interface Client (awslambdaric),
# the Lambda Runtime Interface Emulator (for local `docker run` testing), the
# dual-mode entrypoint, and the handler module.
#
# This directory is self-contained — copy/clone it anywhere and build from
# inside it. No files outside this directory are referenced.
#
# ─── Lambda invocation (default CMD) ──────────────────────────────────────────
# # From inside this directory:
# docker buildx build --platform linux/arm64 -t cloakbrowser-lambda:arm64 --load .
#
# # Or from a parent dir, pointing at this directory as the build context:
# docker buildx build --platform linux/arm64 \
# -f path/to/aws_lambda/Dockerfile -t cloakbrowser-lambda:arm64 --load \
# path/to/aws_lambda
#
# docker run --rm -p 9000:8080 cloakbrowser-lambda:arm64
# curl -XPOST http://localhost:9000/2015-03-31/functions/function/invocations \
# -d '{"url":"https://example.com"}'
#
# ─── Same as the canonical CloakHQ image (CMD overridden) ─────────────────────
# docker run --rm -it cloakbrowser-lambda:arm64 python # REPL
# docker run --rm cloakbrowser-lambda:arm64 python examples/basic.py # examples
# docker run --rm -p 9222:9222 cloakbrowser-lambda:arm64 cloakserve --port=9222 # CDP server
# docker run --rm cloakbrowser-lambda:arm64 cloaktest # stealth tests
# docker run --rm -it cloakbrowser-lambda:arm64 node # JS wrapper
# docker run --rm -it cloakbrowser-lambda:arm64 bash # shell
#
# Pin a specific tag (e.g. cloakhq/cloakbrowser:0.3.25) for reproducible builds;
# `latest` floats with CloakHQ's release cadence.
FROM cloakhq/cloakbrowser:latest
# ─── Lambda Runtime Interface Client ──────────────────────────────────────────
RUN pip install --no-cache-dir awslambdaric
# ─── Lambda Runtime Interface Emulator (local `docker run` testing) ───────────
# Bundled into the image so users can hit the standard local-invoke endpoint
# without mounting the RIE separately. TARGETARCH is provided by buildx.
ARG TARGETARCH
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-${TARGETARCH} \
/usr/local/bin/aws-lambda-rie
RUN chmod +x /usr/local/bin/aws-lambda-rie
# ─── Lambda glue ──────────────────────────────────────────────────────────────
# Dual-mode entrypoint replaces the canonical bin/docker-entrypoint.sh: same
# Xvfb startup, plus routing for `module.func` CMDs through awslambdaric.
COPY lambda-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Handler sits at /app (already on Python's import path in the canonical image,
# WORKDIR=/app), imports cloakbrowser as a normal library.
COPY lambda_handler.py /app/lambda_handler.py
# ─── Lambda non-root readability fix ──────────────────────────────────────────
# The canonical image bakes the Chromium binary at /root/.cloakbrowser/ (root's
# HOME at build time). Lambda runs the container as a non-root user that can't
# read /root by default (mode 750). Make the whole binary tree world-readable
# and traversable. Also restore the .welcome_shown marker the canonical image
# rm's (Lambda's read-only runtime FS can't recreate it, so the welcome would
# print to CloudWatch on every cold start otherwise).
RUN touch /root/.cloakbrowser/.welcome_shown \
&& chmod -R o+rX /root /root/.cloakbrowser
# ─── Lambda runtime env ───────────────────────────────────────────────────────
# HOME=/tmp gives Chromium a writable scratch dir (Lambda only allows writes
# under /tmp). CLOAKBROWSER_CACHE_DIR points at the baked binary location since
# HOME=/tmp would otherwise make get_cache_dir() resolve to /tmp/.cloakbrowser
# (empty). Auto-update is disabled because the runtime FS is read-only.
ENV HOME=/tmp \
CLOAKBROWSER_CACHE_DIR=/root/.cloakbrowser \
CLOAKBROWSER_AUTO_UPDATE=false
ENTRYPOINT ["/entrypoint.sh"]
CMD ["lambda_handler.handler"]
@@ -0,0 +1,194 @@
# CloakBrowser on AWS Lambda
Run stealth Chromium one-shot scrapes inside an AWS Lambda function (container image package type). The image derives directly from the official CloakHQ Docker Hub image (`cloakhq/cloakbrowser`) and adds Lambda runtime support on top — Lambda is an additional invocation surface, not a replacement. Every other surface from the canonical image (`python`, `cloakserve`, `cloaktest`, `node`, `bash`, examples) keeps working.
This document covers what the image is, how to build and locally test it, and the event/response contract. **It does not prescribe a deployment method** — push the resulting image to ECR and create the Lambda function however you prefer (AWS CLI, CDK, Terraform, SAM, console, etc.). Configuration tips for whichever tool you use are at the bottom.
## Files in this directory
| File | Purpose |
|---|---|
| `Dockerfile` | `FROM cloakhq/cloakbrowser` plus a thin Lambda layer. Self-contained — no files outside this directory are referenced. |
| `lambda-entrypoint.sh` | Dual-mode entrypoint. Starts Xvfb, then routes `module.func` CMDs through `awslambdaric` (via the bundled `aws-lambda-rie` locally, or the AWS Runtime API in production), and execs everything else (`python`, `cloakserve`, `cloaktest`, `node`, `bash`) directly. |
| `lambda_handler.py` | Default handler. Takes `{url, ...}`, returns `{title, url, html, screenshot_b64?}`. Always headed via Xvfb. |
| `INSTRUCTIONS.md` | This file. |
The Lambda layer is ~30 lines on top of the official image — no apt list, no Node install, no JS-wrapper build, no Chromium download. The canonical CloakHQ image owns those.
This directory is **standalone**: copy or clone it anywhere (its own repo, a subdirectory of an existing project, a CI artifact bundle) and the build still works. It depends only on the upstream `cloakhq/cloakbrowser` image on Docker Hub and the `aws-lambda-rie` binary on GitHub Releases — both fetched at build time.
## Build
From inside this directory:
```bash
docker buildx build --platform linux/arm64 -t cloakbrowser-lambda:arm64 --load .
```
Or from anywhere, pointing at this directory as the build context:
```bash
docker buildx build --platform linux/arm64 \
-f path/to/aws_lambda/Dockerfile \
-t cloakbrowser-lambda:arm64 --load \
path/to/aws_lambda
```
The build pulls `cloakhq/cloakbrowser:latest` from Docker Hub and adds the Lambda layer on top. Pin a specific tag (e.g. `cloakhq/cloakbrowser:0.3.25`) in the `FROM` line for reproducible builds; `latest` floats with the upstream release cadence.
For x86_64, switch `--platform linux/amd64` (slower on Apple Silicon under emulation).
## Local smoke test (no AWS account needed)
> **What's the RIE?** Lambda container images can't be run with a plain `docker run` — they expect to talk to AWS's Runtime API (the HTTP service Lambda exposes inside its sandbox to deliver events and collect responses). AWS publishes a small binary called the **Runtime Interface Emulator** that stands up a fake Runtime API on localhost so you can test the container exactly the way Lambda will invoke it, without deploying. We bake the RIE into the image, and the dual-mode entrypoint uses it automatically when `AWS_LAMBDA_RUNTIME_API` isn't set (i.e. you're not running in real Lambda).
The image bakes in `aws-lambda-rie`, so the standard Lambda local-invoke endpoint works without mounting anything:
```bash
docker run --rm -p 9000:8080 cloakbrowser-lambda:arm64
# In another shell:
curl -sS -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" \
-d '{"url":"https://example.com"}'
```
Other invocation surfaces stay intact (these match the canonical CloakHQ image):
```bash
docker run --rm -it cloakbrowser-lambda:arm64 python # REPL
docker run --rm cloakbrowser-lambda:arm64 python examples/basic.py # examples
docker run --rm -p 9222:9222 cloakbrowser-lambda:arm64 cloakserve --port=9222 # CDP server
docker run --rm cloakbrowser-lambda:arm64 cloaktest # stealth tests
docker run --rm -it cloakbrowser-lambda:arm64 node # JS wrapper
```
## Event schema
Only `url` is required. Everything else is optional.
### Launch options (forwarded to `cloakbrowser.launch_context_async`)
| Field | Type | Default |
|---|---|---|
| `url` | str | required — `http://` and `https://` only |
| `proxy` | str / dict | none — `http://user:pass@host:port` or a Playwright proxy dict |
| `humanize` | bool | `false` — enable human-like mouse / keyboard / scroll |
| `human_preset` | str | `"default"` or `"careful"` |
| `geoip` | bool | `false` — auto timezone+locale from proxy IP |
| `timezone` | str | none — IANA tz, e.g. `"America/New_York"` |
| `locale` | str | none — BCP-47, e.g. `"en-US"` |
| `viewport` | `{width,height}` | `1920x947` (cloakbrowser default) |
| `user_agent` | str | none |
### Navigation
| Field | Type | Default |
|---|---|---|
| `wait_until` | str | `"domcontentloaded"``load` / `domcontentloaded` / `networkidle` / `commit` |
| `goto_timeout_ms` | int | `30000` |
### Post-navigation waits
`smart_wait` is the default when no other wait is specified. It polls `document.documentElement.outerHTML.length` and returns when the size hasn't changed for `dom_stable_ms`. Robust for at-scale scraping because it ignores network activity (analytics beacons, long-poll, websockets) that doesn't mutate the DOM — `wait_until: "networkidle"` is unreliable on modern SPAs for exactly this reason.
| Field | Type | Default |
|---|---|---|
| `smart_wait` | bool | `true` if no other wait is set |
| `dom_stable_ms` | int | `1500` |
| `max_settle_ms` | int | `15000` |
| `wait_for_load_state` | str | none — `load` / `domcontentloaded` / `networkidle` |
| `wait_for_load_state_timeout_ms` | int | `30000` |
| `wait_for_selector` | str | none — CSS or XPath |
| `wait_for_selector_state` | str | `"visible"` — also `attached` / `detached` / `hidden` |
| `wait_for_selector_timeout_ms` | int | `30000` |
| `wait_ms` | int | none — fixed pause |
### Capture
| Field | Type | Default |
|---|---|---|
| `screenshot` | bool | `true` |
| `full_page_screenshot` | bool | `false` |
### Retry orchestration
The handler retries transient navigation failures inline within the same Lambda invocation. Two layers, both built-in:
- **Launch retries** — 3 attempts with 0.3 s + 0.6 s backoff. Recovers Xvfb / Chromium spawn races at cold start. Fast and cheap; not configurable.
- **Strategy retries** — default 1 attempt, configurable via the `retries` event field. Recovers specific post-launch error classes by relaunching with adjusted internal Chromium args / page-load budgets.
| Field | Type | Default |
|---|---|---|
| `retries` | int | `1` — number of strategy-retry attempts after the first failure. Set to `0` to disable retry entirely. |
Strategies (priority order — first match wins):
| Error pattern | Strategy applied |
|---|---|
| `ERR_CERT_*` (any cert error) | `extra_args: ["--ignore-certificate-errors"]`, `goto_timeout_ms: 60000` |
| `Timeout … exceeded` | `goto_timeout_ms: 90000`, `max_settle_ms: 25000` |
| `ERR_CONNECTION_TIMED_OUT` | same as `Timeout … exceeded` |
Errors that are **not retried** (no anonymous scraper can recover): `ERR_NAME_NOT_RESOLVED`, `ERR_SSL_PROTOCOL_ERROR`, `ERR_CONNECTION_REFUSED`, `ERR_HTTP_RESPONSE_CODE_FAILURE`. These bail immediately.
On final failure, the raised `RuntimeError`'s message includes a `retry_history` block listing every attempt (strategy applied + error seen). Successful invocations return the standard response shape unchanged — no surprise fields when retries didn't fire.
### Response
```json
{
"title": "...",
"url": "https://example.com/",
"html": "<!DOCTYPE html>...",
"screenshot_b64": "<base64 PNG>"
}
```
## Lambda-specific Chromium hardening (baked in, do not remove)
Two flags are forced on every launch by `lambda_handler.py`:
- `--disable-dev-shm-usage` — Lambda's `/dev/shm` is ~64 MB; Chromium's renderer crashes mid-paint without this.
- `--no-zygote` — Lambda's restricted process model can't fork from Chromium's zygote process; without this the browser launches but child renderers fail to spawn and the first `page.new_page()` raises `TargetClosedError`.
## Function configuration recommendations
Whatever tool you use to create the Lambda function (CLI, CDK, Terraform, SAM, console), apply these settings:
| Setting | Value | Why |
|---|---|---|
| Package type | Image | Required — this is a container image, not a zip. |
| Architecture | `arm64` | Roughly 20% cheaper than x86_64. Native build on Apple Silicon. Match the architecture you built for. |
| Memory | 3008 MB | Memory in Lambda is tied to vCPU. Below ~1769 MB Chromium starts noticeably slower. |
| Timeout | 120180 s | Single-attempt scrapes complete in 315 s warm; under retry, a `Timeout`-class first failure (30 s default) plus a longer-budget retry (90 s) plus cleanup can total ~120-130 s. 180 s leaves headroom; below 120 s the function will time out before the retry completes. Cold-start init adds 5-10 s on top. |
| Ephemeral storage (`/tmp`) | 1024 MB | Chromium profile dirs and screenshots can fill the 512 MB default. |
| Networking | Default (no VPC) | Binary is baked in, no network needed at cold start. Add VPC + NAT only if your proxy egress requires it. |
| Execution role | `AWSLambdaBasicExecutionRole` | Just CloudWatch Logs. Add more permissions only if your handler needs them. |
## Cold start
First invocation in a new container takes ~8090 s (image extraction, Chromium binary mmap, JS engine warmup, no DNS/TLS caches). Subsequent warm invocations on the same container are 315 s.
For latency-sensitive use cases: provision concurrency, schedule a CloudWatch/EventBridge warmer ping, or accept the cold tail.
If you see empty/missing dynamic content on cold-start invocations, raise `max_settle_ms` in the event payload (e.g. `25000`) — the default `15000` is tuned for warm runs.
## Security
The handler validates all incoming URLs before navigation:
- **Scheme restriction** — only `http://` and `https://` are accepted. `file://`, `data:`, `javascript:`, and other schemes are rejected.
- **SSRF protection** — hostnames are resolved before navigation and checked against private, loopback, link-local, reserved, and multicast IP ranges. This blocks access to cloud metadata endpoints (e.g. `169.254.169.254`), localhost services, and internal networks.
- **Post-navigation re-validation** — the final URL is re-checked after page load and after post-navigation waits to catch server-side redirects to blocked destinations.
- **No caller-controlled Chromium flags** — the handler does not accept arbitrary CLI flags from the event. Internal retry strategies add flags as needed (e.g. `--ignore-certificate-errors` for cert errors).
- **No arbitrary JS execution** — `wait_for_function` is not exposed. Use `wait_for_selector` or `smart_wait` instead.
**Limitations**:
- Post-navigation re-validation prevents response *exfiltration*, but does not prevent the browser from *making* the request. If an internal endpoint has side effects on GET, the request will still reach it before validation rejects the response. Use network-level controls (security groups, VPC) to protect side-effect-bearing internal endpoints.
- DNS rebinding attacks can bypass pre-navigation IP checks in theory, though the post-navigation re-validation provides a second layer of defense.
**Trust boundary**: if this handler is exposed to untrusted callers (Lambda Function URL, API Gateway without auth, public ALB), add an authentication layer (API Gateway authorizer, IAM auth, etc.). The URL validation above is defense-in-depth, not a substitute for access control.
## License
The patched Chromium binary inside the upstream `cloakhq/cloakbrowser` image is governed by the **CloakBrowser Binary License** (published at https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md). Internal organizational use (private ECR, your own scraping pipelines, your own business) is free. Exposing this Lambda as a paid API to third-party customers — i.e. browser-as-a-service — requires an OEM/SaaS license from CloakHQ (`cloakhq@pm.me`). Do not push the resulting image to a public registry; that would be redistribution and is prohibited.
@@ -0,0 +1,52 @@
#!/bin/sh
# Dual-mode entrypoint for the CloakBrowser Lambda image.
#
# 1. Always start Xvfb on :99 (same as the canonical bin/docker-entrypoint.sh)
# so headed Chromium works no matter how the container is invoked.
# 2. Detect whether the CMD looks like a Lambda handler (a single
# `module.func`-shaped argument). If yes, route through the Lambda runtime
# client (using the bundled aws-lambda-rie locally, or talking to the real
# Lambda Runtime API when AWS_LAMBDA_RUNTIME_API is set in production).
# 3. Otherwise exec the CMD directly — preserving the canonical Dockerfile's
# interaction surface (`python`, `cloakserve`, `cloaktest`, `node`, `bash`,
# `python examples/basic.py`, etc.).
set -e
mkdir -p /tmp/.X11-unix
chmod 1777 /tmp/.X11-unix 2>/dev/null || true
# Clean any stale Xvfb state. If a previous Xvfb died and left its lock file
# behind (we observed this in cold-start storms), a new Xvfb refuses to start
# with "Server is already active for display 99". Removing both files makes
# Xvfb start cleanly every time.
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/tmp/Xvfb.log 2>&1 &
# Wait for the X11 socket to appear AND for Xvfb to be ready to serve. The
# socket file appears at bind(), but listen() and the first accept() come
# slightly later — under cold-start CPU contention this gap matters.
i=0
while [ ! -e /tmp/.X11-unix/X99 ] && [ "$i" -lt 200 ]; do
i=$((i + 1))
sleep 0.05
done
# Small buffer after the socket appears so Xvfb has a moment to call listen()
# and start accepting clients. Cheap insurance against the bind/listen gap.
sleep 0.2
# Lambda handler shape: exactly one arg, dotted identifier (no spaces, no slashes,
# no leading dot). `python`, `cloakserve`, `cloaktest`, `bash`, `node` all fail
# this test and pass through to plain exec.
if [ $# -eq 1 ] && \
echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)+$'; then
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
# Local invocation via bundled RIE.
exec /usr/local/bin/aws-lambda-rie /usr/local/bin/python -m awslambdaric "$@"
else
# Real Lambda — runtime API endpoint already provided by the platform.
exec /usr/local/bin/python -m awslambdaric "$@"
fi
fi
exec "$@"
@@ -0,0 +1,355 @@
"""AWS Lambda handler for one-off stealth-browser invocations.
Always runs **headed** via the Xvfb display started by `lambda-entrypoint.sh`.
Event schema (all fields except `url` are optional):
Launch options (passed to cloakbrowser.launch_context_async):
url str required, the page to scrape (http/https only)
proxy str|dict http://user:pass@host:port or Playwright proxy dict
humanize bool False — enable human-like mouse/keyboard/scroll
human_preset str "default" | "careful"
geoip bool False — auto timezone+locale from proxy IP
timezone str IANA tz, e.g. "America/New_York"
locale str BCP-47, e.g. "en-US"
viewport {width,height} defaults to 1920x947 (cloakbrowser DEFAULT_VIEWPORT)
user_agent str custom UA (rare — cloakbrowser sets one already)
Navigation options (passed to page.goto):
wait_until str "load"|"domcontentloaded"|"networkidle"|"commit"
default "domcontentloaded"
goto_timeout_ms int 30000
Post-navigation waits (run in this order if specified):
smart_wait bool ON by default if no other wait is set.
Polls document.outerHTML.length and bails when it
hasn't changed for `dom_stable_ms`. Handles lazy
hydration, async chunks, and lazy images, and is
immune to analytics beacons / long-poll that keep
the network busy without mutating the DOM.
dom_stable_ms int 1500 — how long DOM must be quiet
max_settle_ms int 15000 — hard cap on smart_wait
wait_for_load_state str "load"|"domcontentloaded"|"networkidle"
wait_for_load_state_timeout_ms int 30000
wait_for_selector str CSS or XPath selector
wait_for_selector_state str "attached"|"detached"|"visible"|"hidden", default "visible"
wait_for_selector_timeout_ms int 30000
wait_ms int fixed pause in ms (page.wait_for_timeout)
Capture options:
screenshot bool True
full_page_screenshot bool False — capture entire scrollable page
Retry orchestration:
retries int default 1. Number of retry attempts after the first
failure. Set to 0 to disable retries entirely (the
handler will fail fast on the first error).
Retried errors:
ERR_CERT_* -> retry with --ignore-certificate-errors
Timeout exceeded -> retry with goto_timeout_ms=90000, max_settle_ms=25000
ERR_CONNECTION_TIMED_OUT -> same as Timeout
Not retried (unrecoverable): ERR_NAME_NOT_RESOLVED,
ERR_SSL_PROTOCOL_ERROR, generic ERR_CONNECTION_REFUSED.
On final failure, the error message includes a
retry_history block with strategy + error per attempt.
Returns:
{"title": ..., "url": ..., "html": ..., "screenshot_b64"?: ...}
"""
from __future__ import annotations
import asyncio
import base64
import ipaddress
import json
import logging
import socket
import subprocess
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from cloakbrowser import launch_context_async
logger = logging.getLogger("cloakbrowser.lambda")
logger.setLevel(logging.INFO)
def _validate_url(url: str) -> None:
"""Reject non-HTTP schemes and URLs that resolve to private/internal IPs."""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(
f"Only http:// and https:// URLs are supported, got: {parsed.scheme!r}"
)
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for info in infos:
addr = ipaddress.ip_address(info[4][0])
if not addr.is_global:
raise ValueError("URLs targeting private/internal networks are blocked")
def _diag_snapshot() -> str:
"""Capture Xvfb status, Xvfb log, X11 socket state, and env for error reports."""
import os
parts = []
try:
r = subprocess.run(["pgrep", "-fa", "Xvfb"], capture_output=True, text=True)
parts.append(f"pgrep Xvfb: rc={r.returncode} stdout={r.stdout.strip()!r}")
except Exception as e:
parts.append(f"pgrep failed: {e}")
try:
r = subprocess.run(["ls", "-la", "/tmp/.X11-unix"], capture_output=True, text=True)
parts.append(f"ls /tmp/.X11-unix:\n{r.stdout}{r.stderr}")
except Exception as e:
parts.append(f"ls /tmp/.X11-unix failed: {e}")
try:
log = Path("/tmp/Xvfb.log").read_text()
parts.append(f"/tmp/Xvfb.log:\n{log}")
except Exception as e:
parts.append(f"Xvfb log unreadable: {e}")
parts.append(f"env: DISPLAY={os.environ.get('DISPLAY')!r} HOME={os.environ.get('HOME')!r}")
return "\n".join(parts)
def handler(event: dict, context: Any) -> dict:
return asyncio.run(_run(event))
def _build_launch_kwargs(event: dict) -> dict:
"""Translate the event dict into kwargs for launch_context_async.
Only includes keys explicitly set in the event so cloakbrowser's defaults
(DEFAULT_VIEWPORT etc.) kick in when fields are absent — passing
viewport=None would *disable* viewport emulation, which we don't want.
"""
kwargs: dict = {
"headless": False, # always headed via Xvfb
"args": [
# Lambda /dev/shm is ~64 MB — Chromium crashes mid-render without this.
"--disable-dev-shm-usage",
# Lambda's restricted process model can't fork from Chromium's zygote
# — without this, child renderer processes fail to spawn.
"--no-zygote",
*event.get("_strategy_args", []),
],
}
for key in ("proxy", "humanize", "human_preset", "geoip",
"timezone", "locale", "viewport", "user_agent"):
if key in event:
kwargs[key] = event[key]
return kwargs
async def _smart_wait(page, dom_stable_ms: int = 1500, max_settle_ms: int = 15000) -> None:
"""Wait until the document HTML hasn't changed for `dom_stable_ms`.
Generic stopping condition for at-scale scraping when you can't tune
selectors per site. More robust than `networkidle` because it ignores
network activity that doesn't mutate the DOM (analytics beacons,
long-poll, websockets, web vitals streams).
"""
js = f"""
(() => {{
if (!window.__cb_settle) {{
window.__cb_settle = {{ len: -1, since: Date.now() }};
}}
const cur = document.documentElement.outerHTML.length;
const s = window.__cb_settle;
if (cur !== s.len) {{
s.len = cur;
s.since = Date.now();
return false;
}}
return (Date.now() - s.since) >= {int(dom_stable_ms)};
}})()
"""
try:
await page.wait_for_function(js, timeout=max_settle_ms, polling=200)
except Exception:
# Hit max_settle_ms cap — return what we have rather than fail the whole invoke
logger.warning("smart_wait hit max_settle_ms=%d cap", max_settle_ms)
_EXPLICIT_WAIT_KEYS = (
"wait_for_load_state", "wait_for_selector", "wait_ms",
)
async def _post_nav_waits(page, event: dict) -> None:
"""Run waits in priority order. smart_wait is the default unless the
caller asked for a more specific stopping condition."""
explicit = any(k in event for k in _EXPLICIT_WAIT_KEYS)
if event.get("smart_wait", not explicit):
await _smart_wait(
page,
dom_stable_ms=event.get("dom_stable_ms", 1500),
max_settle_ms=event.get("max_settle_ms", 15000),
)
if "wait_for_load_state" in event:
await page.wait_for_load_state(
event["wait_for_load_state"],
timeout=event.get("wait_for_load_state_timeout_ms", 30000),
)
if "wait_for_selector" in event:
await page.wait_for_selector(
event["wait_for_selector"],
state=event.get("wait_for_selector_state", "visible"),
timeout=event.get("wait_for_selector_timeout_ms", 30000),
)
if "wait_ms" in event:
await page.wait_for_timeout(event["wait_ms"])
async def _launch_with_retry(event: dict, attempts: int = 3, backoff_s: float = 0.3):
"""Retry launch_context_async up to `attempts` times with linear backoff.
Lambda cold-start storms occasionally race Xvfb readiness or hit transient
Chromium spawn failures — both surface as "Target page, context or browser
has been closed" at launch. The failure is fast (~0.5s) so retries are
cheap, and a retry on a now-warm container almost always succeeds.
Pairs with the lock-cleanup + socket-poll in lambda-entrypoint.sh: the
entrypoint catches the common case at container init; this catches the
residual race when the first invocation hits before Xvfb is fully ready.
"""
last_err: Exception | None = None
for i in range(attempts):
try:
return await launch_context_async(**_build_launch_kwargs(event))
except Exception as e:
last_err = e
logger.warning("launch attempt %d/%d failed: %s",
i + 1, attempts, str(e)[:200])
if i + 1 < attempts:
await asyncio.sleep(backoff_s * (i + 1)) # 0.3s, 0.6s
raise last_err # type: ignore[misc]
def _classify_error(err: Exception) -> dict | None:
"""Map a Playwright error to a retry-strategy override dict, or None
if the error is unrecoverable.
Match on str(e) because Playwright errors carry their codes inside the
message (Error.__str__ includes ERR_CERT_AUTHORITY_INVALID etc.); there
is no stable structured `.error_code` attribute to rely on.
Strategies (priority order — first match wins):
ERR_CERT_* -> --ignore-certificate-errors + 60s goto budget
Timeout exceeded -> 90s goto budget + 25s smart_wait cap
ERR_CONNECTION_TIMED_OUT -> same as Timeout
Returns None for unrecoverable site issues (DNS, SSL, refused, HTTP 4xx/5xx).
"""
msg = str(err)
if "ERR_CERT" in msg:
return {
"_strategy_args": ["--ignore-certificate-errors"],
"goto_timeout_ms": 60000,
}
if ("Timeout" in msg and "exceeded" in msg) or "ERR_CONNECTION_TIMED_OUT" in msg:
return {
"goto_timeout_ms": 90000,
"max_settle_ms": 25000,
}
return None
async def _attempt_scrape(url: str, event: dict) -> dict:
"""One self-contained scrape attempt: launch, navigate, wait, capture, close.
Extracted from `_run` so the retry loop can call it repeatedly with an
overridden event dict. Each attempt relaunches the browser — uniform
behavior across strategies (the cert-bypass strategy *requires* a relaunch
because `--ignore-certificate-errors` is a Chromium CLI arg, not a per-
context switch), and the ~3-5s relaunch cost is fine on the slow path.
"""
ctx = await _launch_with_retry(event)
try:
page = await ctx.new_page()
await page.goto(
url,
wait_until=event.get("wait_until", "domcontentloaded"),
timeout=event.get("goto_timeout_ms", 30000),
)
_validate_url(page.url)
await _post_nav_waits(page, event)
_validate_url(page.url)
result: dict = {
"title": await page.title(),
"url": page.url,
"html": await page.content(),
}
if event.get("screenshot", True):
png = await page.screenshot(
full_page=event.get("full_page_screenshot", False),
)
result["screenshot_b64"] = base64.b64encode(png).decode()
return result
finally:
try:
await ctx.close()
except Exception:
pass
def _raise_with_history(err: Exception, history: list[dict]) -> None:
"""Surface a final failure with a retry_history block embedded in the
error message, so callers see what was tried before bailing."""
diag = _diag_snapshot()
if history:
diag = "retry_history: " + json.dumps(history, default=str) + "\n\n" + diag
logger.error("scrape failed (after %d retries): %s\nDIAG:\n%s",
len(history), err, diag)
raise RuntimeError(f"scrape failed: {err}\n--- DIAG ---\n{diag}") from err
async def _run(event: dict) -> dict:
"""Top-level scrape with strategy-based retry orchestration.
First attempt uses the event verbatim. If it fails with a classifiable
error (cert / timeout), retry with that strategy's overrides merged into
the event. `retries` bounds the number of strategy retries (default 1;
set to 0 to disable retry entirely).
"""
url = event["url"]
_validate_url(url)
event = {k: v for k, v in event.items() if k not in ("extra_args", "_strategy_args")}
retries_left = max(0, int(event.get("retries", 1)))
history: list[dict] = []
current_event = event
while True:
try:
return await _attempt_scrape(url, current_event)
except Exception as e:
if retries_left <= 0:
_raise_with_history(e, history)
strategy = _classify_error(e)
if strategy is None:
_raise_with_history(e, history)
history.append({
"attempt": len(history) + 1,
"error": str(e)[:300],
"strategy": strategy,
})
logger.warning("attempt %d failed (%s); retrying with strategy=%s",
len(history), str(e)[:120], strategy)
merged_args = list(current_event.get("_strategy_args", [])) + list(strategy.get("_strategy_args", []))
current_event = {**current_event, **strategy, "_strategy_args": merged_args}
retries_left -= 1
# No backoff: strategy overrides change goto budget directly;
# the prior failure was either fast (cert reject) or already
# waited its full timeout. Container is warm.
@@ -0,0 +1,41 @@
"""browser-use + CloakBrowser: AI agent with stealth fingerprints.
browser-use handles AI agent logic, CloakBrowser handles bot detection.
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
Requires: pip install browser-use cloakbrowser
Set OPENAI_API_KEY (or swap for another LLM provider).
"""
import asyncio
from browser_use import Agent, BrowserSession, ChatOpenAI
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser (handles binary, stealth args, fingerprints)
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9242", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect browser-use to the stealth browser via CDP
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
# Step 3: Run your AI agent — it browses through CloakBrowser
agent = Agent(
task="Go to https://www.google.com and search for 'browser automation'",
llm=ChatOpenAI(model="gpt-4o-mini"),
browser_session=session,
)
result = await agent.run()
print(result)
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+39
View File
@@ -0,0 +1,39 @@
"""Crawl4AI + CloakBrowser: LLM-ready web crawling with stealth fingerprints.
Crawl4AI handles extraction and markdown conversion,
CloakBrowser handles bot detection.
Requires: pip install crawl4ai cloakbrowser
"""
import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9243", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect Crawl4AI to the stealth browser via CDP
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
run_config = CrawlerRunConfig()
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(
"https://example.com",
config=run_config,
)
print(f"Extracted {len(result.markdown)} chars of markdown")
print(result.markdown[:500])
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+72
View File
@@ -0,0 +1,72 @@
"""Crawlee + CloakBrowser: stealth web crawling with PlaywrightCrawler.
Uses a custom BrowserPlugin to swap Crawlee's default Chromium
for CloakBrowser's patched binary with source-level fingerprint patches.
Requires: pip install cloakbrowser "crawlee[playwright]"
"""
import asyncio
from cloakbrowser.config import IGNORE_DEFAULT_ARGS, get_default_stealth_args
from cloakbrowser.download import ensure_binary
from typing_extensions import override
from crawlee.browsers import (
BrowserPool,
PlaywrightBrowserController,
PlaywrightBrowserPlugin,
)
from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext
class CloakBrowserPlugin(PlaywrightBrowserPlugin):
"""Browser plugin that uses CloakBrowser's patched Chromium,
but otherwise keeps the functionality of PlaywrightBrowserPlugin.
"""
@override
async def new_browser(self) -> PlaywrightBrowserController:
if not self._playwright:
raise RuntimeError('Playwright browser plugin is not initialized.')
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
# Merge CloakBrowser stealth args with any user-provided launch options.
launch_options = dict(self._browser_launch_options)
launch_options.pop('executable_path', None)
launch_options.pop('chromium_sandbox', None)
existing_args = list(launch_options.pop('args', []))
launch_options['args'] = [*existing_args, *stealth_args]
return PlaywrightBrowserController(
browser=await self._playwright.chromium.launch(
executable_path=binary_path,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**launch_options,
),
max_open_pages_per_browser=1,
# CloakBrowser handles fingerprints at the binary level.
header_generator=None,
)
async def main() -> None:
crawler = PlaywrightCrawler(
max_requests_per_crawl=10,
browser_pool=BrowserPool(plugins=[CloakBrowserPlugin()]),
)
@crawler.router.default_handler
async def request_handler(context: PlaywrightCrawlingContext) -> None:
context.log.info(f'Processing {context.request.url} ...')
title = await context.page.title()
await context.push_data({'url': context.request.url, 'title': title})
await context.enqueue_links()
await crawler.run(['https://example.com'])
if __name__ == '__main__':
asyncio.run(main())
+51
View File
@@ -0,0 +1,51 @@
"""LangChain + CloakBrowser: load web pages behind bot detection into LangChain Documents.
LangChain's PlaywrightURLLoader hardcodes chromium.launch() with no way to pass
a custom binary. This example uses CloakBrowser directly as a stealth document loader
that produces LangChain Document objects.
Requires: pip install langchain-core cloakbrowser
"""
import asyncio
from langchain_core.documents import Document
from cloakbrowser import launch_async
async def load_urls_stealth(urls: list[str], **launch_kwargs) -> list[Document]:
"""Load URLs using CloakBrowser stealth browser, return LangChain Documents."""
browser = await launch_async(headless=True, **launch_kwargs)
page = await browser.new_page()
docs = []
for url in urls:
await page.goto(url, wait_until="domcontentloaded")
text = await page.evaluate("document.body.innerText")
title = await page.title()
docs.append(Document(
page_content=text,
metadata={"source": url, "title": title},
))
await browser.close()
return docs
async def main():
urls = [
"https://example.com",
"https://httpbin.org/html",
]
docs = await load_urls_stealth(urls)
for doc in docs:
print(f"--- {doc.metadata['title']} ({doc.metadata['source']}) ---")
print(doc.page_content[:300])
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,42 @@
"""Scrapling + CloakBrowser: adaptive web scraping with stealth fingerprints.
Scrapling handles parsing and element tracking,
CloakBrowser handles bot detection.
Requires: pip install scrapling[all] cloakbrowser
"""
import asyncio
import json
from urllib.request import urlopen
from scrapling.fetchers import StealthyFetcher
from cloakbrowser import launch_async
async def main():
# Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9245", "--remote-debugging-address=127.0.0.1"],
)
# Get the WebSocket URL from Chrome (Scrapling requires ws:// scheme)
info = json.loads(urlopen("http://127.0.0.1:9245/json/version").read())
ws_url = info["webSocketDebuggerUrl"]
# Connect Scrapling to the stealth browser via CDP
page = await StealthyFetcher.async_fetch(
"https://example.com",
cdp_url=ws_url,
)
print(f"Title: {page.css('title::text').get()}")
print(f"Text: {page.css('p::text').getall()}")
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+41
View File
@@ -0,0 +1,41 @@
"""Selenium + CloakBrowser: use stealth Chromium with Selenium WebDriver.
CloakBrowser provides the binary and stealth args.
Selenium drives it via ChromeDriver.
Requires: pip install selenium cloakbrowser
Note: ChromeDriver version must match Chromium 145.
pip install chromedriver-autoinstaller or download manually.
"""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
options = Options()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(f"Selenium + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
@@ -0,0 +1,40 @@
"""undetected-chromedriver + CloakBrowser: double stealth layer.
undetected-chromedriver patches ChromeDriver detection signals,
CloakBrowser patches the browser fingerprints at the C++ level.
Requires: pip install undetected-chromedriver cloakbrowser
"""
import undetected_chromedriver as uc
from cloakbrowser.config import get_chromium_version, get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
chromium_major = int(get_chromium_version().split(".")[0])
options = uc.ChromeOptions()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = uc.Chrome(options=options, version_main=chromium_major)
driver.get("https://example.com")
print(f"undetected-chromedriver + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
hardwareConcurrency: navigator.hardwareConcurrency,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
+12 -11
View File
@@ -5,7 +5,7 @@ Expected: 0.9 (human-level) with cloakbrowser.
Default Playwright typically scores 0.1-0.3.
"""
import time
import re
from cloakbrowser import launch
@@ -13,19 +13,20 @@ print("Launching stealth browser...", flush=True)
browser = launch(headless=True)
page = browser.new_page()
# Google's official reCAPTCHA v3 demo
# Google's official reCAPTCHA v3 demo — scores automatically on page load.
page.goto("https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php")
page.wait_for_load_state("networkidle")
# Click to trigger reCAPTCHA scoring
button = page.query_selector("button")
if button:
button.click()
time.sleep(3)
# The score renders only after an async token + backend-verify round-trip,
# which can finish *after* "networkidle". Wait for the actual result text
# instead of a proxy signal, or the screenshot races the scoring.
page.wait_for_function(
"() => document.body.innerText.includes('Received response from our backend')",
timeout=20000,
)
# Extract score from page
content = page.content()
print("Page loaded. Check the score in the response.")
# Extract score from the rendered response
match = re.search(r'"score":\s*([0-9.]+)', page.inner_text("body"))
print(f"reCAPTCHA v3 score: {match.group(1) if match else 'not found'}")
print(f"URL: {page.url}")
# Take screenshot as proof
+1 -1
View File
@@ -228,7 +228,7 @@ def main():
print()
print("Launching stealth browser...", flush=True)
browser = launch(headless=not HEADED, proxy=PROXY)
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True)
page = browser.new_page()
# Show browser fingerprint details
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+237
View File
@@ -0,0 +1,237 @@
{
description = "CloakBrowser development shell with Nix-packaged Chromium binaries";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = lib.genAttrs supportedSystems;
packageInfo = {
x86_64-linux = {
platformTag = "linux-x64";
version = "146.0.7680.177.5";
hash = "sha256-ShK83pX6G7G+7ytBq15cJ8Nr544749DayMZNcFIWZw4=";
};
aarch64-linux = {
platformTag = "linux-arm64";
version = "146.0.7680.177.3";
hash = "sha256-i3HOU7T9ExMnMxox+6ODXXGILRm/qr3njdD1OQvRb0U=";
};
};
cloakbrowserBinaryLicense = {
shortName = "cloakbrowser-binary";
fullName = "CloakBrowser Binary License";
url = "https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md";
free = false;
redistributable = false;
};
mkPkgs = system: import nixpkgs {
inherit system;
config.allowUnfree = true;
};
runtimeLibraries = pkgs: with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libGL
libpulseaudio
libxkbcommon
mesa
nspr
nss
pango
systemd
wayland
libx11
libxcb
libxcomposite
libxcursor
libxdamage
libxext
libxfixes
libxi
libxrandr
libxrender
libxscrnsaver
libxshmfence
libxtst
];
fontPackages = pkgs: with pkgs; [
freefont_ttf
ipafont
liberation_ttf
noto-fonts
noto-fonts-cjk-sans
noto-fonts-color-emoji
tlwg
unifont
wqy_zenhei
];
desktopPackages = pkgs: with pkgs; [
adwaita-icon-theme
gsettings-desktop-schemas
xdg-utils
];
mkCloakBrowserChromium = pkgs: system:
let
info = packageInfo.${system} or (throw "CloakBrowser flake package currently supports only x86_64-linux and aarch64-linux.");
archiveName = "cloakbrowser-${info.platformTag}.tar.gz";
chromiumVersion = info.version;
libs = runtimeLibraries pkgs;
desktopDeps = desktopPackages pkgs;
fonts = fontPackages pkgs;
fontsConf = pkgs.makeFontsConf {
fontDirectories = fonts;
};
in
pkgs.stdenvNoCC.mkDerivation {
pname = "cloakbrowser-chromium";
version = chromiumVersion;
src = pkgs.fetchurl {
url = "https://cloakbrowser.dev/chromium-v${chromiumVersion}/${archiveName}";
inherit (info) hash;
};
dontUnpack = true;
nativeBuildInputs = with pkgs; [
autoPatchelfHook
makeWrapper
];
buildInputs = libs ++ desktopDeps;
runtimeDependencies = libs;
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/cloakbrowser" "$out/bin"
tar -xzf "$src" -C "$out/lib/cloakbrowser"
chmod +x "$out/lib/cloakbrowser/chrome"
chmod +x "$out/lib/cloakbrowser/chromedriver"
runHook postInstall
'';
postFixup = ''
makeWrapper "$out/lib/cloakbrowser/chrome" "$out/bin/cloakbrowser-chrome" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libs}" \
--prefix XDG_DATA_DIRS : "$GSETTINGS_SCHEMAS_PATH:$XDG_ICON_DIRS" \
--suffix PATH : "${lib.makeBinPath [ pkgs.xdg-utils ]}" \
--set FONTCONFIG_FILE "${fontsConf}" \
--set CHROME_WRAPPER "cloakbrowser-chrome"
makeWrapper "$out/lib/cloakbrowser/chromedriver" "$out/bin/cloakbrowser-chromedriver" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libs}"
'';
meta = {
description = "Official CloakBrowser patched Chromium binary";
homepage = "https://github.com/CloakHQ/CloakBrowser";
license = cloakbrowserBinaryLicense;
mainProgram = "cloakbrowser-chrome";
platforms = supportedSystems;
sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
};
};
in
{
packages = forAllSystems (system:
let
pkgs = mkPkgs system;
cloakbrowserChromium = mkCloakBrowserChromium pkgs system;
in
{
inherit cloakbrowserChromium;
default = cloakbrowserChromium;
});
apps = forAllSystems (system:
let
cloakbrowserChromium = self.packages.${system}.cloakbrowserChromium;
in
{
default = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
meta.description = "Run CloakBrowser Chromium";
};
cloakbrowser-chrome = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
meta.description = "Run CloakBrowser Chromium";
};
cloakbrowser-chromedriver = {
type = "app";
program = "${cloakbrowserChromium}/bin/cloakbrowser-chromedriver";
meta.description = "Run the CloakBrowser Chromedriver binary";
};
});
devShells = forAllSystems (system:
let
pkgs = mkPkgs system;
cloakbrowserChromium = self.packages.${system}.cloakbrowserChromium;
python = pkgs.python312.withPackages (ps: with ps; [
aiohttp
geoip2
hatchling
httpx
playwright
pytest
pytest-asyncio
socksio
websockets
]);
in
{
default = pkgs.mkShell {
packages = [
cloakbrowserChromium
python
pkgs.cacert
pkgs.curl
pkgs.git
pkgs.jq
pkgs.nodejs_20
pkgs.which
pkgs.xdotool
pkgs.xvfb-run
]
++ runtimeLibraries pkgs
++ fontPackages pkgs;
CLOAKBROWSER_BINARY_PATH = "${cloakbrowserChromium}/bin/cloakbrowser-chrome";
};
});
};
}
+81 -15
View File
@@ -11,12 +11,12 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
- **Free and open source** — no subscriptions, no usage limits
- **Works with any framework** also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
- **Works with any framework** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
## Install
@@ -39,11 +39,24 @@ import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
```
**For sites with anti-bot protection**, add a residential proxy and these flags:
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true, // match timezone + locale to proxy IP
headless: false, // some sites detect headless even with C++ patches
humanize: true, // human-like mouse, keyboard, scroll
});
```
See the [main README](https://github.com/CloakHQ/CloakBrowser#troubleshooting) for site-specific troubleshooting (FingerprintJS, Kasada, reCAPTCHA).
### Puppeteer
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
@@ -53,7 +66,7 @@ import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
```
@@ -63,10 +76,13 @@ await browser.close();
```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy
// With proxy (HTTP or SOCKS5)
const browser = await launch({
proxy: 'http://user:pass@proxy:8080',
});
const browser = await launch({
proxy: 'socks5://user:pass@proxy:1080',
});
// With proxy object (bypass, separate auth fields)
const browser = await launch({
@@ -81,7 +97,7 @@ const browser = await launch({
args: ['--fingerprint=12345'],
});
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
// With timezone and locale
const browser = await launch({
timezone: 'America/New_York',
locale: 'en-US',
@@ -93,7 +109,7 @@ const browser = await launch({
geoip: true,
});
// Browser + context in one call (timezone/locale set both binary flags AND context)
// Browser + context in one call (timezone/locale set via binary flags)
const context = await launchContext({
userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 },
@@ -133,6 +149,17 @@ const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
### CLI
Pre-download the binary or check installation status from the command line:
```bash
npx cloakbrowser install # Download binary with progress output
npx cloakbrowser info # Show version, path, platform
npx cloakbrowser update # Check for and download newer binary
npx cloakbrowser clear-cache # Remove cached binaries
```
### Utilities
```javascript
@@ -175,6 +202,18 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
### Widevine / DRM
The binary supports Widevine, but the CDM is proprietary and can't be redistributed. Sideload it once by copying a `WidevineCdm/` directory from a real Chrome install next to the binary (full steps in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)):
```bash
cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-<version>/WidevineCdm
```
With the CDM in place, `launchPersistentContext()` enables Widevine on the **first** launch — the wrapper auto-seeds the CDM hint file into the profile. This plays DRM-protected video (Netflix, Spotify Web) and makes a persistent profile present as a regular Chrome install to detection services that probe for DRM/EME support. **Linux only.** A sideloaded CDM is the opt-in (no flag); set `CLOAKBROWSER_WIDEVINE_CDM` for a custom path or `CLOAKBROWSER_WIDEVINE=0` to disable. See the [main README](https://github.com/CloakHQ/CloakBrowser#widevine--drm) for details.
## Migrate From Playwright
@@ -192,15 +231,35 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status |
|---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest |
| Linux x86_64 | 146 | 58 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 58 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 146 | 58 | ✅ Latest |
## CloakBrowser Pro
The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed
free-release model:
- **Free (v146)** — free forever on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Unlimited sessions. Works today, goes stale as detection evolves.
- **Pro (latest, v148)** — the newest patches and Chromium upgrades first, so the [test results](#test-results) stay green as anti-bot systems change. Linux + Windows (macOS coming).
Anti-bot detection updates constantly — an older binary degrades within weeks.
Pro keeps you on the build that's actively maintained against it.
Activate with your license key (env var, `licenseKey` option, or `~/.cloakbrowser/license.key`):
```bash
export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx
```
Pro plans → **[cloakbrowser.dev](https://cloakbrowser.dev)**
## Requirements
- Node.js >= 18
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
- Node.js >= 20
- One of: `playwright-core` >= 1.53 or `puppeteer-core` >= 21
## Troubleshooting
@@ -215,6 +274,13 @@ const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
// Load Chrome extensions
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
extensionPaths: ['./my-extension'],
});
```
This also gives you cookie and localStorage persistence across sessions.
@@ -247,13 +313,13 @@ Other tips for maximizing reCAPTCHA scores:
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash
# Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-146.0.7680.177.4/chrome
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-146.0.7680.177.4\chrome.exe
```
## Links
+36
View File
@@ -0,0 +1,36 @@
/**
* Stagehand + CloakBrowser: AI browser automation with stealth fingerprints.
*
* Stagehand handles AI-powered navigation and actions,
* CloakBrowser handles bot detection.
*
* Requires: npm install @browserbasehq/stagehand cloakbrowser
* Set OPENAI_API_KEY for the AI model.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stagehand.ts
*/
import { Stagehand } from "@browserbasehq/stagehand";
import { ensureBinary } from "../src/download.js";
import { getDefaultStealthArgs } from "../src/config.js";
const binaryPath = await ensureBinary();
const stealthArgs = getDefaultStealthArgs();
const stagehand = new Stagehand({
env: "LOCAL",
localBrowserLaunchOptions: {
executablePath: binaryPath,
args: stealthArgs,
headless: true,
},
});
await stagehand.init();
const page = stagehand.context.pages()[0];
await page.goto("https://example.com");
console.log(`Stagehand + CloakBrowser: ${await page.title()}`);
await stagehand.close();
+242 -585
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.11",
"version": "0.4.0",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
@@ -13,8 +13,15 @@
"./puppeteer": {
"types": "./dist/puppeteer.d.ts",
"import": "./dist/puppeteer.js"
},
"./human": {
"types": "./dist/human/index.d.ts",
"import": "./dist/human/index.js"
}
},
"bin": {
"cloakbrowser": "./dist/cli.js"
},
"files": [
"dist"
],
@@ -48,12 +55,13 @@
},
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0"
"playwright-core": ">=1.53.0",
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
},
"peerDependenciesMeta": {
"playwright-core": {
@@ -64,17 +72,21 @@
},
"mmdb-lib": {
"optional": true
},
"socks-proxy-agent": {
"optional": true
}
},
"dependencies": {
"tar": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/node": "^25.9.1",
"mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
"playwright-core": "1.60",
"puppeteer-core": "^25.0.4",
"socks-proxy-agent": "^10.0.0",
"typescript": "^6.0.3",
"vitest": "^1.0.0"
},
"scripts": {
+27 -6
View File
@@ -1,7 +1,7 @@
/**
* Shared argument builder for Playwright and Puppeteer wrappers.
*/
import path from "path";
import type { LaunchOptions } from "./types.js";
import { getDefaultStealthArgs } from "./config.js";
@@ -20,6 +20,15 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(arg.split("=")[0], arg);
}
}
// GPU blocklist bypass:
// - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
// in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
// - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
// Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
// isn't enough. Linux doesn't need it.
if (options.headless === false || process.platform === "win32") {
seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
}
if (options.args) {
for (const arg of options.args) {
const key = arg.split("=")[0];
@@ -38,12 +47,24 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(key, flag);
}
if (options.locale) {
const key = "--lang";
const flag = `${key}=${options.locale}`;
if (seen.has(key)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
for (const k of ["--lang", "--fingerprint-locale"] as const) {
const flag = `${k}=${options.locale}`;
if (seen.has(k)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
}
seen.set(k, flag);
}
seen.set(key, flag);
}
if (options.extensionPaths?.length) {
const absPaths = options.extensionPaths.map(p => path.resolve(p));
const joined = absPaths.join(",");
seen.set("--load-extension", `--load-extension=${joined}`);
seen.set(
"--disable-extensions-except",
`--disable-extensions-except=${joined}`
);
}
return [...seen.values()];
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* CLI for cloakbrowser download and manage the stealth Chromium binary.
*
* Usage:
* npx cloakbrowser install # Download binary (with progress)
* npx cloakbrowser info # Show binary version, path, platform
* npx cloakbrowser update # Check for and download newer binary
* npx cloakbrowser clear-cache # Remove cached binaries
*/
import { ensureBinary, binaryInfo, checkForUpdate, clearCache } from "./download.js";
import { getLocalBinaryOverride, getCacheDir } from "./config.js";
import fs from "node:fs";
const USAGE = `Usage: cloakbrowser <command>
Commands:
install Download the Chromium binary
info Show binary version, path, and platform
update Check for and download a newer binary
clear-cache Remove all cached binaries`;
async function cmdInstall(): Promise<void> {
const binaryPath = await ensureBinary();
console.log(binaryPath);
}
function cmdInfo(): void {
const info = binaryInfo();
const override = getLocalBinaryOverride();
console.log(`Version: ${info.version}`);
console.log(`Platform: ${info.platform}`);
console.log(`Binary: ${info.binaryPath}`);
console.log(`Installed: ${info.installed}`);
console.log(`Cache: ${info.cacheDir}`);
if (override) {
console.log(`Override: ${override} (CLOAKBROWSER_BINARY_PATH)`);
}
}
async function cmdUpdate(): Promise<void> {
console.error("Checking for updates...");
const newVersion = await checkForUpdate();
if (newVersion) {
console.log(`Updated to Chromium ${newVersion}`);
} else {
console.log("Already up to date.");
}
}
function cmdClearCache(): void {
const cacheDir = getCacheDir();
if (!fs.existsSync(cacheDir)) {
console.log("No cache to clear.");
return;
}
clearCache();
console.log("Cache cleared.");
}
async function main(): Promise<void> {
const command = process.argv[2];
if (!command || command === "--help" || command === "-h") {
console.log(USAGE);
process.exit(command ? 0 : 2);
}
try {
switch (command) {
case "install":
await cmdInstall();
break;
case "info":
cmdInfo();
break;
case "update":
await cmdUpdate();
break;
case "clear-cache":
cmdClearCache();
break;
default:
console.error(`Unknown command: ${command}\n`);
console.log(USAGE);
process.exit(2);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
}
main();
+62 -28
View File
@@ -27,15 +27,29 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version.
// ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.3";
export const CHROMIUM_VERSION = "146.0.7680.177.5";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.3",
"linux-x64": "146.0.7680.177.5",
"linux-arm64": "146.0.7680.177.3",
"darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2",
"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
// ---------------------------------------------------------------------------
@@ -85,12 +99,13 @@ export function getCacheDir(): string {
return path.join(os.homedir(), ".cloakbrowser");
}
export function getBinaryDir(version?: string): string {
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
export function getBinaryDir(version?: string, pro = false): string {
const suffix = pro ? "-pro" : "";
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}${suffix}`);
}
export function getBinaryPath(version?: string): string {
const binaryDir = getBinaryDir(version);
export function getBinaryPath(version?: string, pro = false): string {
const binaryDir = getBinaryDir(version, pro);
if (process.platform === "darwin") {
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
}
@@ -144,10 +159,29 @@ export function getFallbackDownloadUrl(version?: string): string {
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
}
export function getEffectiveVersion(): string {
export function getEffectiveVersion(pro = false): string {
const base = getChromiumVersion();
const cacheDir = getCacheDir();
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
if (pro) {
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
try {
if (fs.existsSync(marker)) {
const version = fs.readFileSync(marker, "utf-8").trim();
if (version) {
const binary = getBinaryPath(version, true);
if (fs.existsSync(binary)) {
return version;
}
}
}
} catch {
// Marker unreadable
}
return base;
}
// Free tier: try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
const marker = path.join(cacheDir, name);
try {
@@ -188,12 +222,22 @@ export function getLocalBinaryOverride(): string | undefined {
return process.env.CLOAKBROWSER_BINARY_PATH || undefined;
}
// ---------------------------------------------------------------------------
// Playwright default args to suppress — these leak automation signals.
// --enable-automation: exposes navigator.webdriver = true
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
// producing a distinctive renderer string that no real user browser has
// ---------------------------------------------------------------------------
export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"];
// ---------------------------------------------------------------------------
// Default stealth arguments
// ---------------------------------------------------------------------------
// Default viewport — realistic maximized Chrome on 1080p Windows
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
// Default viewport — used for HEADLESS only (headed launches use no viewport so
// the page tracks the real window). Headless has no window chrome, so a fixed
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
export function getDefaultStealthArgs(): string[] {
@@ -202,27 +246,17 @@ export function getDefaultStealthArgs(): string[] {
const base = [
"--no-sandbox",
"--disable-blink-features=AutomationControlled",
`--fingerprint=${seed}`,
];
if (isMac) {
// macOS: run as native Mac browser — GPU/UA match natively
return [
...base,
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
];
return [...base, "--fingerprint-platform=macos"];
}
// Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, and window size are
// auto-generated by the binary from the seed (v14+).
return [
...base,
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
];
// Linux/Windows: spoof as Windows desktop.
// Screen and window size come from the real display, not this flag (verified:
// identical across seeds), so the wrapper must not emulate a viewport on top in
// headed mode — that would break outerWidth >= innerWidth coherence.
return [...base, "--fingerprint-platform=windows"];
}
+449 -29
View File
@@ -5,7 +5,7 @@
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
@@ -14,6 +14,8 @@ import { extract as tarExtract } from "tar";
import type { BinaryInfo } from "./types.js";
import {
BINARY_SIGNING_PUBKEYS,
CHROMIUM_VERSION,
DOWNLOAD_BASE_URL,
GITHUB_API_URL,
GITHUB_DOWNLOAD_BASE_URL,
@@ -32,10 +34,25 @@ import {
getPlatformTag,
versionNewer,
} from "./config.js";
import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js";
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
/**
* A downloaded binary could not be authenticated (bad/missing signature,
* version mismatch, or checksum failure). Distinct from transient
* download/network errors: a verification failure is a tampering signal and
* MUST surface, never silently fall back to another binary. The Pro routing in
* ensureBinary re-throws this rather than downgrading to the free tier.
*/
export class BinaryVerificationError extends Error {
constructor(message: string) {
super(message);
this.name = "BinaryVerificationError";
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -44,7 +61,7 @@ const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
* Ensure the stealth Chromium binary is available. Download if needed.
* Returns the path to the chrome executable.
*/
export async function ensureBinary(): Promise<string> {
export async function ensureBinary(licenseKey?: string): Promise<string> {
// Check for local override
const localOverride = getLocalBinaryOverride();
if (localOverride) {
@@ -57,6 +74,37 @@ export async function ensureBinary(): Promise<string> {
return localOverride;
}
// Pro license key check (custom download URL overrides Pro path)
const key = resolveLicenseKey(licenseKey);
const effectiveKey = process.env.CLOAKBROWSER_DOWNLOAD_URL ? undefined : key;
if (effectiveKey) {
const info = await validateLicense(effectiveKey);
if (info?.valid) {
// A valid license is entitled to Pro, so Pro failures surface loudly
// rather than silently substituting the older free binary. (A blip during
// a routine update never reaches here: ensureProBinary returns the cached
// Pro binary and updates in the background.)
try {
return await ensureProBinary(effectiveKey);
} catch (e) {
// Authenticity could not be confirmed — surface verbatim.
if (e instanceof BinaryVerificationError) throw e;
// Transient failure with no cached Pro binary to use — surface a clear
// error rather than silently downloading the free binary.
throw new Error(
`Pro binary unavailable: ${e}. Your license is valid but the Pro ` +
`binary could not be downloaded right now. Retry in a moment. To use ` +
`the free binary instead, unset CLOAKBROWSER_LICENSE_KEY.`,
{ cause: e }
);
}
} else if (info) {
console.log(`[cloakbrowser] License validation failed (plan=${info.plan}), using free tier`);
} else {
console.log("[cloakbrowser] License validation unavailable, using free tier");
}
}
// Fail fast if no binary available for this platform
checkPlatformAvailable();
@@ -108,17 +156,31 @@ export function clearCache(): void {
}
}
/** Return info about the current binary installation. */
/**
* Return info about the current binary installation.
*
* tier reflects what is actually installed on disk, not merely whether a license
* is cached a cached license with no Pro binary downloaded yet is still
* effectively running the free binary, and the active key may differ from the
* cached one.
*/
export function binaryInfo(): BinaryInfo {
const effective = getEffectiveVersion();
const binaryPath = getBinaryPath(effective);
// Prefer Pro only if a Pro binary actually exists on disk.
const proVersion = getEffectiveVersion(true);
const proPath = getBinaryPath(proVersion, true);
const isPro = fs.existsSync(proPath) && isExecutable(proPath);
const effective = isPro ? proVersion : getEffectiveVersion(false);
const binaryPath = isPro ? proPath : getBinaryPath(effective, false);
return {
version: effective,
bundledVersion: CHROMIUM_VERSION,
tier: isPro ? "pro" : "free",
platform: getPlatformTag(),
binaryPath,
installed: fs.existsSync(binaryPath),
cacheDir: getBinaryDir(effective),
downloadUrl: getDownloadUrl(effective),
cacheDir: getBinaryDir(effective, isPro),
downloadUrl: isPro ? `${DOWNLOAD_BASE_URL}/api/download/latest` : getDownloadUrl(effective),
};
}
@@ -146,13 +208,14 @@ export async function checkForUpdate(): Promise<string | null> {
function showWelcome(): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return;
console.log();
console.log(" CloakBrowser — stealth Chromium for automation");
console.log(" https://github.com/CloakHQ/CloakBrowser");
console.log();
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.log(" Star us if CloakBrowser helps your project!");
console.log();
console.error();
console.error(" CloakBrowser — stealth Chromium for automation");
console.error(" https://github.com/CloakHQ/CloakBrowser");
console.error();
console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.error(" Donate? https://ko-fi.com/cloakhq");
console.error(" Star us if CloakBrowser helps your project!");
console.error();
try {
fs.mkdirSync(getCacheDir(), { recursive: true });
fs.writeFileSync(marker, "");
@@ -194,10 +257,11 @@ async function downloadAndExtract(version?: string): Promise<void> {
await downloadFile(fallbackUrl, tmpPath);
}
// Verify checksum before extraction
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
await verifyDownloadChecksum(tmpPath, version);
}
// Verify the download before extraction. On the official path this is a
// mandatory, non-bypassable Ed25519 signature check (see
// verifyDownloadChecksum); the skip flag only applies to custom
// self-hosted CLOAKBROWSER_DOWNLOAD_URL setups.
await verifyDownloadChecksum(tmpPath, version);
await extractArchive(tmpPath, binaryDir, binaryPath);
showWelcome();
@@ -209,25 +273,180 @@ async function downloadAndExtract(version?: string): Promise<void> {
}
}
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const checksums = await fetchChecksums(version);
/** @internal Exported for testing only. */
export async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
const tarballName = getArchiveName();
if (!checksums) {
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
// Self-hosted mirror: the pinned signature keys do not apply to a
// third-party server. Preserve the legacy same-origin checksum behavior,
// skippable via CLOAKBROWSER_SKIP_CHECKSUM.
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() === "true") {
console.warn(
"[cloakbrowser] CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL"
);
return;
}
const checksums = await fetchChecksums(version);
if (!checksums) {
console.warn(
"[cloakbrowser] SHA256SUMS not available from custom URL — skipping checksum verification"
);
return;
}
const expectedCustom = checksums.get(tarballName);
if (!expectedCustom) {
console.warn(
`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`
);
return;
}
await verifyChecksum(filePath, expectedCustom);
return;
}
// Official path: signature is the trust root and is non-bypassable.
const manifest = await fetchSignedManifest(version);
if (!manifest) {
throw new Error(
"Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) for " +
"this release — refusing to use an unverified binary. " +
"Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues"
);
}
const { manifestBytes, sigBytes } = manifest;
verifySignature(manifestBytes, sigBytes);
const manifestText = new TextDecoder().decode(manifestBytes);
// Version binding: the signed manifest must declare the version we asked for.
// The signature proves "we made this manifest", not "this is the version you
// requested" — without this check a mirror could serve a genuinely-signed
// older release in place of the requested one (forced downgrade).
const requested = version || getChromiumVersion();
const declared = parseManifestVersion(manifestText);
if (declared !== requested) {
throw new Error(
`Version mismatch in signed SHA256SUMS: requested ${requested}, ` +
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
);
}
const checksums = parseChecksums(manifestText);
const expected = checksums.get(tarballName);
if (!expected) {
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
return;
throw new Error(
`Signature-verified SHA256SUMS has no entry for ${tarballName}` +
`cannot confirm binary integrity.`
);
}
await verifyChecksum(filePath, expected);
}
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
/**
* 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)");
}
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. */
export async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
@@ -285,7 +504,7 @@ async function verifyChecksum(filePath: string, expectedHash: string): Promise<v
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
}
async function downloadFile(url: string, dest: string): Promise<void> {
async function downloadFile(url: string, dest: string, headers?: Record<string, string>): Promise<void> {
console.log(`[cloakbrowser] Downloading from ${url}`);
const controller = new AbortController();
@@ -298,6 +517,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
...(headers ? { headers } : {}),
});
if (!response.ok) {
@@ -361,6 +581,168 @@ async function downloadFile(url: string, dest: string): Promise<void> {
}
// ---------------------------------------------------------------------------
// Pro binary download
// ---------------------------------------------------------------------------
async function ensureProBinary(licenseKey: string): Promise<string> {
const effective = getEffectiveVersion(true);
const effectivePath = getBinaryPath(effective, true);
if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) {
showWelcome();
maybeTriggerProUpdateCheck(licenseKey);
return effectivePath;
}
const version = await getProLatestVersion();
if (!version) {
throw new Error("Could not determine latest Pro version from server");
}
const versionPath = getBinaryPath(version, true);
if (fs.existsSync(versionPath) && isExecutable(versionPath)) {
showWelcome();
return versionPath;
}
console.log(
`[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...`
);
await downloadProBinary(version, licenseKey);
const downloadedPath = getBinaryPath(version, true);
if (!fs.existsSync(downloadedPath)) {
throw new Error(
`Pro download completed but binary not found at: ${downloadedPath}`
);
}
// Write Pro version marker
try {
const cacheDir = getCacheDir();
fs.mkdirSync(cacheDir, { recursive: true });
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
fs.writeFileSync(marker, version);
} catch {
// Non-fatal
}
showWelcome();
return downloadedPath;
}
/** @internal Exported for testing only. */
export async function downloadProBinary(version: string, licenseKey: string): Promise<void> {
// Request the explicit version so the served archive matches the signed
// manifest verified in verifyProDownload.
const downloadUrl = `${DOWNLOAD_BASE_URL}/api/download/${version}`;
const binaryDir = getBinaryDir(version, true);
const binaryPath = getBinaryPath(version, true);
const platformTag = getPlatformTag();
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
const tmpPath = path.join(
path.dirname(binaryDir),
`_download_${Date.now()}${getArchiveExt()}`
);
try {
await downloadFile(downloadUrl, tmpPath, {
Authorization: `Bearer ${licenseKey}`,
"X-Platform": platformTag,
});
// Pro binaries come from cloakbrowser.dev — the same origin as free
// downloads — so the M1 attack the Ed25519 signature defends against
// applies equally. Verify with the same non-bypassable signature check;
// CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the official
// free path).
await verifyProDownload(tmpPath, version);
await extractArchive(tmpPath, binaryDir, binaryPath);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
}
/**
* Verify a Pro archive with the same non-bypassable Ed25519 signature check as
* official free downloads. Pro binaries are served from cloakbrowser.dev (same
* origin as the free tier), so a tampered same-origin SHA256SUMS could
* otherwise certify a tampered binary (M1, #308). Fetch the Pro SHA256SUMS +
* detached SHA256SUMS.sig, verify the signature against the pinned keys FIRST,
* bind the manifest to the requested version, then verify the archive's
* SHA-256.
*
* An invalid signature, checksum, or version mismatch throws
* BinaryVerificationError (a tampering signal the router surfaces verbatim);
* CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
* transient nothing was validated and throws a plain Error. A valid-license
* user is never silently downgraded to the free binary.
* @internal Exported for testing only.
*/
export async function verifyProDownload(filePath: string, version: string): Promise<void> {
const base = `${DOWNLOAD_BASE_URL}/releases/pro/chromium-v${version}`;
let manifestBytes: Uint8Array;
let sigBytes: Uint8Array;
try {
const manifestResp = await fetch(`${base}/SHA256SUMS`, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!manifestResp.ok) throw new Error(`HTTP ${manifestResp.status} for SHA256SUMS`);
const sigResp = await fetch(`${base}/SHA256SUMS.sig`, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status} for SHA256SUMS.sig`);
manifestBytes = new Uint8Array(await manifestResp.arrayBuffer());
sigBytes = new Uint8Array(await sigResp.arrayBuffer());
} catch (e) {
// Fetch failure is transient, not tampering — throw a plain Error (the
// router reports it as "unavailable, retry") rather than a
// BinaryVerificationError (which it surfaces as a tampering signal).
throw new Error(`Could not fetch the signed SHA256SUMS for Pro ${version} (${e})`);
}
// verifySignature / verifyChecksum throw a plain Error; convert to
// BinaryVerificationError so the Pro router treats them as tampering signals
// (re-throw) rather than transient failures (fall back to free).
try {
verifySignature(manifestBytes, sigBytes);
} catch (e) {
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
}
const manifestText = new TextDecoder().decode(manifestBytes);
// Version binding: same forced-downgrade defense as the official path.
const declared = parseManifestVersion(manifestText);
if (declared !== version) {
throw new BinaryVerificationError(
`Version mismatch in signed Pro SHA256SUMS: requested ${version}, ` +
`manifest declares ${declared ?? "none"}. Refusing (possible downgrade).`
);
}
const tarballName = getArchiveName();
const expected = parseChecksums(manifestText).get(tarballName);
if (!expected) {
throw new BinaryVerificationError(
`Signature-verified Pro SHA256SUMS has no entry for ${tarballName}` +
`cannot confirm binary integrity.`
);
}
try {
await verifyChecksum(filePath, expected);
} catch (e) {
throw new BinaryVerificationError(e instanceof Error ? e.message : String(e));
}
}
async function extractArchive(
archivePath: string,
destDir: string,
@@ -423,11 +805,16 @@ async function extractZip(archivePath: string, destDir: string): Promise<void> {
if (process.platform === "win32") {
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
// with recently-closed Node.js file handles. Use ZipFile API directly.
// Pass paths via env vars (not interpolated into the script) so a quote or
// other special char in the path can't break out and be parsed as code.
execFileSync("powershell", [
"-NoProfile", "-Command",
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
], { timeout: 120_000 });
`[System.IO.Compression.ZipFile]::ExtractToDirectory($env:CB_ARCHIVE, $env:CB_DEST)`,
], {
timeout: 120_000,
env: { ...process.env, CB_ARCHIVE: archivePath, CB_DEST: destDir },
});
} else {
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
}
@@ -608,3 +995,36 @@ function maybeTriggerUpdateCheck(): void {
if (!shouldCheckForUpdate()) return;
checkAndDownloadUpdate().catch(() => { });
}
function maybeTriggerProUpdateCheck(licenseKey: string): void {
const checkFile = path.join(getCacheDir(), ".last_pro_update_check");
try {
if (fs.existsSync(checkFile)) {
const lastCheck = parseFloat(fs.readFileSync(checkFile, "utf-8").trim());
if (Date.now() - lastCheck * 1000 < UPDATE_CHECK_INTERVAL_MS) return;
}
} catch {
// unreadable — proceed
}
(async () => {
try {
fs.mkdirSync(path.dirname(checkFile), { recursive: true });
fs.writeFileSync(checkFile, String(Date.now() / 1000));
const latest = await getProLatestVersion();
if (!latest) return;
if (fs.existsSync(getBinaryPath(latest, true))) return;
console.log(`[cloakbrowser] Newer Pro binary available: ${latest}. Downloading in background...`);
await downloadProBinary(latest, licenseKey);
const marker = path.join(getCacheDir(), `latest_pro_version_${getPlatformTag()}`);
fs.writeFileSync(marker, latest);
console.log(`[cloakbrowser] Pro background update complete: ${latest} ready. Will use on next launch.`);
} catch (err) {
// non-fatal
}
})();
}
+175 -13
View File
@@ -14,12 +14,15 @@ import { createWriteStream } from "node:fs";
import dns from "node:dns/promises";
import net from "node:net";
import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL =
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
const DEFAULT_GEOIP_TIMEOUT_MS = 5_000;
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
@@ -42,6 +45,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
export interface GeoResult {
timezone: string | null;
locale: string | null;
exitIp: string | null;
}
/**
@@ -63,12 +67,20 @@ export async function resolveProxyGeo(
}
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null };
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
const timeoutMs = getGeoipTimeoutMs();
const deadline = deadlineFromTimeout(timeoutMs);
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null };
let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
if (!ip && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
if (!ip || deadlineExpired(deadline)) {
if (deadlineExpired(deadline)) {
console.warn(`[cloakbrowser] GeoIP resolution timed out after ${timeoutMs}ms; continuing without GeoIP`);
}
return { timezone: null, locale: null, exitIp: null };
}
try {
const buf = fs.readFileSync(dbPath);
@@ -78,12 +90,36 @@ export async function resolveProxyGeo(
const countryCode: string | null = result?.country?.iso_code ?? null;
const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale };
return { timezone, locale, exitIp: ip };
} catch {
return { timezone: null, locale: null };
return { timezone: null, locale: null, exitIp: ip };
}
}
function getGeoipTimeoutMs(): number {
const raw = process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
if (!raw) return DEFAULT_GEOIP_TIMEOUT_MS;
const timeoutSeconds = Number(raw);
if (!Number.isFinite(timeoutSeconds)) {
console.warn(`[cloakbrowser] Invalid CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS=${raw}; using ${DEFAULT_GEOIP_TIMEOUT_MS / 1000}s`);
return DEFAULT_GEOIP_TIMEOUT_MS;
}
return Math.max(timeoutSeconds, 0) * 1000;
}
function deadlineFromTimeout(timeoutMs: number): number | null {
return timeoutMs > 0 ? performance.now() + timeoutMs : null;
}
function remainingMs(deadline: number | null): number | undefined {
if (deadline === null) return undefined;
return Math.max(deadline - performance.now(), 0);
}
function deadlineExpired(deadline: number | null): boolean {
return deadline !== null && performance.now() >= deadline;
}
// ---------------------------------------------------------------------------
// Proxy IP resolution
// ---------------------------------------------------------------------------
@@ -125,16 +161,56 @@ const IP_ECHO_URLS = [
"https://ifconfig.me/ip",
];
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
// For simplicity, use a direct HTTP request to a plain-text IP echo service
// through the proxy using Node's http module
async function resolveExitIp(proxyUrl: string, timeoutMs?: number): Promise<string | null> {
const deadline = timeoutMs && timeoutMs > 0 ? performance.now() + timeoutMs : null;
const isSocks = isSocksProxy(proxyUrl);
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
if (isSocks) {
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
try {
({ SocksProxyAgent } = await import("socks-proxy-agent"));
} catch {
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
return null;
}
const { default: https } = await import("node:https");
const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try {
const ip = await new Promise<string | null>((resolve) => {
const req = https.request(echoUrl, { agent, timeout: Math.min(10_000, remaining ?? 10_000) }, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
const ip = data.trim();
resolve(net.isIP(ip) ? ip : null);
});
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
if (ip) return ip;
} catch {
continue;
}
}
return null;
}
// HTTP/HTTPS: use a CONNECT tunnel via http
try {
const { default: http } = await import("node:http");
const { default: https } = await import("node:https");
const proxyUrlObj = new URL(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try {
const ip = await new Promise<string | null>((resolve, reject) => {
const targetUrl = new URL(echoUrl);
@@ -152,13 +228,14 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
).toString("base64"),
}
: {},
timeout: 10_000,
timeout: Math.min(10_000, remaining ?? 10_000),
});
connectReq.on("connect", (_res, socket) => {
const innerRemaining = remainingMs(deadline);
const req = https.request(
echoUrl,
{ socket, timeout: 5_000 } as any,
{ socket, timeout: Math.min(5_000, innerRemaining ?? 5_000) } as any,
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
@@ -169,6 +246,7 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
}
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
@@ -223,7 +301,9 @@ async function downloadGeoipDb(dest: string): Promise<void> {
const tmpPath = `${dest}.tmp.${Date.now()}`;
try {
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
const response = await fetch(GEOIP_DB_URL, {
redirect: "follow",
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
@@ -260,3 +340,85 @@ function maybeTriggerUpdate(dbPath: string): void {
// Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {});
}
/**
* Extract a usable proxy URL from LaunchOptions.proxy.
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
* with inline credentials so SOCKS5 auth works.
*/
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
if (!proxy) return null;
if (typeof proxy === "string") return ensureProxyScheme(proxy);
const p = proxy as ProxyDict;
if (!p.server) return null;
if (p.username && isSocksProxy(p)) {
return reconstructSocksUrl(p);
}
return ensureProxyScheme(p.server);
}
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
const timeoutMs = getGeoipTimeoutMs();
const exitIp = await resolveExitIp(proxyUrl, timeoutMs) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
const exitIp = geoExitIp ?? undefined;
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
exitIp,
};
}
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
*/
export async function resolveWebrtcArgs(
options: LaunchOptions
): Promise<string[] | undefined> {
const args = options.args;
if (!args) return args;
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args;
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) {
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
const result = [...args];
result.splice(idx, 1);
return result;
}
try {
const ip = await resolveExitIp(proxyUrl, getGeoipTimeoutMs());
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
result.splice(idx, 1);
}
return result;
} catch {
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
const result = [...args];
result.splice(idx, 1);
return result;
}
}
+999
View File
@@ -0,0 +1,999 @@
/**
* Human-like behavioral layer for cloakbrowser Puppeteer edition.
*
* Mirrors Playwright humanize architecture, adapted for Puppeteer API.
*
* Patches ALL native Puppeteer interaction surfaces:
*
* PAGE-LEVEL:
* click (with clickCount support for dblclick), hover, type,
* select, focus, tap, goto
*
* MOUSE:
* move, click (with clickCount support for dblclick), wheel,
* dragAndDrop
*
* KEYBOARD:
* type, down, up, press, sendCharacter
*
* FRAME-LEVEL:
* click, hover, type, select, focus, tap
* + $, $$, waitForSelector (return patched ElementHandles)
*
* ELEMENTHANDLE-LEVEL (Puppeteer-specific, no Playwright equivalent):
* click (with clickCount), hover, type, press, tap, select,
* focus, drop, dragAndDrop
* + $, $$, waitForSelector (nested elements are also patched)
*
* BROWSER-LEVEL:
* newPage, createBrowserContext / createIncognitoBrowserContext,
* targetcreated event
*
* Stealth-aware:
* - isInputElement / isSelectorFocused use CDP Isolated Worlds
* - Shift symbol typing uses CDP Input.dispatchKeyEvent (isTrusted=true)
* - ElementHandle isInput check uses CDP DOM.describeNode (no JS execution)
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Puppeteer-specific adaptations:
* - page.createCDPSession() instead of context.newCDPSession(page)
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) instead of page.locator(selector)
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText
* - mouse.wheel({deltaX, deltaY}) object form adapted to (dx, dy)
* - page.select() instead of page.selectOption()
* - ElementHandle prototype patching (Puppeteer-only)
* - No page.dblclick() Puppeteer uses click({clickCount:2})
*/
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig, HumanActionOptions } from '../human/config.js';
import { resolveConfig, mergeConfig, rand, randRange, sleep } from '../human/config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, humanScrollIntoView, smoothWheel } from './scroll.js';
export type { HumanConfig } from '../human/config.js';
export { resolveConfig, mergeConfig } from '../human/config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement, humanScrollIntoView } from './scroll.js';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation (Puppeteer version)
// ============================================================================
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.createCDPSession();
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = (tree as any).frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = (result as any).executionContextId;
this.contextId = ctxId;
return ctxId;
}
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if ((result as any).exceptionDetails) {
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return (result as any).result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try { await this.createWorld(); } catch { return undefined; }
continue;
}
return undefined;
}
}
return undefined;
}
invalidate(): void {
this.contextId = null;
}
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries
// ============================================================================
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}, selector).catch(() => false);
}
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// instead of el.evaluate() to avoid main-world JS execution.
// ============================================================================
async function isInputElementHandle(
stealth: StealthEval | null,
el: ElementHandle,
): Promise<boolean> {
if (stealth) {
try {
const cdp = await stealth.getCdpSession();
const remoteObject = (el as any).remoteObject?.();
if (remoteObject?.objectId) {
const { node } = await cdp.send('DOM.describeNode', {
objectId: remoteObject.objectId,
}) as any;
const tag = (node?.nodeName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
const attrs: string[] = node?.attributes || [];
for (let i = 0; i < attrs.length; i += 2) {
if (attrs[i] === 'contenteditable' && attrs[i + 1] === 'true') {
return true;
}
}
return false;
}
} catch { /* fallthrough to el.evaluate */ }
}
return el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
}).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
select: page.select.bind(page),
focus: page.focus.bind(page),
goto: page.goto.bind(page),
tap: page.tap.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
mouseWheel: (page.mouse as any).wheel?.bind(page.mouse),
mouseDragAndDrop: (page.mouse as any).dragAndDrop?.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardUp: page.keyboard.up.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardSendCharacter: page.keyboard.sendCharacter.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try { cdpSession = await stealth.getCdpSession(); } catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: async (deltaX: number, deltaY: number) => {
if (originals.mouseWheel) {
await originals.mouseWheel({ deltaX, deltaY });
}
},
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardSendCharacter,
};
async function ensureCursorInit(): Promise<void> {
if (!cursor.initialized) {
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
await originals.mouseMove(cursor.x, cursor.y);
cursor.initialized = true;
}
}
// ==== goto ====
const humanGoto = async (url: string, options?: {
referer?: string;
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
}) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// ==== click (with clickCount support for dblclick) ====
const humanClickFn = async (selector: string, options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, isInput, callCfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, isInput, callCfg);
}
};
// ==== hover ====
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
};
// ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions & {
delay?: number;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
};
// ==== select ====
const humanSelectFn = async (selector: string, ...values: string[]) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.select(selector, ...values);
};
// ==== focus ====
const humanFocusFn = async (selector: string) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
};
// ==== tap ====
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options);
};
// ============================================================
// Assign page-level patches
// ============================================================
(page as any).goto = humanGoto;
(page as any).click = humanClickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).select = humanSelectFn;
(page as any).focus = humanFocusFn;
(page as any).tap = humanTapFn;
// ============================================================
// Mouse patches
// ============================================================
page.mouse.move = async (x: number, y: number, options?: { steps?: number }) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, false, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, false, cfg);
}
};
if (originals.mouseWheel) {
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
const dx = options?.deltaX ?? 0;
const dy = options?.deltaY ?? 0;
if (Math.abs(dy) > 0) {
await smoothWheel(raw, dy, cfg, 'y');
}
if (Math.abs(dx) > 0) {
await smoothWheel(raw, dx, cfg, 'x');
}
};
}
if (originals.mouseDragAndDrop) {
(page.mouse as any).dragAndDrop = async (
start: { x: number; y: number },
target: { x: number; y: number },
options?: { delay?: number },
) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
cursor.x = start.x;
cursor.y = start.y;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await sleep(rand(80, 150));
await originals.mouseUp();
};
}
// ============================================================
// Keyboard patches
// ============================================================
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
page.keyboard.press = async (key: any, options?: { delay?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
page.keyboard.down = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardDown(key as any);
};
page.keyboard.up = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardUp(key as any);
};
// ============================================================
// Store helpers for frame/element patching
// ============================================================
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// Patch frames
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch ElementHandle selectors
patchElementHandle(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// ElementHandle patching — PUPPETEER-SPECIFIC
// ============================================================================
function patchElementHandle(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
const orig$ = page.$.bind(page);
const orig$$ = page.$$.bind(page);
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(page as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
const origElClick = el.click.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElPress = (el as any).press?.bind(el);
const origElTap = (el as any).tap?.bind(el);
const origElFocus = (el as any).focus?.bind(el);
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.bind(el);
// Puppeteer v22+ adds ElementHandle.scrollIntoView(); earlier versions
// expose it implicitly via evaluate(node => node.scrollIntoView()).
const origElScrollIntoView = (el as any).scrollIntoView?.bind(el);
// --- Nested selectors ---
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
// so type/fill overrides like ``el.type(text, { typing_delay: 30 })``
// carry through to mouse timing for that single call. Also scrolls into
// view first so off-screen elements work (#129, #172 follow-up).
const moveToElement = async (callCfg: HumanConfig = cfg) => {
await (page as any)._ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox().then(b => b ?? null),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch { /* let boundingBox() decide */ }
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, callCfg);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, info.isInp, callCfg);
}
};
// --- el.hover() ---
(el as any).hover = async () => {
const info = await moveToElement();
if (!info) return origElHover();
};
// --- el.type() ---
(el as any).type = async (text: string, options?: HumanActionOptions & { delay?: number }) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
const cdp = await stealth.getCdpSession().catch(() => null);
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- el.scrollIntoView() ---
// Puppeteer-only equivalent of Playwright's scrollIntoViewIfNeeded.
// Replaces the native snap-scroll (a strong bot signal) with the same
// accelerate → cruise → decelerate → overshoot wheel sequence used by
// page.click(). Only patched when the underlying ElementHandle exposes
// ``scrollIntoView`` (Puppeteer v22+).
if (origElScrollIntoView) {
(el as any).scrollIntoView = async (options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await (page as any)._ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox().then(b => b ?? null),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch {
return origElScrollIntoView(options);
}
};
}
// --- el.press() ---
if (origElPress) {
(el as any).press = async (key: string, options?: { delay?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
}
// --- el.tap() ---
if (origElTap) {
(el as any).tap = async () => {
const info = await moveToElement();
if (!info) return origElTap();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.focus() ---
if (origElFocus) {
(el as any).focus = async () => {
const info = await moveToElement();
if (!info) return origElFocus();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.select() ---
if (origElSelect) {
(el as any).select = async (...values: string[]) => {
const info = await moveToElement();
if (!info) return origElSelect(...values);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelect(...values);
};
}
// --- el.drop() ---
if (origElDrop) {
(el as any).drop = async (draggable: ElementHandle, options?: { delay?: number }) => {
const srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDrop(draggable, options);
}
};
}
// --- el.dragAndDrop() ---
if (origElDragAndDrop) {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: { delay?: number }) => {
const srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDragAndDrop(targetEl, options);
}
};
}
}
// ============================================================================
// Frame-level patching — native Puppeteer Frame methods only
// Puppeteer Frame has: click, hover, type, select, focus, tap
// ============================================================================
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
const origFrameSelect = frame.select.bind(frame);
(frame as any).click = async (selector: string, options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await (page as any).click(selector, options);
};
(frame as any).hover = async (selector: string, options?: HumanActionOptions) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions & {
delay?: number;
}) => {
await (page as any).type(selector, text, options);
};
(frame as any).select = async (selector: string, ...values: string[]) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelect(selector, ...values);
};
(frame as any).focus = async (selector: string) => {
await (page as any).focus(selector);
};
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await (page as any).click(selector, options);
};
// Patch frame.$() to return patched ElementHandles
const origFrame$ = frame.$.bind(frame);
const origFrame$$ = frame.$$.bind(frame);
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
(frame as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
browser.pages().then(pages => {
for (const page of pages) {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
}).catch(() => {});
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
// v21: createIncognitoBrowserContext
// v22+: createBrowserContext (renamed in puppeteer/puppeteer#11834)
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser);
(browser as any)[methodName] = async (options?: Parameters<typeof origCreateContext>[0]) => {
const context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origCtxNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
return context;
};
}
}
browser.on('targetcreated', async (target: any) => {
try {
if (target.type() === 'page') {
const page = await target.page();
if (page && !(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
} catch {}
});
}
export { patchPage };
+187
View File
@@ -0,0 +1,187 @@
/**
* cloakbrowser-human Human-like keyboard input.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - Uses puppeteer-core Page/CDPSession types
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText adapter
* - CDPSession obtained via page.createCDPSession()
*
* Stealth-aware: shift symbols use CDP Input.dispatchKeyEvent (isTrusted=true).
*/
import type { Page, CDPSession } from 'puppeteer-core';
import { RawKeyboard } from '../human/mouse.js';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, sleep } from '../human/config.js';
const SHIFT_SYMBOLS = new Set([
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
]);
const NEARBY_KEYS: Record<string, string> = {
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
z: 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII → sendCharacter via insertText adapter
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) await interCharDelay(cfg);
continue;
}
// Mistype
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
await raw.down('Backspace');
await sleep(randRange(cfg.key_hold));
await raw.up('Backspace');
await sleep(randRange(cfg.mistype_delay_correct));
}
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < chars.length - 1) await interCharDelay(cfg);
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* cloakbrowser-human Human-like scrolling via mouse wheel events.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
* - boundingBox() has no timeout param we poll page.$() up to ``timeout`` ms
*/
import type { Page } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, randIntRange, sleep } from '../human/config.js';
import { RawMouse, humanMove } from '../human/mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
export async function smoothWheel(
raw: RawMouse,
delta: number,
cfg: HumanConfig,
axis: 'x' | 'y' = 'y',
): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
const d = Math.round(chunk) * sign;
if (axis === 'x') {
await raw.wheel(d, 0);
} else {
await raw.wheel(0, d);
}
sent += chunk;
await sleep(rand(8, 20));
}
}
/**
* Poll ``page.$(selector)`` for up to ``timeout`` ms, returning the element's
* bounding box when found. ``timeout`` defaults to 30000ms when not specified.
*/
async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
const start = Date.now();
const pollInterval = 100;
while (true) {
try {
const el = await page.$(selector);
if (el) {
const box = await el.boundingBox();
if (box) return { x: box.x, y: box.y, width: box.width, height: box.height };
}
} catch { /* keep polling */ }
if (Date.now() - start >= timeout) return null;
await sleep(pollInterval);
}
}
/**
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
* ``scrollIntoView`` patch.
*/
export async function humanScrollIntoView(
page: Page,
raw: RawMouse,
getBox: () => Promise<ElementBounds | null>,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
await sleep(randRange(cfg.scroll_settle_delay));
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY };
}
/**
* Selector-based humanized scroll (Puppeteer).
*
* ``timeout`` controls how long we poll ``page.$(selector)`` before giving up,
* so callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*/
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
cursorX, cursorY, cfg,
);
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Playwright-style actionability checks for the humanize layer.
*
* Checks: attached, visible, stable, enabled, editable, receives pointer events.
* Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
*/
import type { Page, Frame, ElementHandle } from 'playwright-core';
// ---------------------------------------------------------------------------
// Error hierarchy
// ---------------------------------------------------------------------------
export class ActionabilityError extends Error {
selector: string;
check: string;
constructor(selector: string, check: string, message: string) {
super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`);
this.name = 'ActionabilityError';
this.selector = selector;
this.check = check;
}
}
export class ElementNotAttachedError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'attached', 'element not found in DOM');
this.name = 'ElementNotAttachedError';
}
}
export class ElementNotVisibleError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'visible', 'element is not visible');
this.name = 'ElementNotVisibleError';
}
}
export class ElementNotStableError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'stable', 'element position is still changing');
this.name = 'ElementNotStableError';
}
}
export class ElementNotEnabledError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'enabled', 'element is disabled');
this.name = 'ElementNotEnabledError';
}
}
export class ElementNotEditableError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'editable', 'element is not editable');
this.name = 'ElementNotEditableError';
}
}
export class ElementNotReceivingEventsError extends ActionabilityError {
coveringTag: string;
constructor(selector: string, coveringTag: string = 'unknown') {
super(selector, 'pointer_events', `element is covered by <${coveringTag}>`);
this.name = 'ElementNotReceivingEventsError';
this.coveringTag = coveringTag;
}
}
// ---------------------------------------------------------------------------
// Check-set constants
// ---------------------------------------------------------------------------
export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events';
export const CHECKS_CLICK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
export const CHECKS_HOVER: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'pointer_events']);
export const CHECKS_INPUT: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']);
export const CHECKS_FOCUS: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled']);
export const CHECKS_CHECK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
const BACKOFF_MS = [100, 250, 500, 1000];
function backoffSleep(attempt: number): Promise<void> {
const idx = Math.min(attempt, BACKOFF_MS.length - 1);
return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx]));
}
// ---------------------------------------------------------------------------
// Pre-scroll actionability
// ---------------------------------------------------------------------------
export async function ensureActionable(
pageOrFrame: Page | Frame,
selector: string,
checks: ReadonlySet<CheckName>,
timeout: number = 30000,
force: boolean = false,
): Promise<void> {
if (force) return;
const deadline = Date.now() + timeout;
let attempt = 0;
let lastError: ActionabilityError | null = null;
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) {
if (lastError) throw lastError;
throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check');
}
try {
const loc = pageOrFrame.locator(selector).first();
if (checks.has('attached')) {
try {
await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotAttachedError(selector);
}
}
if (checks.has('visible')) {
if (!await loc.isVisible()) throw new ElementNotVisibleError(selector);
}
if (checks.has('enabled')) {
if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector);
}
if (checks.has('editable')) {
if (!await loc.isEditable()) throw new ElementNotEditableError(selector);
}
return;
} catch (e) {
if (e instanceof ActionabilityError) {
lastError = e;
if (Date.now() >= deadline) throw lastError;
await backoffSleep(attempt);
attempt++;
} else {
throw e;
}
}
}
}
// ---------------------------------------------------------------------------
// Post-scroll stability check
// ---------------------------------------------------------------------------
function boxesDiffer(
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
): boolean {
return (
Math.abs(a.x - b.x) > 1 ||
Math.abs(a.y - b.y) > 1 ||
Math.abs(a.width - b.width) > 1 ||
Math.abs(a.height - b.height) > 1
);
}
export async function ensureStable(
pageOrFrame: Page | Frame,
selector: string,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) throw new ElementNotStableError(selector);
const loc = pageOrFrame.locator(selector).first();
const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
if (!box1) throw new ElementNotAttachedError(selector);
await new Promise(r => setTimeout(r, 100));
const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
if (!box2) throw new ElementNotAttachedError(selector);
if (!boxesDiffer(box1, box2)) return;
if (Date.now() >= deadline) throw new ElementNotStableError(selector);
await backoffSleep(attempt);
attempt++;
}
}
// ---------------------------------------------------------------------------
// Pointer-events check (post-scroll, at actual click coordinates)
// ---------------------------------------------------------------------------
const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
const POINTER_EVENTS_HANDLE_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
export async function checkPointerEvents(
pageOrFrame: Page | Frame,
selector: string,
x: number,
y: number,
stealth?: { evaluate(expression: string): Promise<any> } | null,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
while (true) {
let result: any = null;
try {
const loc = pageOrFrame.locator(selector).first();
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
} catch {
result = null;
}
if (!result || result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
await backoffSleep(attempt);
attempt++;
}
}
// ---------------------------------------------------------------------------
// ElementHandle variant
// ---------------------------------------------------------------------------
export async function ensureActionableHandle(
el: ElementHandle,
checks: ReadonlySet<CheckName>,
timeout: number = 30000,
force: boolean = false,
): Promise<void> {
if (force) return;
const deadline = Date.now() + timeout;
let attempt = 0;
let lastError: ActionabilityError | null = null;
const label = '<ElementHandle>';
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) {
if (lastError) throw lastError;
throw new ActionabilityError(label, 'timeout', 'timeout expired before first check');
}
try {
if (checks.has('visible')) {
try {
await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotVisibleError(label);
}
}
if (checks.has('enabled')) {
try {
await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotEnabledError(label);
}
}
if (checks.has('editable')) {
try {
await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotEditableError(label);
}
}
return;
} catch (e) {
if (e instanceof ActionabilityError) {
lastError = e;
if (Date.now() >= deadline) throw lastError;
await backoffSleep(attempt);
attempt++;
} else {
throw e;
}
}
}
}
export async function checkPointerEventsHandle(
el: ElementHandle,
x: number,
y: number,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
while (true) {
let result: any;
try {
const box = await el.boundingBox();
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
} catch {
result = null;
}
if (!result || result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
await backoffSleep(attempt);
attempt++;
}
}
+22
View File
@@ -70,6 +70,12 @@ export interface HumanConfig {
export type HumanPreset = 'default' | 'careful';
export type HumanActionOptions = Partial<HumanConfig> & {
timeout?: number;
force?: boolean;
human_config?: Partial<HumanConfig>;
};
// ---------------------------------------------------------------------------
// Default preset
// ---------------------------------------------------------------------------
@@ -201,6 +207,22 @@ export function resolveConfig(
return { ...base, ...overrides };
}
/**
* Merge a partial overrides object on top of an existing HumanConfig.
* Returns a new object the original ``cfg`` is never mutated.
*
* Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })``
* so the same patched page can type different fields at different speeds
* without re-patching.
*/
export function mergeConfig(
cfg: HumanConfig,
overrides?: Partial<HumanConfig> | null,
): HumanConfig {
if (!overrides) return cfg;
return { ...cfg, ...overrides };
}
// ---------------------------------------------------------------------------
// Utility: random number in range
+541
View File
@@ -0,0 +1,541 @@
/**
* ElementHandle humanization for Playwright.
*
* Mirrors Puppeteer's ElementHandle patching architecture.
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
* and patches all interaction methods on each ElementHandle instance.
*
* Playwright ElementHandle methods patched:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* Stealth-aware:
* - Uses CDP DOM.describeNode when available to check element type
* (no main-world JS execution)
* - Falls back to el.evaluate() only when CDP is unavailable
*/
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
import type { HumanConfig, HumanActionOptions } from './config.js';
import { rand, randRange, sleep, mergeConfig } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { humanScrollIntoView } from './scroll.js';
import {
ensureActionableHandle, checkPointerEventsHandle,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
} from './actionability.js';
// --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// ============================================================================
async function isInputElementHandle(
stealth: any, // StealthEval from index.ts
el: ElementHandle,
): Promise<boolean> {
// Try CDP DOM.describeNode first (no main-world JS execution)
if (stealth) {
try {
const cdp: CDPSession = await stealth.getCdpSession();
// Playwright exposes the JSHandle's internal preview via _objectId or similar
// We need the remote object ID. Try to get it via internal API.
const impl = (el as any)._impl ?? (el as any)._object ?? el;
const guid = (impl as any)._guid;
// Use el.evaluate as a reliable fallback within stealth context
// Playwright doesn't expose remoteObject directly like Puppeteer
} catch { /* fallthrough */ }
}
// Fallback: el.evaluate (works reliably in Playwright)
try {
return await el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
});
} catch {
return false;
}
}
// ============================================================================
// CursorState type (matches index.ts)
// ============================================================================
interface CursorState {
x: number;
y: number;
initialized: boolean;
}
// ============================================================================
// Patch a single Playwright ElementHandle
// ============================================================================
export function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
// Save originals
const origElClick = el.click.bind(el);
const origElDblclick = el.dblclick.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElFill = el.fill.bind(el);
const origElPress = el.press.bind(el);
const origElSelectOption = el.selectOption.bind(el);
const origElCheck = el.check.bind(el);
const origElUncheck = el.uncheck.bind(el);
const origElSetChecked = (el as any).setChecked?.bind(el);
const origElTap = el.tap.bind(el);
const origElFocus = el.focus.bind(el);
const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el);
// Nested selectors
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
// --- Nested elements are also patched ---
(el as any).$ = async (selector: string) => {
const child = await origEl$(selector);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
(el as any).$$ = async (selector: string) => {
const children = await origEl$$(selector);
for (const child of children) {
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return children;
};
(el as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const child = await origElWaitForSelector(selector, options ?? {});
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get bounding box and move cursor to element ---
// Accepts a per-call ``callCfg`` so type/fill overrides like
// ``el.type(text, { human_config: { typing_delay: 30 } })`` or
// ``el.type(text, { typing_delay: 30 })`` carry through to mouse movement
// & idle timing for that single call.
// Also scrolls the element into view first so off-screen elements work
// (#129, #172 follow-up): otherwise boundingBox() returns null and we'd
// silently fall back to the unpatched native method.
const moveToElement = async (callCfg: HumanConfig = cfg) => {
// Ensure cursor is initialized
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
// Scroll into view first so boundingBox() returns coordinates even when
// the element starts below the fold. Best-effort — if humanScrollIntoView
// throws (e.g. detached element), we let boundingBox() decide whether to
// proceed or fall back to the original method.
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox(),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch { /* let boundingBox() decide */ }
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, callCfg);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
};
// --- el.dblclick() ---
(el as any).dblclick = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle';
delay?: number;
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
// --- el.hover() ---
(el as any).hover = async (options?: HumanActionOptions & {
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElHover(options);
};
// --- el.type() ---
(el as any).type = async (text: string, options?: HumanActionOptions & {
delay?: number;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = (options as any)?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, text, callCfg, cdpSession);
};
// --- el.fill() ---
(el as any).fill = async (value: string, options?: HumanActionOptions & {
force?: boolean;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, value, callCfg, cdpSession);
};
// --- el.press() ---
(el as any).press = async (key: string, options?: { delay?: number; noWaitAfter?: boolean; timeout?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key);
};
// --- el.selectOption() ---
(el as any).selectOption = async (values: any, options?: {
force?: boolean;
noWaitAfter?: boolean;
timeout?: number;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelectOption(values, options);
};
// --- el.check() ---
(el as any).check = async (options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const checked = await el.isChecked();
if (checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
// --- el.uncheck() ---
(el as any).uncheck = async (options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const checked = await el.isChecked();
if (!checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
// --- el.setChecked() ---
if (origElSetChecked) {
(el as any).setChecked = async (checked: boolean, options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.tap() ---
(el as any).tap = async (options?: {
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const info = await moveToElement();
if (!info) return origElTap(options);
await humanClick(raw, info.isInp, cfg);
};
// --- el.focus() ---
// Move cursor humanly but use programmatic focus (no click side-effects).
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
// submit forms, navigate links, etc.
(el as any).focus = async () => {
await moveToElement(); // human-like Bézier cursor movement
await origElFocus(); // programmatic focus, no click
};
// --- el.scrollIntoViewIfNeeded() ---
// Playwright's native version snaps the page — a strong bot signal.
// Replace with the same accelerate → cruise → decelerate → overshoot
// wheel sequence used by page.click() etc. Falls back to the native
// method if the element is detached or scrolling fails.
if (origElScrollIntoViewIfNeeded) {
(el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox(),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch {
return origElScrollIntoViewIfNeeded(options);
}
};
}
}
// ============================================================================
// Page-level ElementHandle patching
// ============================================================================
export function patchPageElementHandles(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch page.$() — only if the method exists
if (typeof page.$ === 'function') {
const orig$ = page.$.bind(page);
(page as any).$ = async (selector: string) => {
const el = await orig$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch page.$$()
if (typeof page.$$ === 'function') {
const orig$$ = page.$$.bind(page);
(page as any).$$ = async (selector: string) => {
const els = await orig$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch page.waitForSelector()
if (typeof page.waitForSelector === 'function') {
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const el = await origWaitForSelector(selector, options ?? {});
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
// ============================================================================
// Frame-level ElementHandle patching
// ============================================================================
export function patchFrameElementHandles(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: any,
): void {
// Patch frame.$() — only if the method exists
if (typeof frame.$ === 'function') {
const origFrame$ = frame.$.bind(frame);
(frame as any).$ = async (selector: string) => {
const el = await origFrame$(selector);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
// Patch frame.$$()
if (typeof frame.$$ === 'function') {
const origFrame$$ = frame.$$.bind(frame);
(frame as any).$$ = async (selector: string) => {
const els = await origFrame$$(selector);
for (const el of els) {
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
}
return els;
};
}
// Patch frame.waitForSelector()
if (typeof frame.waitForSelector === 'function') {
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const el = await origFrameWaitForSelector(selector, options ?? {});
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
}
}
+550 -97
View File
@@ -4,32 +4,187 @@
* Activated via humanize: true in launch() / launchContext().
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
*
* Stealth-aware (fixes #110):
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*
* ELEMENTHANDLE-LEVEL:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
* return patched ElementHandles automatically.
*/
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
import { scrollToElement, humanScrollIntoView } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
import {
ensureActionable, ensureStable, checkPointerEvents,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
type CheckName,
} from './actionability.js';
export { HumanConfig, resolveConfig } from './config.js';
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
export { scrollToElement, humanScrollIntoView } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation
// ============================================================================
/**
* Manages a CDP isolated execution context for DOM reads.
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
* and is invisible to querySelector monkey-patches in the main world.
*
* Context ID is invalidated on navigation and auto-recreated on next call.
*/
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.context().newCDPSession(this.page);
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = tree.frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = result.executionContextId;
this.contextId = ctxId;
return ctxId;
}
/**
* Evaluate a JS expression in the isolated world.
* Auto-recreates the world if the context was invalidated (navigation).
* Returns the result value, or undefined on failure.
*/
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if (result.exceptionDetails) {
// Context was likely invalidated by navigation
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return result.result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try {
await this.createWorld();
} catch {
return undefined;
}
continue;
}
return undefined;
}
}
return undefined;
}
/** Mark context as stale — call after navigation. */
invalidate(): void {
this.contextId = null;
}
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
async function isInputElement(page: Page, selector: string): Promise<boolean> {
// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
// ============================================================================
/**
* Check if selector matches an input/textarea/contenteditable element.
* Uses CDP Isolated World when available invisible to main world.
*/
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
@@ -39,13 +194,37 @@ async function isInputElement(page: Page, selector: string): Promise<boolean> {
}, selector).catch(() => false);
}
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
/**
* Check if the element matching selector is currently focused.
* Uses CDP Isolated World when available invisible to main world.
*/
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
@@ -82,6 +261,21 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
// --- Stealth infrastructure ---
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try {
cdpSession = await stealth.getCdpSession();
} catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
@@ -105,43 +299,80 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}
}
// --- goto ---
const humanGoto = async (url: string, options?: any) => {
// --- goto (invalidate isolated world on navigation) ---
const humanGoto = async (url: string, options?: {
referer?: string;
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
}) => {
const response = await originals.goto(url, options);
// Patch any new frames after navigation
patchFrames(page, cfg, cursor, raw, rawKb, originals);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// --- click ---
const humanClickFn = async (selector: string, options?: any) => {
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) {
await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const isInput = await isInputElement(stealth, page, selector);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
await humanClick(raw, isInput, cfg);
await humanClick(raw, isInput, callCfg);
};
// --- dblclick ---
const humanDblclickFn = async (selector: string, options?: any) => {
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const isInput = await isInputElement(stealth, page, selector);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
await raw.down({ clickCount: 2 });
@@ -150,44 +381,82 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- hover ---
const humanHoverFn = async (selector: string, options?: any) => {
const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, false, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
};
// --- type ---
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
await humanType(page, rawKb, value, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, value, callCfg, cdp);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
@@ -196,54 +465,88 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- check ---
const humanCheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
await humanHoverFn(selector);
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 300));
return originals.selectOption(selector, values, options);
};
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector);
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(100, 250));
await humanType(page, rawKb, text, cfg);
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- tap ---
const humanTapFn = async (selector: string, options?: any) => {
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options);
};
@@ -258,16 +561,25 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any).uncheck = humanUncheckFn;
(page as any).selectOption = humanSelectOptionFn;
(page as any).press = humanPressFn;
(page as any).pressSequentially = humanPressSequentiallyFn;
(page as any).tap = humanTapFn;
(page as any).clear = humanClearFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => {
page.mouse.move = async (x: number, y: number, options?: {
steps?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
page.mouse.click = async (x: number, y: number, options?: {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
@@ -276,8 +588,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => {
await humanType(page, rawKb, text, cfg);
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// Store helpers for frame patching
@@ -301,7 +614,10 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}).catch(() => {});
// --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals);
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
}
@@ -311,8 +627,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
/**
* Patch Frame methods so Locator-based calls go through humanization.
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, clear, dragAndDrop.
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
*/
function patchFrames(
page: Page,
@@ -321,61 +637,187 @@ function patchFrames(
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals);
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
function firstFrameLocator(frame: Frame, selector: string): any {
const locator = frame.locator(selector) as any;
return typeof locator.first === 'function' ? locator.first() : locator;
}
async function isFrameInputElement(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => {
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}).catch(() => false);
}
async function isFrameSelectorFocused(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement)
.catch(() => false);
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
// Save originals for methods that need fallback
const origFrameClick = frame.click.bind(frame);
const origFrameDblclick = frame.dblclick.bind(frame);
const origFrameHover = frame.hover.bind(frame);
const origFrameType = frame.type.bind(frame);
const origFrameFill = frame.fill.bind(frame);
const origFrameCheck = frame.check.bind(frame);
const origFrameUncheck = frame.uncheck.bind(frame);
const origFrameSelectOption = frame.selectOption.bind(frame);
const origFramePress = frame.press.bind(frame);
const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame);
const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
const moveToFrameSelector = async (
selector: string,
options: HumanActionOptions | undefined,
inputBias: boolean,
remainingMs: () => number,
) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
}
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { callCfg, isInput };
};
(frame as any).dblclick = async (selector: string, options?: any) => {
await (page as any).dblclick(selector, options);
const frameClick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await humanClick(raw, moved.isInput, moved.callCfg);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
(frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
(frame as any).fill = async (selector: string, value: string, options?: any) => {
await (page as any).fill(selector, value, options);
(frame as any).hover = frameHover;
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options));
};
(frame as any).check = async (selector: string, options?: any) => {
await (page as any).check(selector, options);
(frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
const cdp = await getFrameCdp();
await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options));
};
(frame as any).uncheck = async (selector: string, options?: any) => {
await (page as any).uncheck(selector, options);
(frame as any).check = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options);
const checked = await locator.isChecked();
if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options));
};
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
await (page as any).hover(selector);
(frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options);
const checked = await locator.isChecked();
if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options));
};
(frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => {
await frameHover(selector, options);
await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options);
};
(frame as any).press = async (selector: string, key: string, options?: any) => {
await (page as any).press(selector, key, options);
(frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(page, selector)) {
await (page as any).click(selector);
(frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options));
};
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
};
(frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
@@ -383,9 +825,20 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
await originals.keyboardPress('Backspace');
};
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
(frame as any).dragAndDrop = async (source: string, target: string, options?: {
force?: boolean;
noWaitAfter?: boolean;
sourcePosition?: { x: number; y: number };
strict?: boolean;
targetPosition?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(1, deadline - Date.now());
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
@@ -401,7 +854,7 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origFrameDragAndDrop(source, target, options);
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
}
};
}
@@ -454,14 +907,14 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
}
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = async (options?: any) => {
(browser as any).newContext = async (options?: Parameters<typeof origNewContext>[0]) => {
const context = await origNewContext(options);
patchContext(context, cfg);
return context;
};
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (options?: any) => {
(browser as any).newPage = async (options?: Parameters<typeof origNewPage>[0]) => {
const page = await origNewPage(options);
if (!(page as any)._original) {
const ctx = page.context();
+126 -23
View File
@@ -1,8 +1,12 @@
/**
* cloakbrowser-human Human-like keyboard input.
*
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
* Falls back to page.evaluate when no CDPSession is available.
*/
import type { Page } from 'playwright-core';
import type { Page, CDPSession } from 'playwright-core';
import { RawKeyboard } from './mouse.js';
import { HumanConfig, rand, randRange, sleep } from './config.js';
@@ -22,6 +26,36 @@ const NEARBY_KEYS: Record<string, string> = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
/**
* CDP key code for each shift symbol's physical key.
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
*/
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
/**
* Windows virtual key codes for shift symbols.
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
*/
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
@@ -32,17 +66,42 @@ function getNearbyKey(ch: string): string {
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
/**
* Type text with human-like per-character timing, mistype simulation,
* and realistic shift handling.
*
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
* producing isTrusted=true events with no evaluate stack trace.
* If null/undefined, falls back to page.evaluate (detectable).
*/
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const chars = [...text]; // Handle emoji surrogate pairs correctly
// Mistype chance — press wrong key, notice, backspace, then correct
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) {
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
continue;
}
// Mistype chance — only for ASCII alphanumeric
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
@@ -55,12 +114,12 @@ export async function humanType(
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg);
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < text.length - 1) {
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
}
@@ -82,23 +141,67 @@ async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig):
await raw.up('Shift');
}
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
/**
* Type a shift symbol character.
*
* Stealth path (cdpSession provided):
* Uses CDP Input.dispatchKeyEvent isTrusted=true, clean stack.
*
* Fallback path (no cdpSession):
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
* Detectable via isTrusted=false and evaluate stack frame.
*/
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
// --- Stealth path: CDP Input.dispatchKeyEvent ---
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8, // Shift modifier flag
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
// --- Fallback path: page.evaluate (detectable) ---
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
+21 -1
View File
@@ -172,13 +172,33 @@ export async function humanClick(
// Human idle / drift
// ---------------------------------------------------------------------------
export async function humanIdle(
export function humanIdle(
raw: RawMouse,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void>;
export function humanIdle(
raw: RawMouse,
seconds: number,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void>;
export async function humanIdle(
raw: RawMouse,
secondsOrCx: number,
cxOrCy: number,
cyOrCfg: number | HumanConfig,
maybeCfg?: HumanConfig,
): Promise<void> {
const hasExplicitSeconds = maybeCfg !== undefined;
const seconds = hasExplicitSeconds
? secondsOrCx
: rand((cyOrCfg as HumanConfig).idle_between_duration[0], (cyOrCfg as HumanConfig).idle_between_duration[1]);
const cx = hasExplicitSeconds ? cxOrCy : secondsOrCx;
const cy = hasExplicitSeconds ? (cyOrCfg as number) : cxOrCy;
const cfg = hasExplicitSeconds ? maybeCfg! : (cyOrCfg as HumanConfig);
const endTime = Date.now() + seconds * 1000;
let x = cx;
let y = cy;
+48 -16
View File
@@ -38,26 +38,29 @@ async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Prom
}
}
export async function scrollToElement(
/**
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
*
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
* ``scrollIntoViewIfNeeded`` patch so the same accelerate cruise
* decelerate overshoot behavior runs everywhere.
*/
export async function humanScrollIntoView(
page: Page,
raw: RawMouse,
selector: string,
getBox: () => Promise<ElementBounds | null>,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
return { box, cursorX, cursorY, didScroll: false };
}
// Move cursor into scroll area
@@ -107,7 +110,7 @@ export async function scrollToElement(
// Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
@@ -133,16 +136,45 @@ export async function scrollToElement(
// Settle
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY };
return { box, cursorX, cursorY, didScroll: true };
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
/**
* Selector-based humanized scroll.
*
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*
* Returns `{ box, cursorX, cursorY, didScroll }`.
*/
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
cursorX, cursorY, cfg,
);
}
async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: 2000 });
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
return box;
} catch {
return null;
+5 -1
View File
@@ -16,7 +16,7 @@
*/
// Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
@@ -24,5 +24,9 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download
// Config
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
// License
export { validateLicense } from "./license.js";
// Types
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
export type { LicenseInfo } from "./license.js";
+218
View File
@@ -0,0 +1,218 @@
/**
* License validation and caching for CloakBrowser Pro.
* Mirrors Python cloakbrowser/license.py.
*
* Handles license key resolution, server validation with local caching,
* and Pro version checks.
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { getCacheDir } from "./config.js";
const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate";
const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version";
const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours
const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
export interface LicenseInfo {
valid: boolean;
plan: string;
expires: string | null;
}
/**
* Resolve the license key: explicit param > env var > file > undefined.
*/
export function resolveLicenseKey(licenseKey?: string): string | undefined {
const trimmed = licenseKey?.trim();
if (trimmed) return trimmed;
const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim();
if (envKey) return envKey;
try {
const keyFile = path.join(getCacheDir(), "license.key");
const content = fs.readFileSync(keyFile, "utf-8").trim();
if (content) return content;
} catch {
// File doesn't exist or unreadable
}
return undefined;
}
/**
* Validate a license key with the CloakBrowser server.
*
* Checks a local file cache first (24h TTL). Falls back to stale
* cache if the server is unreachable.
*
* Returns LicenseInfo if validation succeeded, null on total failure.
*/
export async function validateLicense(licenseKey: string): Promise<LicenseInfo | null> {
const cachePath = path.join(getCacheDir(), ".license_cache");
const keySha = createHash("sha256").update(licenseKey).digest("hex");
const cached = readCache(cachePath, keySha);
if (cached) return cached;
try {
const resp = await fetch(VALIDATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: licenseKey }),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as Record<string, unknown>;
const info: LicenseInfo = {
valid: Boolean(data.valid ?? false),
plan: String(data.plan ?? "solo"),
expires: data.expires != null ? String(data.expires) : null,
};
if (info.valid) {
writeCache(cachePath, keySha, info);
}
return info;
} catch (e) {
console.warn(
`[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}`
);
// Fall back to stale cache
const stale = readCache(cachePath, keySha, true);
if (stale) {
console.warn("[cloakbrowser] Using cached license validation (server unreachable)");
return stale;
}
return null;
}
}
/**
* Get the latest Pro binary version from the server.
* Rate-limited to 1 call per hour via a marker file.
*/
export async function getProLatestVersion(): Promise<string | null> {
const marker = path.join(getCacheDir(), ".last_pro_version_check");
try {
if (fs.existsSync(marker)) {
const stats = fs.statSync(marker);
const age = Date.now() - stats.mtimeMs;
if (age < PRO_VERSION_CHECK_INTERVAL_MS) {
const content = fs.readFileSync(marker, "utf-8").trim();
return content || null;
}
}
} catch {
// Marker unreadable — proceed with fetch
}
try {
const resp = await fetch(PRO_VERSION_URL, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as Record<string, unknown>;
const version = data.version != null ? String(data.version) : null;
if (!version) return null;
try {
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, version);
} catch {
// Non-fatal
}
return version;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------
interface CacheData {
key_sha256: string;
valid: boolean;
plan: string;
expires: string | null;
validated_at: number;
}
function readCache(
cachePath: string,
keySha: string,
ignoreTtl = false,
): LicenseInfo | null {
try {
if (!fs.existsSync(cachePath)) return null;
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData;
if (data.key_sha256 !== keySha) return null;
if (!ignoreTtl) {
const validatedAt = data.validated_at ?? 0;
// A non-numeric validated_at (corrupted cache) is treated as absent rather
// than coercing to NaN and silently trusting the entry.
if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) {
return null;
}
}
if (data.expires) {
try {
if (new Date(data.expires).getTime() < Date.now()) {
return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires };
}
} catch {
// unparseable date — skip check
}
}
return {
valid: Boolean(data.valid ?? false),
plan: String(data.plan ?? "solo"),
expires: data.expires ?? null,
};
} catch {
return null;
}
}
function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void {
try {
const dir = path.dirname(cachePath);
fs.mkdirSync(dir, { recursive: true });
const tmpPath = cachePath + ".tmp";
fs.writeFileSync(
tmpPath,
JSON.stringify({
key_sha256: keySha,
valid: info.valid,
plan: info.plan,
expires: info.expires,
validated_at: Date.now() / 1000,
}),
);
fs.renameSync(tmpPath, cachePath);
} catch {
// Non-fatal
}
}
+182 -78
View File
@@ -3,17 +3,18 @@
* Mirrors Python cloakbrowser/browser.py.
*/
import type { Browser, BrowserContext } from "playwright-core";
import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import { DEFAULT_VIEWPORT } from "./config.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { resolveProxyConfig } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
/** @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 {
if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId;
return merged;
@@ -21,6 +22,123 @@ export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: st
return options;
}
/**
* Strip `locale` and `timezoneId` from user-provided contextOptions both route
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
* fields use binary flags instead (undetectable). Warn so users notice.
*/
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
if (!ctx) return {};
const { locale, timezoneId, ...rest } = ctx;
if (locale !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
if (timezoneId !== undefined) {
console.warn(
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
"instead (routes through binary flag, avoids detectable CDP emulation)."
);
}
return rest;
}
/**
* Build Playwright BrowserContext options for CloakBrowser without launching a browser
* or creating a context.
*
* Useful when integrating CloakBrowser with an existing Playwright Browser while
* keeping the wrapper's stealth-safe defaults for `newContext()`.
*/
/**
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
* top-level field at the actual chromium.launch() call. Viewport logic must read
* the same effective value otherwise a headed browser gets a fixed viewport
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
* resolves headless the opposite way).
*/
function effectiveHeadless(options: LaunchOptions): boolean {
return (
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
options.headless ??
true
);
}
export function buildContextOptions(
options: LaunchContextOptions = {}
): BrowserContextOptions {
// Headed: viewport=null (no emulation) so the page tracks the real window and
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
// inner > outer = a physically impossible window = bot tell. Headless has no
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
const headless = effectiveHeadless(options);
const viewport =
options.viewport !== undefined
? options.viewport
: headless
? DEFAULT_VIEWPORT
: null;
return {
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
} as BrowserContextOptions;
}
/**
* Build Playwright launch options for CloakBrowser without starting Chromium.
*
* Useful when integrating CloakBrowser with a custom Playwright build or another
* wrapper that needs to call `chromium.launch()` itself.
*/
export async function buildLaunchOptions(
options: LaunchOptions = {}
): Promise<PlaywrightLaunchOptions> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
return {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...options.launchOptions,
} as PlaywrightLaunchOptions;
}
/**
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright browser.
*/
export async function humanizeBrowser(
browser: Browser,
options: LaunchOptions = {}
): Promise<void> {
if (!options.humanize) return;
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
/**
* Launch stealth Chromium browser via Playwright.
*
@@ -36,36 +154,34 @@ export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: st
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const browser = await chromium.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions,
});
// Human-like behavioral patching
if (options.humanize) {
const { patchBrowser } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchBrowser(browser, cfg);
const browser = await chromium.launch(await buildLaunchOptions(options));
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
// newPage()/newContext() to viewport:null so the page tracks the real window.
// Headless keeps Playwright's default viewport (coherent there).
if (!effectiveHeadless(options)) {
applyDefaultNoViewport(browser);
}
await humanizeBrowser(browser, options);
return browser;
}
/**
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
* emulation) when the caller didn't specify a viewport. setdefault-style: an
* explicit viewport (including null) is always honored. Apply before humanize's
* patchBrowser so the wraps compose.
*/
function applyDefaultNoViewport(browser: Browser): void {
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
}
/**
* Launch stealth browser and return a BrowserContext with common options pre-set.
* Closing the context also closes the browser.
@@ -85,23 +201,24 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
// humanize:false on the inner launch — patchContext below applies humanize
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
let context: BrowserContext;
try {
context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
context = await browser.newContext(buildContextOptions(options));
} catch (err) {
await browser.close();
throw err;
@@ -119,8 +236,8 @@ export async function launchContext(
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
@@ -152,26 +269,29 @@ export async function launchContext(
export async function launchPersistentContext(
options: LaunchPersistentContextOptions
): Promise<BrowserContext> {
options = migrateTimezoneId(options);
options = resolveTimezone(options);
const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
seedWidevineHint(options.userDataDir, binaryPath);
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...buildContextOptions(options),
...options.launchOptions,
});
@@ -180,8 +300,8 @@ export async function launchPersistentContext(
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
@@ -193,21 +313,5 @@ export async function launchPersistentContext(
// Internal
// ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */
export { buildArgs as _buildArgsForTest } from "./args.js";
+284 -2
View File
@@ -2,22 +2,304 @@
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
export interface ParsedProxy {
server: string;
username?: string;
password?: string;
}
/**
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
* Used by geoip resolution which only needs a valid hostname, not auth fields.
*/
export function ensureProxyScheme(proxyUrl: string): string {
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}
/**
* Parse a proxy URL, extracting credentials into separate fields.
*
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port.
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
/** Playwright proxy option (for HTTP proxies). */
proxyOption?: ParsedProxy;
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
proxyArgs: string[];
}
/**
* Check if a proxy uses the SOCKS5 protocol.
*/
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
if (!proxy) return false;
const url = typeof proxy === "string" ? proxy : proxy.server;
return /^socks5h?:\/\//i.test(url);
}
/**
* Build a SOCKS URL from already-percent-encoded credentials and a host suffix.
*
* `encPass === null` means no password (no colon in userinfo). Empty string
* means present-but-empty (colon preserved).
*/
function assembleSocksUrl(
scheme: string,
encUser: string,
encPass: string | null,
hostAndRest: string,
): string {
let userinfo: string;
if (encPass !== null) {
userinfo = `${encUser}:${encPass}@`;
} else if (encUser) {
userinfo = `${encUser}@`;
} else {
userinfo = "";
}
return `${scheme}://${userinfo}${hostAndRest}`;
}
/**
* Lenient percent-decode that handles malformed escapes gracefully, matching
* Python's ``urllib.parse.unquote``: valid ``%XX`` sequences are decoded,
* bare ``%`` not followed by two hex digits is left as a literal ``%``.
*/
function lenientDecodeURIComponent(s: string): string {
return s.replace(/%([0-9A-Fa-f]{2})|%/g, (match, hex) =>
hex ? String.fromCharCode(parseInt(hex, 16)) : "%",
);
}
/**
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
*/
export function reconstructSocksUrl(proxy: ProxyDict): string {
const url = new URL(proxy.server);
if (proxy.username) {
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
}
return url.href.replace(/\/$/, "");
}
/**
* Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't
* truncate them at special chars like '='. Idempotent: pre-encoded input stays
* the same (decoded then re-encoded).
*
* Parsing is done manually rather than via `new URL` + setters, because WHATWG
* URL's username/password setters re-encode `%` on assignment, causing
* double-encoding when we round-trip decode-then-encode.
*
* On any unexpected failure, logs a warning and returns the original string
* so Chromium's own error handling can surface the real problem.
*/
export function normalizeSocksStringUrl(urlStr: string): string {
// Split userinfo from host at the LAST '@' (RFC 3986), so a raw '@' inside
// a password like `socks5://user:p@ss@host:1080` parses correctly. Matches
// Python urlparse's rpartition('@') behavior.
const schemeMatch = urlStr.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
if (!schemeMatch) return urlStr;
const [, scheme, rest] = schemeMatch;
const hostStart = rest.search(/[/?#]/);
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
const atIdx = authority.lastIndexOf("@");
if (atIdx === -1) return urlStr; // no creds
const userinfo = authority.slice(0, atIdx);
const hostPart = authority.slice(atIdx + 1);
// Validate port (matches Python's urlparse().port ValueError guard).
// Extract port after last ':' — but skip IPv6 brackets (e.g. [::1]:1080).
const bracketEnd = hostPart.lastIndexOf("]");
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
if (portColonIdx !== -1) {
const portStr = hostPart.slice(portColonIdx + 1);
if (portStr && !/^\d+$/.test(portStr)) {
console.warn(`[cloakbrowser] Malformed SOCKS5 proxy URL, passing through unchanged: invalid port`);
return urlStr;
}
}
const hostAndRest = hostPart + suffix;
const colonIdx = userinfo.indexOf(":");
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
const hasPassword = colonIdx !== -1;
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
try {
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
const normalized = assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
// Compare credentials, not the full URL: keeps the log condition focused
// on real encoding work, not cosmetic differences (parity with the Python
// implementation, which has to skip urlparse's hostname lowercasing).
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded SOCKS5 proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return normalized;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
return urlStr;
}
}
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
export function supportsHttpProxyInlineAuth(): boolean {
try {
const tag = getPlatformTag();
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
const current = parseVersion(getChromiumVersion());
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
}
return true; // equal = supported
} catch {
return false;
}
}
function hasCredentials(proxy: string | ProxyDict): boolean {
if (typeof proxy === "string") return proxy.includes("@");
return !!proxy.username;
}
/**
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
*/
export function reconstructHttpUrl(proxy: ProxyDict): string {
if (!proxy.username) return proxy.server;
const url = new URL(ensureProxyScheme(proxy.server));
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
return url.href.replace(/\/$/, "");
}
/**
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
* Same pattern as normalizeSocksStringUrl.
*/
export function normalizeHttpStringUrl(urlStr: string): string {
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
if (!schemeMatch) return normalized;
const [, scheme, rest] = schemeMatch;
const hostStart = rest.search(/[/?#]/);
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
const atIdx = authority.lastIndexOf("@");
if (atIdx === -1) return normalized;
const userinfo = authority.slice(0, atIdx);
const hostPart = authority.slice(atIdx + 1);
const bracketEnd = hostPart.lastIndexOf("]");
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
if (portColonIdx !== -1) {
const portStr = hostPart.slice(portColonIdx + 1);
if (portStr && !/^\d+$/.test(portStr)) {
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
return normalized;
}
}
const hostAndRest = hostPart + suffix;
const colonIdx = userinfo.indexOf(":");
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
const hasPassword = colonIdx !== -1;
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
try {
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
let userinfoPart: string;
if (encPass !== null) {
userinfoPart = `${encUser}:${encPass}@`;
} else if (encUser) {
userinfoPart = `${encUser}@`;
} else {
userinfoPart = "";
}
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return result;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
return normalized;
}
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
if (isSocksProxy(proxy)) {
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
if (typeof proxy === "string") {
// Re-encode creds to work around Chromium parser truncating passwords
// at '=' and other special chars (#157).
return { proxyArgs: [`--proxy-server=${normalizeSocksStringUrl(proxy)}`] };
}
const socksUrl = reconstructSocksUrl(proxy);
const args = [`--proxy-server=${socksUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
const httpUrl = reconstructHttpUrl(proxy);
const args = [`--proxy-server=${httpUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}
export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
const normalized =
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
try {
url = new URL(proxy);
url = new URL(normalized);
} catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy };
+160 -69
View File
@@ -1,71 +1,102 @@
/**
* Puppeteer launch wrapper for cloakbrowser.
* Alternative to the Playwright wrapper for users who prefer Puppeteer.
* NOW WITH HUMANIZE SUPPORT humanize: true enables human-like
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
*/
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/**
* Launch stealth Chromium browser via Puppeteer.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* const browser = await launch();
* const page = await browser.newPage();
* await page.goto('https://bot.incolumitas.com');
* console.log(await page.title());
* await browser.close();
* ```
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
* emulated viewport = a physically impossible window = bot tell). Headless has no
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
// launchOptions spread, so the top-level field wins at launch — match it here.
if (launchOpts.defaultViewport !== undefined) {
return launchOpts.defaultViewport as { width: number; height: number } | null;
}
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
}
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved });
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary(options.licenseKey));
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
// Puppeteer handles proxy via CLI args, not a separate option.
// Chromium's --proxy-server does NOT support inline credentials,
// so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) {
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
}
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
return { binaryPath, args: buildArgs({ ...options, ...resolved, args: resolvedArgs }) };
}
/**
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
* HTTP on supported platforms: inline credentials via --proxy-server.
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
*/
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
if (!options.proxy) return undefined;
if (isSocksProxy(options.proxy)) {
const { proxyArgs } = resolveProxyConfig(options.proxy);
args.push(...proxyArgs);
return undefined;
}
const browser = await puppeteer.default.launch({
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: ["--enable-automation"],
...options.launchOptions,
});
// On supported platforms: pass full URL with inline creds to --proxy-server
if (supportsHttpProxyInlineAuth()) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
}
const url = options.proxy.username
? reconstructHttpUrl(options.proxy)
: options.proxy.server;
args.push(`--proxy-server=${url}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
return undefined;
}
// Monkey-patch newPage() to auto-authenticate proxy credentials
// Unsupported platform: strip credentials, fall back to page.authenticate()
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
return username ? { username, password: password ?? "" } : undefined;
}
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
return username ? { username, password: password ?? "" } : undefined;
}
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
async function applyPostLaunch(
browser: Browser,
options: LaunchOptions,
proxyAuth?: { username: string; password: string },
): Promise<void> {
if (proxyAuth) {
const origNewPage = browser.newPage.bind(browser);
const auth = proxyAuth;
@@ -76,26 +107,86 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
};
}
if (options.humanize) {
const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchBrowser(browser, cfg);
}
}
/**
* Launch stealth Chromium browser via Puppeteer.
*
* @example
* ```ts
* import { launch } from 'cloakbrowser/puppeteer';
* // With humanize — human-like mouse, keyboard, scroll
* const browser = await launch({ humanize: true });
* const page = await browser.newPage();
* await page.goto('https://example.com');
* await page.click('#login'); // Bézier curve mouse movement
* await page.type('#email', 'user@example.com'); // Per-character timing
* ```
*/
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const { binaryPath, args } = await resolveArgs(options);
const proxyAuth = resolveProxy(options, args);
const browser = await puppeteer.default.launch({
...options.launchOptions,
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);
return browser;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/**
* Launch stealth Chromium with a persistent user profile via Puppeteer.
* Passes `userDataDir` to Puppeteer's launch options so cookies,
* localStorage, and session data persist across launches.
*
* @example
* ```ts
* import { launchPersistentContext } from 'cloakbrowser/puppeteer';
* const browser = await launchPersistentContext({
* userDataDir: './chrome-profile',
* headless: false,
* proxy: 'http://user:pass@proxy:8080',
* });
* const page = await browser.newPage();
* await page.goto('https://example.com');
* await browser.close();
* ```
*/
export async function launchPersistentContext(
options: LaunchOptions & { userDataDir: string }
): Promise<Browser> {
const puppeteer = await import("puppeteer-core");
const { binaryPath, args } = await resolveArgs(options);
const proxyAuth = resolveProxy(options, args);
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
seedWidevineHint(options.userDataDir, binaryPath);
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
const browser = await puppeteer.default.launch({
...options.launchOptions,
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
userDataDir: options.userDataDir,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);
return browser;
}
+23 -4
View File
@@ -2,6 +2,9 @@
* Shared types for cloakbrowser launch wrappers.
*/
import type { BrowserContextOptions } from "playwright-core";
import type { HumanConfig, HumanPreset } from "./human/config.js";
export interface LaunchOptions {
/** Run in headless mode (default: true). */
headless?: boolean;
@@ -14,6 +17,8 @@ export interface LaunchOptions {
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */
args?: string[];
/** Chrome extension paths to load. */
extensionPaths?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
stealthArgs?: boolean;
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
@@ -22,27 +27,38 @@ export interface LaunchOptions {
locale?: string;
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
geoip?: boolean;
/** Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. */
licenseKey?: string;
/** Raw options passed directly to playwright/puppeteer launch(). */
launchOptions?: Record<string, unknown>;
/** Enable human-like mouse, keyboard, and scroll behavior. */
humanize?: boolean;
/** Human behavior preset: 'default' or 'careful'. */
humanPreset?: 'default' | 'careful';
humanPreset?: HumanPreset;
/** Override individual human behavior parameters. */
humanConfig?: Record<string, unknown>;
humanConfig?: Partial<HumanConfig>;
}
export interface LaunchContextOptions extends LaunchOptions {
/** Custom user agent string. */
userAgent?: string;
/** Viewport size. */
viewport?: { width: number; height: number };
viewport?: { width: number; height: number } | null;
/** Browser locale, e.g. "en-US". */
locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
/** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference";
/**
* Extra options forwarded directly to Playwright's `browser.newContext()`
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
* `httpCredentials`. Use this for context-level options not surfaced as
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
* detectable CDP emulation use the top-level `locale` and `timezone`
* wrapper fields instead (they route through undetectable binary flags).
*/
contextOptions?: BrowserContextOptions;
}
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
@@ -52,7 +68,10 @@ export interface LaunchPersistentContextOptions extends LaunchContextOptions {
export interface BinaryInfo {
version: string;
/** The wrapper's bundled baseline Chromium version (CHROMIUM_VERSION). */
bundledVersion: string;
platform: string;
tier: "pro" | "free";
binaryPath: string;
installed: boolean;
cacheDir: string;
+111
View File
@@ -0,0 +1,111 @@
/**
* Widevine CDM hint-file seeding for persistent contexts.
* Mirrors Python cloakbrowser/widevine.py.
*
* CloakBrowser's binary supports Widevine but ships no CDM (proprietary, can't
* redistribute). Users sideload it by copying a `WidevineCdm/` directory from a
* real Chrome install next to the binary (see issue #96). Chromium reads a
* "hint file" from the user-data-dir at early startup to register the CDM, but
* on a fresh profile it doesn't exist yet, and Playwright disables the component
* updater that would write it. This seeds the hint file before launch so a
* sideloaded CDM works on the first run. It never bundles, downloads, or copies
* the CDM only writes the hint when a user-provided CDM is already present.
*
* Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific.
*/
import fs from "node:fs";
import path from "node:path";
const HINT_FILENAME = "latest-component-updated-widevine-cdm";
/** True if `file` exists and is a regular file (mirrors Python's Path.is_file()). */
function isFile(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
/** Absolute, symlink-resolved path (mirrors Python's Path.resolve()). */
function realPath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}
function seedingDisabled(): boolean {
const val = (process.env.CLOAKBROWSER_WIDEVINE ?? "").trim().toLowerCase();
return val === "0" || val === "false" || val === "off" || val === "no";
}
/**
* Locate a sideloaded Widevine CDM directory, or null if absent.
*
* Resolution:
* - If CLOAKBROWSER_WIDEVINE_CDM is set, it is used exclusively (overrides
* auto-detection). An invalid value (no `manifest.json`) skips seeding.
* - Otherwise, `<dir of the chrome binary>/WidevineCdm` where a user naturally
* drops it, and where it lives for both downloaded and CLOAKBROWSER_BINARY_PATH binaries.
*
* A directory counts only if it contains `manifest.json`. The returned path is
* absolute and symlink-resolved (mirrors Python's Path.resolve()).
* @internal Exported for testing.
*/
export function resolveWidevineCdmDir(binaryPath: string): string | null {
const custom = process.env.CLOAKBROWSER_WIDEVINE_CDM;
// `!== undefined` (not truthiness): a present-but-empty env var is "set" and
// used exclusively — it resolves to an invalid path and skips seeding.
const cdmDir = custom !== undefined ? custom : path.join(path.dirname(binaryPath), "WidevineCdm");
return isFile(path.join(cdmDir, "manifest.json")) ? realPath(cdmDir) : null;
}
/**
* Write the Widevine CDM hint file into a persistent profile before launch.
* `binaryPath` is the resolved chrome executable; the CDM is looked for next to
* it. No-op on non-Linux, when disabled via CLOAKBROWSER_WIDEVINE, or when no
* sideloaded CDM is present. Never throws a failure must not break launch.
*/
export function seedWidevineHint(userDataDir: string, binaryPath: string): void {
if (process.platform !== "linux") return;
if (seedingDisabled()) return;
// Empty userDataDir = Playwright's ephemeral profile (its own temp dir);
// a persistent hint can't be placed there, and "" would pollute the CWD.
if (!userDataDir) return;
// Everything below is best-effort and must never break the browser launch,
// so the whole body (resolution + write) is guarded.
try {
const cdmDir = resolveWidevineCdmDir(binaryPath);
if (cdmDir === null) {
if (process.env.CLOAKBROWSER_WIDEVINE_CDM !== undefined) {
console.warn(
"[cloakbrowser] CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " +
"skipping Widevine hint seeding",
);
}
return;
}
const hintDir = path.join(userDataDir, "WidevineCdm");
fs.mkdirSync(hintDir, { recursive: true });
const hintFile = path.join(hintDir, HINT_FILENAME);
// cdmDir is already absolute/resolved.
const content = JSON.stringify({ Path: cdmDir });
try {
if (isFile(hintFile) && fs.readFileSync(hintFile, "utf-8") === content) {
return; // already seeded correctly
}
} catch {
console.warn("[cloakbrowser] Existing Widevine hint unreadable; rewriting");
}
fs.writeFileSync(hintFile, content);
} catch (e) {
// Best-effort: never break the launch, but surface the failure.
console.warn("[cloakbrowser] Failed to seed Widevine CDM hint file:", e);
}
}
+33 -13
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl,
getFallbackDownloadUrl,
} from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => {
@@ -21,16 +21,17 @@ describe("config", () => {
const isMac = process.platform === "darwin";
expect(args).toContain("--no-sandbox");
expect(args).toContain("--disable-blink-features=AutomationControlled");
if (isMac) {
expect(args).toContain("--fingerprint-platform=macos");
// macOS: no hardware-concurrency or GPU spoofing (uses native values)
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else {
expect(args).toContain("--fingerprint-platform=windows");
}
// GPU flags removed — binary auto-generates from seed + platform
expect(args.some((a) => a.includes("fingerprint-gpu-vendor"))).toBe(false);
expect(args.some((a) => a.includes("fingerprint-gpu-renderer"))).toBe(false);
// Should have a random fingerprint seed
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
expect(fingerprintArg).toBeDefined();
@@ -97,21 +98,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York");
});
it("injects --lang when locale is set", () => {
it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
});
it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
});
it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
});
@@ -119,6 +123,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
});
});
@@ -147,14 +152,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
});
it("locale param overrides user --lang arg", () => {
it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({
args: ["--lang=de-DE"],
args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US",
});
const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
});
it("no duplicate flag keys in output", () => {
@@ -175,29 +183,41 @@ describe("buildArgs deduplication", () => {
});
});
describe("migrateTimezoneId deprecation", () => {
it("migrates timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
describe("buildArgs webrtc IP", () => {
it("passes --fingerprint-webrtc-ip from args", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
});
it("does not inject when not in args", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
});
});
describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId");
});
it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId");
});
it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC");
});
it("returns options unchanged when neither is set", () => {
const opts = {};
const result = migrateTimezoneId(opts);
const result = resolveTimezone(opts);
expect(result).toBe(opts);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from "vitest";
import path from "path";
import { _buildArgsForTest } from "../src/playwright.js";
test("extension paths inject chrome flags", () => {
const args = _buildArgsForTest({
extensionPaths: ["./ext"],
});
const abs = path.resolve("./ext");
expect(args).toContain(`--load-extension=${abs}`);
expect(args).toContain(
`--disable-extensions-except=${abs}`
);
});
+70 -2
View File
@@ -1,5 +1,17 @@
import { describe, it, expect } from "vitest";
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
import { describe, it, expect, afterEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { COUNTRY_LOCALE_MAP, maybeResolveGeoip, resolveProxyGeo, resolveProxyIp } from "../src/geoip.js";
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
delete process.env.CLOAKBROWSER_CACHE_DIR;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveProxyIp", () => {
it("returns literal IPv4 from proxy URL", async () => {
@@ -25,8 +37,64 @@ describe("resolveProxyIp", () => {
it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull();
});
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
// no scheme — new URL() gives empty hostname for both bare formats
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
});
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
});
describe("maybeResolveGeoip", () => {
it("does not apply the GeoIP resolution timeout to first-use database download", async () => {
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-download-"));
tempDirs.push(cacheDir);
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.001";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
}),
} as Response);
const result = await resolveProxyGeo("http://203.0.113.10:8080");
expect(result).toEqual({ timezone: null, locale: null, exitIp: null });
expect(fetchSpy).toHaveBeenCalledOnce();
expect(fetchSpy.mock.calls[0][1]).toEqual({ redirect: "follow" });
});
it("returns quickly when GeoIP resolution times out", async () => {
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-timeout-"));
tempDirs.push(cacheDir);
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.025";
const start = performance.now();
const result = await maybeResolveGeoip({
geoip: true,
proxy: "http://203.0.113.10:8080",
timezone: "Europe/Paris",
locale: "fr-FR",
});
const elapsed = performance.now() - start;
expect(result).toEqual({ timezone: "Europe/Paris", locale: "fr-FR", exitIp: undefined });
expect(elapsed).toBeLessThan(500);
});
});
describe("COUNTRY_LOCALE_MAP", () => {
it("contains common countries", () => {
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
+1126 -77
View File
File diff suppressed because it is too large Load Diff
+315 -27
View File
@@ -1,17 +1,195 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { binaryInfo } from "../src/download.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
import { DEFAULT_VIEWPORT, getBinaryPath, getChromiumVersion, getPlatformTag } from "../src/config.js";
import * as config from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
const info = binaryInfo();
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}`;
try {
const info = binaryInfo();
expect(info.version).toBe(getChromiumVersion());
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean");
expect(info.cacheDir).toContain("cloakbrowser");
expect(info.downloadUrl).toContain(".tar.gz");
expect(info.version).toBe(getChromiumVersion());
expect(info.bundledVersion).toBeTruthy();
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
expect(info.binaryPath).toBeTruthy();
expect(typeof info.installed).toBe("boolean");
expect(info.cacheDir).toContain("cloakbrowser");
} finally {
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
else delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
it("reports tier from the installed binary, not a cached license", () => {
// A valid, fresh license is cached but NO Pro binary is on disk → free.
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
const dir = `/tmp/cloakbrowser-test-${Date.now()}-tier`;
fs.mkdirSync(dir, { recursive: true });
process.env.CLOAKBROWSER_CACHE_DIR = dir;
try {
fs.writeFileSync(
path.join(dir, ".license_cache"),
JSON.stringify({
key_sha256: "abc",
valid: true,
plan: "solo",
expires: null,
validated_at: Date.now() / 1000,
})
);
expect(binaryInfo().tier).toBe("free");
// Now drop a Pro binary on disk → pro.
fs.writeFileSync(path.join(dir, `latest_pro_version_${getPlatformTag()}`), "147.0.5555.1");
const bp = getBinaryPath("147.0.5555.1", true);
fs.mkdirSync(path.dirname(bp), { recursive: true });
fs.writeFileSync(bp, "fake");
fs.chmodSync(bp, 0o755);
const info = binaryInfo();
expect(info.tier).toBe("pro");
expect(info.version).toBe("147.0.5555.1");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
else delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
});
describe("composable Playwright launch helpers", () => {
const origBinaryPath = process.env.CLOAKBROWSER_BINARY_PATH;
beforeEach(() => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
if (origBinaryPath) {
process.env.CLOAKBROWSER_BINARY_PATH = origBinaryPath;
} else {
delete process.env.CLOAKBROWSER_BINARY_PATH;
}
});
it("exports composable helpers from the package entrypoint", async () => {
const entry = await import("../src/index.js");
expect(entry.buildLaunchOptions).toBeTypeOf("function");
expect(entry.buildContextOptions).toBeTypeOf("function");
expect(entry.humanizeBrowser).toBeTypeOf("function");
});
it("buildContextOptions returns Playwright context options without launching a browser", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { buildContextOptions } = await import("../src/index.js");
const options = buildContextOptions({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "Context/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
expect(options).toMatchObject({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
storageState: "state.json",
});
expect(options.locale).toBeUndefined();
expect(options.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
it("buildContextOptions applies DEFAULT_VIEWPORT by default and allows null viewport", async () => {
const { buildContextOptions } = await import("../src/index.js");
expect(buildContextOptions().viewport).toEqual(DEFAULT_VIEWPORT);
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
});
it("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
const { buildContextOptions } = await import("../src/index.js");
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
// Headless keeps the deterministic default.
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
// Explicit viewport always honored, even headed.
const custom = { width: 800, height: 600 };
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
});
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
const { buildContextOptions } = await import("../src/index.js");
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
// at the actual launch. Viewport must follow it — a raw headless:false (browser
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
// And launchOptions.headless:true forces the deterministic viewport even if the
// top-level field said headed.
expect(
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
).toEqual(DEFAULT_VIEWPORT);
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { buildLaunchOptions } = await import("../src/index.js");
const options = await buildLaunchOptions({
headless: false,
proxy: "http://user:pass@proxy.example:8080",
args: ["--custom-flag"],
launchOptions: { timeout: 1234 },
});
expect(options.executablePath).toBe("/fake/chrome");
expect(options.headless).toBe(false);
expect(options.args).toContain("--custom-flag");
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
expect(options.proxy).toEqual({
server: "http://proxy.example:8080",
username: "user",
password: "pass",
});
expect(options.timeout).toBe(1234);
} finally {
vi.restoreAllMocks();
}
});
it("humanizeBrowser patches an existing browser only when requested", async () => {
const { humanizeBrowser } = await import("../src/index.js");
const browser = {
contexts: () => [],
newContext: vi.fn(async () => ({})),
newPage: vi.fn(async () => ({ context: () => ({}) })),
};
const originalNewContext = browser.newContext;
await humanizeBrowser(browser as any, { humanize: false });
expect(browser.newContext).toBe(originalNewContext);
await humanizeBrowser(browser as any, { humanize: true });
expect(browser.newContext).not.toBe(originalNewContext);
});
});
@@ -96,20 +274,20 @@ describe("launchContext (unit)", () => {
expect(ctxArgs.userAgent).toBe("Custom/1.0");
});
it("passes timezone to context timezoneId, not to launch", async () => {
it("passes timezone via binary flag, not CDP context", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag)
// launch() called with --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=")
a.startsWith("--fingerprint-timezone=America/New_York")
);
expect(hasTimezoneFlag).toBe(false);
expect(hasTimezoneFlag).toBe(true);
// newContext() gets timezoneId
// NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York");
expect(ctxArgs.timezoneId).toBeUndefined();
});
it("forwards colorScheme to newContext", async () => {
@@ -130,6 +308,60 @@ describe("launchContext (unit)", () => {
// Browser also closed
expect(mockBrowser.close).toHaveBeenCalledOnce();
});
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
permissions: ["geolocation"],
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.storageState).toBe("state.json");
expect(ctxArgs.permissions).toEqual(["geolocation"]);
});
it("explicit top-level fields win over contextOptions on collision", async () => {
const { launchContext } = await import("../src/playwright.js");
await launchContext({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
expect(ctxArgs.colorScheme).toBe("dark");
});
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchContext } = await import("../src/playwright.js");
await launchContext({
contextOptions: {
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
expect(ctxArgs.locale).toBeUndefined();
expect(ctxArgs.timezoneId).toBeUndefined();
// Benign keys preserved
expect(ctxArgs.storageState).toBe("state.json");
// Warning was logged for both stripped keys
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
describe("launchPersistentContext (unit)", () => {
@@ -165,7 +397,7 @@ describe("launchPersistentContext (unit)", () => {
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
});
it("passes timezone and locale to context", async () => {
it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
@@ -174,24 +406,31 @@ describe("launchPersistentContext (unit)", () => {
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo");
expect(args.locale).toBe("ja-JP");
// Also in binary args
// Binary args (native, undetectable)
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP");
// NOT in context kwargs (would trigger detectable CDP emulation)
expect(args.timezoneId).toBeUndefined();
expect(args.locale).toBeUndefined();
});
it("forwards proxy string", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
proxy: "http://user:pass@proxy:8080",
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
} finally {
vi.restoreAllMocks();
}
});
it("forwards userAgent and colorScheme", async () => {
@@ -206,4 +445,53 @@ describe("launchPersistentContext (unit)", () => {
expect(args.userAgent).toBe("Custom/1.0");
expect(args.colorScheme).toBe("dark");
});
it("forwards contextOptions to launchPersistentContext", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
permissions: ["geolocation"],
extraHTTPHeaders: { "X-Custom": "1" },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.permissions).toEqual(["geolocation"]);
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
});
it("explicit top-level fields win over contextOptions in persistent context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
contextOptions: {
userAgent: "ShouldBeOverridden/9.9",
viewport: { width: 9999, height: 9999 },
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.userAgent).toBe("Explicit/1.0");
expect(args.viewport).toEqual({ width: 1280, height: 720 });
});
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
contextOptions: {
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.locale).toBeUndefined();
expect(args.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
+312
View File
@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import {
resolveLicenseKey,
validateLicense,
getProLatestVersion,
} from "../src/license.js";
import * as config from "../src/config.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = path.join("/tmp", `cloakbrowser-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
vi.spyOn(config, "getCacheDir").mockReturnValue(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {}
});
// ── resolveLicenseKey ─────────────────────────────────
describe("resolveLicenseKey", () => {
it("explicit param wins over env", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
expect(resolveLicenseKey("explicit")).toBe("explicit");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("env var fallback", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
expect(resolveLicenseKey()).toBe("env-key");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("returns undefined when absent", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
expect(resolveLicenseKey()).toBeUndefined();
});
it("file fallback when no param or env", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
const keyFile = path.join(tmpDir, "license.key");
fs.writeFileSync(keyFile, "file-key-123\n");
expect(resolveLicenseKey()).toBe("file-key-123");
});
it("env takes precedence over file", () => {
process.env.CLOAKBROWSER_LICENSE_KEY = "env-key";
const keyFile = path.join(tmpDir, "license.key");
fs.writeFileSync(keyFile, "file-key");
expect(resolveLicenseKey()).toBe("env-key");
delete process.env.CLOAKBROWSER_LICENSE_KEY;
});
it("returns undefined when file missing", () => {
delete process.env.CLOAKBROWSER_LICENSE_KEY;
expect(resolveLicenseKey()).toBeUndefined();
});
});
// ── validateLicense ───────────────────────────────────
describe("validateLicense", () => {
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
it("fresh cache skips server call", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "team",
expires: "2026-12-01",
validated_at: Date.now() / 1000,
})
);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const result = await validateLicense("test-key");
expect(fetchSpy).not.toHaveBeenCalled();
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.plan).toBe("team");
});
it("stale cache triggers server call", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: null,
validated_at: Date.now() / 1000 - 90000, // 25 hours ago
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("test-key");
expect(globalThis.fetch).toHaveBeenCalledOnce();
expect(result!.valid).toBe(true);
});
it("server success returns LicenseInfo", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "business", expires: "2026-07-13" }),
} as Response);
const result = await validateLicense("pro-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.plan).toBe("business");
expect(result!.expires).toBe("2026-07-13");
});
it("server rejection returns invalid", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: false, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("bad-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
});
it("server unreachable uses stale cache", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: "2026-12-01",
validated_at: Date.now() / 1000 - 90000,
})
);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const result = await validateLicense("test-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
});
it("server unreachable no cache returns null", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const result = await validateLicense("test-key");
expect(result).toBeNull();
});
it("cache stores hash not raw key", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
await validateLicense("secret-key-123");
const cachePath = path.join(tmpDir, ".license_cache");
const content = fs.readFileSync(cachePath, "utf-8");
expect(content).not.toContain("secret-key-123");
const expectedSha = crypto
.createHash("sha256")
.update("secret-key-123")
.digest("hex");
expect(content).toContain(expectedSha);
});
it("wrong key cache ignored", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: "other-hash",
valid: true,
plan: "solo",
expires: null,
validated_at: Date.now() / 1000,
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
await validateLicense("different-key");
expect(globalThis.fetch).toHaveBeenCalledOnce();
});
it("expired license rejected from cache", async () => {
const cachePath = path.join(tmpDir, ".license_cache");
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
fs.writeFileSync(
cachePath,
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: "2020-01-01T00:00:00+00:00",
validated_at: Date.now() / 1000,
})
);
const result = await validateLicense("test-key");
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
});
it("does not cache invalid responses", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: false, plan: "solo", expires: null }),
} as Response);
await validateLicense("bad-key");
const cachePath = path.join(tmpDir, ".license_cache");
expect(fs.existsSync(cachePath)).toBe(false);
});
it("corrupted validated_at is treated as absent cache, not trusted", async () => {
const keySha = crypto.createHash("sha256").update("test-key").digest("hex");
fs.writeFileSync(
path.join(tmpDir, ".license_cache"),
JSON.stringify({
key_sha256: keySha,
valid: true,
plan: "solo",
expires: null,
validated_at: "not-a-number",
})
);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ valid: true, plan: "solo", expires: null }),
} as Response);
const result = await validateLicense("test-key");
expect(globalThis.fetch).toHaveBeenCalledOnce(); // corrupted cache ignored → server hit
expect(result!.valid).toBe(true);
});
});
// ── getProLatestVersion ───────────────────────────────
describe("getProLatestVersion", () => {
it("fetches version from server", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "147.0.1234.5" }),
} as Response);
const version = await getProLatestVersion();
expect(version).toBe("147.0.1234.5");
});
it("rate limited by marker file", async () => {
const marker = path.join(tmpDir, ".last_pro_version_check");
fs.writeFileSync(marker, "147.0.1234.5");
const fetchSpy = vi.spyOn(globalThis, "fetch");
const version = await getProLatestVersion();
expect(fetchSpy).not.toHaveBeenCalled();
expect(version).toBe("147.0.1234.5");
});
it("network error returns null", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
const version = await getProLatestVersion();
expect(version).toBeNull();
});
});
// ── Config pro parameter ──────────────────────────────
describe("config pro parameter", () => {
it("getBinaryDir adds -pro suffix", () => {
const normal = config.getBinaryDir("147.0.0.0");
const pro = config.getBinaryDir("147.0.0.0", true);
expect(normal).toMatch(/chromium-147\.0\.0\.0$/);
expect(pro).toMatch(/chromium-147\.0\.0\.0-pro$/);
});
it("getBinaryDir default has no suffix", () => {
const normal = config.getBinaryDir("147.0.0.0");
expect(normal).not.toMatch(/-pro$/);
});
});
+340 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js";
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
import * as config from "../src/config.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
@@ -82,3 +83,340 @@ describe("proxy dict type", () => {
}
});
});
describe("bare proxy format (user:pass@host:port)", () => {
it("extracts credentials from bare format", () => {
expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("credentials not in server", () => {
const r = parseProxyUrl("user:pass@proxy1.example.com:5610");
expect(r.server).not.toContain("user");
expect(r.server).not.toContain("pass");
});
it("bare username only", () => {
const r = parseProxyUrl("user@proxy:8080");
expect(r.username).toBe("user");
expect(r.password).toBeUndefined();
expect(r.server).toBe("http://proxy:8080");
});
it("bare no port", () => {
const r = parseProxyUrl("user:pass@proxy.example.com");
expect(r.username).toBe("user");
expect(r.server).toBe("http://proxy.example.com");
});
it("bare no credentials passes through unchanged", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
});
});
describe("isSocksProxy", () => {
it("detects socks5 string", () => {
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
});
it("detects socks5h string", () => {
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
});
it("case insensitive", () => {
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
});
it("rejects http", () => {
expect(isSocksProxy("http://host:8080")).toBe(false);
});
it("detects socks5 dict", () => {
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
});
it("rejects http dict", () => {
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
});
it("returns false for undefined", () => {
expect(isSocksProxy(undefined)).toBe(false);
});
});
describe("resolveProxyConfig", () => {
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string on unsupported platform", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("returns playwright dict for http dict", () => {
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
expect(proxyOption).toEqual(proxy);
expect(proxyArgs).toEqual([]);
});
it("returns chrome arg for socks5 string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
});
it("returns chrome arg for socks5 no auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("returns chrome arg for socks5h string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
});
it("reconstructs URL from socks5 dict with auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
username: "user",
password: "p@ss",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:p%40ss@host:1080"]);
});
it("includes bypass for socks5 dict", () => {
const { proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
bypass: ".example.com",
});
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
});
// Chromium's --proxy-server parser truncates passwords at '=' (#157).
// Wrapper must auto URL-encode before passing to Chrome.
it("encodes '=' in socks5 string password", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:pass=123@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3D123@host:1080"]);
});
it("encoding is idempotent for already-encoded socks5 string", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:pass%3D123@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3D123@host:1080"]);
});
it("leaves socks5 string without creds unchanged", () => {
const { proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("encodes password even with empty username (password-only userinfo)", () => {
// Regression: empty-username bypass would skip encoding, leaving the
// Chromium truncation bug alive for this userinfo shape.
const { proxyArgs } = resolveProxyConfig("socks5://:pass=123@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://:pass%3D123@host:1080"]);
});
it("handles literal '%' in password without throwing (malformed escape)", () => {
// JS's decodeURIComponent throws on '%sure' (% not followed by 2 hex digits).
// Must fall back to treating '%' as literal and percent-encoding it.
const { proxyArgs } = resolveProxyConfig("socks5://user:100%sure@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:100%25sure@host:1080"]);
});
it("passes malformed SOCKS5 URLs through unchanged (no throw)", () => {
// Broken IPv6 bracket — wrapper must not throw;
// Chromium will surface its own error.
const { proxyArgs: a1 } = resolveProxyConfig("socks5://user:pass@[::1");
expect(a1).toEqual(["--proxy-server=socks5://user:pass@[::1"]);
});
it("passes non-numeric port through unchanged", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:pass@host:abc");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:abc"]);
});
it("encodes special chars in IPv6 SOCKS5 string password", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:pass=eq@[::1]:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3Deq@[::1]:1080"]);
});
// Regression #157: userinfo must be split at the LAST '@' (RFC 3986),
// not the first, so raw '@' in a password parses correctly.
it("encodes raw '@' in socks5 string password (last-@ split)", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:p@ss@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:p%40ss@host:1080"]);
});
it("handles multiple raw '@' in password (splits at last)", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@host:1080"]);
});
// Visibility for #157: when wrapper actually rewrites the URL, surface an
// info log so users debugging silent SOCKS5 fallback can see what happened.
it("logs info message when SOCKS5 credentials get re-encoded", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://user:pass=123@host:1080");
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("Auto URL-encoded SOCKS5"),
);
// Credentials must not leak into the log.
const calls = debugSpy.mock.calls.flat().join(" ");
expect(calls).not.toContain("pass=123");
expect(calls).not.toContain("pass%3D123");
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when SOCKS5 URL is already encoded (no log spam)", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://user:pass%3D123@host:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when SOCKS5 URL has no credentials", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://host:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when only host case differs (no credential rewrite)", () => {
// Parity with Python: log condition must track credential changes, not
// cosmetic URL-string differences (regression for Copilot's PR #209 review).
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://USER:pass@HOST.com:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
});
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
} finally {
vi.restoreAllMocks();
}
});
it("encodes special chars in http proxy password on supported platform v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on linux-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
});
+229 -9
View File
@@ -13,6 +13,8 @@ vi.mock("../src/download.js", () => ({
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)),
}));
describe("puppeteer launch", () => {
@@ -20,6 +22,7 @@ describe("puppeteer launch", () => {
let mockBrowser: any;
beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
@@ -62,6 +65,53 @@ describe("puppeteer launch", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
});
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Headless (default): deterministic viewport.
await launch();
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
const { launch } = await import("../src/puppeteer.js");
const custom = { width: 640, height: 480 };
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(custom);
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: true, launchOptions: { defaultViewport: null } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
// launch — the viewport decision must follow the same (top-level) value.
await launch({ headless: true, launchOptions: { headless: false } });
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(opts.headless).toBe(true);
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
});
it("adds --proxy-server for string proxy", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ proxy: "http://proxy:8080" });
@@ -81,16 +131,39 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("monkey-patches newPage for proxy auth", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
});
it("injects timezone and locale as binary flags", async () => {
@@ -110,4 +183,151 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run");
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
// Should NOT set up page.authenticate for SOCKS5
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("forwards launchOptions to puppeteer launch", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ launchOptions: { slowMo: 50 } });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.slowMo).toBe(50);
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
});
describe("puppeteer launchPersistentContext", () => {
let puppeteerMock: any;
let mockBrowser: any;
beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core");
mockBrowser = {
newPage: vi.fn().mockResolvedValue({
authenticate: vi.fn(),
}),
close: vi.fn(),
};
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("passes userDataDir to puppeteer launch", async () => {
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile" });
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
expect.objectContaining({
userDataDir: "./my-profile",
executablePath: "/fake/chrome",
})
);
});
it("includes stealth args", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("headed persistent context uses null defaultViewport (tracks real window)", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
vi.mocked(puppeteerMock.default.launch).mockClear();
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
});
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "http://user:pass@proxy:8080",
});
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
proxy: "socks5://user:pass@proxy:1080",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("forwards launchOptions to puppeteer launch", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile", launchOptions: { slowMo: 50 } });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.slowMo).toBe(50);
expect(callArgs.userDataDir).toBe("./my-profile");
});
it("injects timezone and locale as binary flags", async () => {
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({
userDataDir: "./my-profile",
timezone: "Asia/Tokyo",
locale: "ja-JP",
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(callArgs.args).toContain("--lang=ja-JP");
});
});
+336
View File
@@ -0,0 +1,336 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { sign as cryptoSign, createPrivateKey, createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Generate a throwaway signing keypair BEFORE the config mock is hoisted, then
// pin its public key so verifySignature accepts signatures we produce here.
const h = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require("node:crypto");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const otherPub = crypto.generateKeyPairSync("ed25519").publicKey;
const rawB64 = (pk: any) =>
Buffer.from(pk.export({ format: "jwk" }).x, "base64url").toString("base64");
return {
pinnedPubB64: rawB64(publicKey),
otherPubB64: rawB64(otherPub),
privPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
};
});
vi.mock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.pinnedPubB64] };
});
import {
BinaryVerificationError,
downloadProBinary,
fetchSignedManifest,
parseChecksums,
parseManifestVersion,
verifyDownloadChecksum,
verifyProDownload,
verifySignature,
} from "../src/download.js";
import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js";
/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
function sign(manifest: Uint8Array): Uint8Array {
const priv = createPrivateKey(h.privPem);
const sig = cryptoSign(null, manifest, priv); // raw 64-byte Ed25519 signature
return new TextEncoder().encode(sig.toString("base64"));
}
const enc = (s: string) => new TextEncoder().encode(s);
describe("verifySignature", () => {
it("accepts a valid signature", () => {
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
expect(() => verifySignature(manifest, sign(manifest))).not.toThrow();
});
it("rejects a tampered manifest", () => {
const manifest = enc("abc cloakbrowser-linux-x64.tar.gz\n");
const sig = sign(manifest);
const tampered = enc("xyz cloakbrowser-linux-x64.tar.gz\n");
expect(() => verifySignature(tampered, sig)).toThrow(/signature verification failed/);
});
it("rejects malformed base64 in the .sig", () => {
expect(() => verifySignature(enc("data\n"), enc("!!!not base64!!!")))
.toThrow(/Malformed/);
});
it("rejects a signature from a non-pinned key", async () => {
// Re-mock config so ONLY the other key is pinned, then the signature
// (made with the real key) must fail.
vi.resetModules();
vi.doMock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64] };
});
const { verifySignature: vs } = await import("../src/download.js");
const manifest = enc("data\n");
expect(() => vs(manifest, sign(manifest))).toThrow(/signature verification failed/);
vi.doUnmock("../src/config.js");
vi.resetModules();
});
it("accepts a signature under the new key during rotation", async () => {
// Pin BOTH keys (old + new) and sign with the real (new) key — must pass.
vi.resetModules();
vi.doMock("../src/config.js", async (importActual) => {
const actual = await importActual<typeof import("../src/config.js")>();
return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64, h.pinnedPubB64] };
});
const { verifySignature: vs } = await import("../src/download.js");
const manifest = enc("rotated\n");
expect(() => vs(manifest, sign(manifest))).not.toThrow();
vi.doUnmock("../src/config.js");
vi.resetModules();
});
});
describe("verifyDownloadChecksum (official path, fail-closed)", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
});
function tmpFile(bytes: Buffer): string {
const p = path.join(os.tmpdir(), `cloak-sig-${process.pid}-${bytes.length}-${bytes[0]}`);
fs.writeFileSync(p, bytes);
return p;
}
/** Mock fetch to serve a signed manifest for the official URLs. */
function mockManifest(manifestBytes: Uint8Array) {
const sig = sign(manifestBytes);
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const body = url.endsWith(".sig") ? sig : manifestBytes;
return { ok: true, arrayBuffer: async () => body.buffer } as Response;
});
}
/** Manifest body with the bound version line prepended (defaults to current). */
const body = (lines: string, version = getChromiumVersion()) =>
enc(`version=${version}\n${lines}`);
it("passes when signature is valid and hash matches", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(body(`${hash} ${getArchiveName()}\n`));
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
});
it("fails when the binary is tampered (hash mismatch)", async () => {
const file = tmpFile(Buffer.from("a malicious binary"));
const goodHash = createHash("sha256").update(Buffer.from("the real binary")).digest("hex");
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Checksum verification failed/);
});
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
// Genuinely signed, but declares an old version we did not request.
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
});
it("fails when the version line is missing (binding required)", async () => {
const data = Buffer.from("the real binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(enc(`${hash} ${getArchiveName()}\n`)); // no version= line
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
});
it("fails closed when no signed manifest can be fetched", async () => {
const file = tmpFile(Buffer.from("x"));
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/signed SHA256SUMS/);
});
it("fails when the signed manifest has no entry for this platform", async () => {
const file = tmpFile(Buffer.from("x"));
const someHash = "0".repeat(64);
mockManifest(body(`${someHash} some-other-file.tar.gz\n`));
await expect(verifyDownloadChecksum(file)).rejects.toThrow(/no entry for/);
});
it("custom download URL keeps the legacy skippable path (no signature fetch)", async () => {
const file = tmpFile(Buffer.from("x"));
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://my-mirror.test";
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
const spy = vi.spyOn(globalThis, "fetch");
await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
expect(spy).not.toHaveBeenCalled();
});
});
describe("downloadProBinary (version-pinned URL)", () => {
afterEach(() => vi.restoreAllMocks());
it("requests the explicit version, not /latest", async () => {
let capturedUrl = "";
// First fetch is the binary download; capture its URL then abort the flow
// before verify/extract by returning a non-ok response.
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
capturedUrl = typeof input === "string" ? input : (input as URL).toString();
return { ok: false, status: 500, statusText: "stop" } as Response;
});
await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});
expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
expect(capturedUrl.endsWith("/latest")).toBe(false);
});
});
describe("verifyProDownload (Pro path, fail-closed parity)", () => {
const PRO_VERSION = "147.0.1.0";
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
});
function tmpFile(bytes: Buffer): string {
const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
fs.writeFileSync(p, bytes);
return p;
}
/** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
return { ok: true, arrayBuffer: async () => out.buffer } as Response;
});
}
const body = (lines: string, version = PRO_VERSION) =>
enc(`version=${version}\n${lines}`);
it("passes when signature is valid and hash matches", async () => {
const data = Buffer.from("the real pro binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(body(`${hash} ${getArchiveName()}\n`));
await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
});
it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
const file = tmpFile(Buffer.from("a malicious pro binary"));
const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
mockManifest(body(`${goodHash} ${getArchiveName()}\n`));
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
// The error TYPE is the contract the ensureBinary router branches on:
// BinaryVerificationError => re-throw (never downgrade to free).
expect(err).toBeInstanceOf(BinaryVerificationError);
expect(err.message).toMatch(/Checksum verification failed/);
});
it("treats a failed manifest fetch as transient, not tampering", async () => {
// A failed manifest FETCH must be a plain Error (router falls back to free),
// NOT a BinaryVerificationError (which the router re-throws as a hard fail).
const file = tmpFile(Buffer.from("x"));
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(BinaryVerificationError);
});
it("fails on a signed manifest for the wrong version (downgrade)", async () => {
const data = Buffer.from("the real pro binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
mockManifest(body(`${hash} ${getArchiveName()}\n`, "1.0.0.0"));
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
expect(err).toBeInstanceOf(BinaryVerificationError);
expect(err.message).toMatch(/Version mismatch/);
});
it("rejects a manifest tampered after signing", async () => {
const data = Buffer.from("the real pro binary");
const file = tmpFile(data);
const hash = createHash("sha256").update(data).digest("hex");
const good = body(`${hash} ${getArchiveName()}\n`);
const sig = sign(good);
const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
mockManifest(tampered, sig);
const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
expect(err).toBeInstanceOf(BinaryVerificationError);
expect(err.message).toMatch(/signature verification failed/);
});
});
describe("version binding", () => {
it("reads the version= line", () => {
expect(
parseManifestVersion("version=146.0.7680.177.5\nabc file.tar.gz\n")
).toBe("146.0.7680.177.5");
});
it("returns null when absent", () => {
expect(parseManifestVersion("abc file.tar.gz\n")).toBeNull();
});
it("old parseChecksums ignores the version line", () => {
const result = parseChecksums(
`version=146.0.7680.177.5\n${"a".repeat(64)} cloakbrowser-linux-x64.tar.gz\n`
);
expect(result.size).toBe(1);
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
});
});
describe("fetchSignedManifest", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const mockPair = (manifest: string, sig: string, failPrimarySig = false) =>
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const isSig = url.endsWith(".sig");
if (url.includes("cloakbrowser.dev") && isSig && failPrimarySig) {
return { ok: false, status: 404 } as Response;
}
return {
ok: true,
arrayBuffer: async () =>
new TextEncoder().encode(isSig ? sig : manifest).buffer,
} as Response;
});
it("returns manifest + sig from the primary origin", async () => {
mockPair("MANIFEST", "U0lH");
const result = await fetchSignedManifest("1.2.3.4");
expect(new TextDecoder().decode(result!.manifestBytes)).toBe("MANIFEST");
expect(new TextDecoder().decode(result!.sigBytes)).toBe("U0lH");
});
it("falls back to GitHub when the primary .sig is missing", async () => {
const spy = mockPair("MANIFEST", "U0lH", true);
const result = await fetchSignedManifest("1.2.3.4");
expect(result).not.toBeNull();
// primary SHA256SUMS + primary .sig (404) + github SHA256SUMS + github .sig
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it("returns null when everything fails", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
expect(await fetchSignedManifest("1.2.3.4")).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -14,6 +14,7 @@ import {
checkWrapperUpdate,
clearCache,
ensureBinary,
fetchChecksums,
getLatestChromiumVersion,
parseChecksums,
resetWrapperUpdateChecked,
@@ -269,10 +270,64 @@ describe("parseChecksums", () => {
});
});
describe("download fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
it("checksum fetch falls back to GitHub on primary 429", async () => {
const HASH =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("cloakbrowser.dev")) {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response;
}
// GitHub fallback
return { ok: true, text: async () => checksumText } as Response;
});
const result = await fetchChecksums();
expect(result).not.toBeNull();
expect(
result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`)
).toBe(true);
});
it("checksum fetch returns null when both sources fail", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
const result = await fetchChecksums();
expect(result).toBeNull();
});
});
describe("effective version", () => {
it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment
expect(getEffectiveVersion()).toBe(getChromiumVersion());
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}`;
try {
expect(getEffectiveVersion()).toBe(getChromiumVersion());
} finally {
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
else delete process.env.CLOAKBROWSER_CACHE_DIR;
}
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
// Assert the persistent-context launchers actually invoke seedWidevineHint,
// so accidental removal of the wiring fails CI (parity with the Python
// test_persistent_context_seeds_widevine tests).
vi.mock("../src/widevine.js", () => ({
seedWidevineHint: vi.fn(),
resolveWidevineCdmDir: vi.fn(),
}));
vi.mock("../src/download.js", () => ({
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
}));
vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
}));
vi.mock("playwright-core", () => ({ chromium: { launchPersistentContext: vi.fn() } }));
vi.mock("puppeteer-core", () => ({ default: { launch: vi.fn() } }));
describe("persistent context seeds Widevine (integration)", () => {
beforeEach(() => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
});
afterEach(() => {
vi.clearAllMocks();
});
it("Playwright launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pw = await import("playwright-core");
vi.mocked(pw.chromium.launchPersistentContext).mockResolvedValue({
close: vi.fn(),
pages: () => [],
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
it("Puppeteer launchPersistentContext seeds with (userDataDir, binaryPath)", async () => {
const pptr = await import("puppeteer-core");
vi.mocked(pptr.default.launch).mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ authenticate: vi.fn() }),
close: vi.fn(),
} as any);
const { seedWidevineHint } = await import("../src/widevine.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "/tmp/profile" });
expect(seedWidevineHint).toHaveBeenCalledWith("/tmp/profile", "/fake/chrome");
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveWidevineCdmDir, seedWidevineHint } from "../src/widevine.js";
const HINT = "WidevineCdm/latest-component-updated-widevine-cdm";
const tempDirs: string[] = [];
const origPlatform = process.platform;
function tmpDir(prefix: string): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(d);
return d;
}
function makeCdm(dir: string): string {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "manifest.json"), '{"version":"4.10.3050.0"}');
return dir;
}
/** A fake chrome binary path inside its own dir. */
function fakeBinary(): string {
const bdir = path.join(tmpDir("cloak-bin-"), "bin");
fs.mkdirSync(bdir, { recursive: true });
return path.join(bdir, "chrome");
}
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, configurable: true });
}
beforeEach(() => {
setPlatform("linux"); // seeding is Linux-only; default to Linux in tests
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
delete process.env.CLOAKBROWSER_WIDEVINE;
delete process.env.CLOAKBROWSER_WIDEVINE_CDM;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveWidevineCdmDir", () => {
it("returns env-var dir when it has manifest.json", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
expect(resolveWidevineCdmDir(fakeBinary())).toBe(fs.realpathSync(cdm));
});
it("returns null when dir lacks manifest.json", () => {
const bogus = path.join(tmpDir("cloak-wv-"), "WidevineCdm");
fs.mkdirSync(bogus, { recursive: true });
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(fakeBinary())).toBeNull();
});
it("falls back to <binary dir>/WidevineCdm", () => {
const binary = fakeBinary();
expect(resolveWidevineCdmDir(binary)).toBeNull(); // no CDM yet
const cdm = makeCdm(path.join(path.dirname(binary), "WidevineCdm"));
expect(resolveWidevineCdmDir(binary)).toBe(fs.realpathSync(cdm));
});
it("env var is exclusive — invalid env skips, no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
const bogus = path.join(tmpDir("cloak-wv-"), "bogus");
fs.mkdirSync(bogus, { recursive: true }); // set but no manifest.json
process.env.CLOAKBROWSER_WIDEVINE_CDM = bogus;
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
it("empty env var is exclusive — no fallback to binary dir", () => {
const binary = fakeBinary();
makeCdm(path.join(path.dirname(binary), "WidevineCdm")); // valid CDM next to binary
process.env.CLOAKBROWSER_WIDEVINE_CDM = ""; // set but empty
expect(resolveWidevineCdmDir(binary)).toBeNull();
});
});
describe("seedWidevineHint", () => {
it("writes the hint file with the absolute CDM path", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
const hint = path.join(profile, HINT);
expect(fs.existsSync(hint)).toBe(true);
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
it("no-ops when no CDM present", () => {
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("kill switch CLOAKBROWSER_WIDEVINE=0 disables seeding", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
process.env.CLOAKBROWSER_WIDEVINE = "0";
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("is idempotent", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(path.join(profile, HINT), "utf-8")).Path).toBe(
fs.realpathSync(cdm),
);
});
it("no-ops on non-Linux", () => {
setPlatform("win32");
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
seedWidevineHint(profile, fakeBinary());
expect(fs.existsSync(path.join(profile, HINT))).toBe(false);
});
it("skips empty userDataDir (no CWD pollution)", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
seedWidevineHint("", fakeBinary());
expect(fs.existsSync(path.join(process.cwd(), "WidevineCdm"))).toBe(false);
});
it("never throws on write failure", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
// Block mkdir of <profile>/WidevineCdm by occupying that path with a file.
fs.writeFileSync(path.join(profile, "WidevineCdm"), "not a dir");
expect(() => seedWidevineHint(profile, fakeBinary())).not.toThrow();
});
it("rewrites a mismatched existing hint", () => {
const cdm = makeCdm(path.join(tmpDir("cloak-wv-"), "WidevineCdm"));
process.env.CLOAKBROWSER_WIDEVINE_CDM = cdm;
const profile = tmpDir("cloak-prof-");
const hint = path.join(profile, HINT);
fs.mkdirSync(path.dirname(hint), { recursive: true });
fs.writeFileSync(hint, '{"Path":"/stale/path"}');
seedWidevineHint(profile, fakeBinary());
expect(JSON.parse(fs.readFileSync(hint, "utf-8")).Path).toBe(fs.realpathSync(cdm));
});
});
+6 -2
View File
@@ -51,13 +51,17 @@ classifiers = [
dependencies = [
"playwright>=1.40",
"httpx>=0.24",
"cryptography>=41.0", # verify Ed25519 signature on SHA256SUMS before trusting it
]
[project.optional-dependencies]
geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts]
cloakbrowser = "cloakbrowser.__main__:main"
[project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser"
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
-11
View File
@@ -1,11 +0,0 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
-45
View File
@@ -1,45 +0,0 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+79 -44
View File
@@ -1,97 +1,88 @@
"""Unit tests for _build_args timezone/locale injection and deprecation compat."""
"""Unit tests for build_args timezone/locale injection and timezone alias."""
import warnings
from cloakbrowser.browser import _build_args, _migrate_timezone_id
from cloakbrowser.browser import build_args, _resolve_timezone
def test_timezone_injected():
"""--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
args = build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected():
"""--lang flag should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
"""--lang and --fingerprint-locale flags should appear when locale is set."""
args = build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected():
"""Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
args = build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
def test_timezone_independent_of_stealth_args():
"""--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
args = build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
# No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None)
"""No timezone/lang/fingerprint-locale flags when params are None."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
def test_extra_args_preserved():
"""Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
args = build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args
assert "--fingerprint-locale=ja-JP" in args
# --- _migrate_timezone_id deprecation compat ---
# --- _resolve_timezone alias ---
def test_migrate_old_param_only():
def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
result = _resolve_timezone(None, kwargs)
assert result == "Europe/Paris"
assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins():
def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param():
"""No warning when timezone_id is absent."""
def test_resolve_no_alias():
"""No-op when timezone_id is absent."""
kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
result = _resolve_timezone("UTC", kwargs)
assert result == "UTC"
assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none():
"""Neither param set — returns None, no warning."""
def test_resolve_both_none():
"""Neither param set — returns None."""
kwargs = {}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
result = _resolve_timezone(None, kwargs)
assert result is None
assert len(w) == 0
# --- Deduplication tests ---
@@ -99,7 +90,7 @@ def test_migrate_both_none():
def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
args = build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887"
@@ -107,7 +98,7 @@ def test_user_fingerprint_overrides_default():
def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
args = build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux"
@@ -115,7 +106,7 @@ def test_user_platform_overrides_default():
def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York",
@@ -126,20 +117,23 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg."""
args = _build_args(
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = build_args(
stealth_args=True,
extra_args=["--lang=de-DE"],
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US",
)
lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US"
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
assert len(locale_args) == 1
assert locale_args[0] == "--fingerprint-locale=en-US"
def test_no_duplicate_flags():
"""No flag key should appear more than once in the output."""
args = _build_args(
args = build_args(
stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin",
@@ -151,7 +145,7 @@ def test_no_duplicate_flags():
def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
args = build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args
assert "--no-zygote" in args
assert "--no-sandbox" in args
@@ -162,5 +156,46 @@ def test_override_logs_debug(caplog):
import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
# --- WebRTC IP spoofing ---
def test_webrtc_ip_passed_through_args():
"""--fingerprint-webrtc-ip in args should pass through to output."""
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
def test_webrtc_ip_not_present_by_default():
"""No --fingerprint-webrtc-ip when not in args."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
def test_resolve_webrtc_args_auto():
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
from cloakbrowser.browser import _resolve_webrtc_args
from unittest.mock import patch
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
def test_resolve_webrtc_args_explicit_ip_unchanged():
"""Explicit IP in args should not be touched."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
def test_resolve_webrtc_args_no_flag():
"""No webrtc flag in args should return args unchanged."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
assert result == ["--no-sandbox"]
+678
View File
@@ -0,0 +1,678 @@
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
import asyncio
import importlib.machinery
import importlib.util
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
# Load cloakserve as a module from bin/ (no .py extension).
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["cloakserve"] = _mod
_loader.exec_module(_mod)
parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
_external_host = _mod._external_host
_ws_scheme = _mod._ws_scheme
SAFE_SEED_RE = _mod.SAFE_SEED_RE
RESERVED_SEEDS = _mod.RESERVED_SEEDS
# ---------------------------------------------------------------------------
# parse_connection_params
# ---------------------------------------------------------------------------
class TestParseConnectionParams:
def test_empty_query(self):
result = parse_connection_params("")
assert result["seed"] is None
assert result["extra_args"] == []
def test_fingerprint_seed(self):
result = parse_connection_params("fingerprint=12345")
assert result["seed"] == "12345"
def test_timezone_and_locale(self):
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
assert result["timezone"] == "Asia/Tokyo"
assert result["locale"] == "ja-JP"
def test_proxy(self):
result = parse_connection_params("proxy=http://proxy:8080")
assert result["proxy"] == "http://proxy:8080"
def test_geoip_true_variants(self):
for val in ("true", "1", "yes", "True", "YES"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is True, f"geoip={val} should be True"
def test_geoip_false(self):
for val in ("false", "0", "no", "anything"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is False, f"geoip={val} should be False"
def test_generic_fingerprint_params(self):
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
result = parse_connection_params(qs)
assert "--fingerprint-platform=windows" in result["extra_args"]
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
def test_special_params_not_in_extra_args(self):
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
result = parse_connection_params(qs)
assert result["extra_args"] == []
def test_multiple_values_takes_first(self):
result = parse_connection_params("fingerprint=111&fingerprint=222")
assert result["seed"] == "111"
# ---------------------------------------------------------------------------
# parse_cli_args
# ---------------------------------------------------------------------------
class TestParseCliArgs:
def test_defaults(self):
config, passthrough = parse_cli_args([])
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert config["idle_timeout"] == 0.0
assert passthrough == []
def test_custom_port(self):
config, _ = parse_cli_args(["--port=8080"])
assert config["port"] == 8080
def test_headless_false(self):
config, passthrough = parse_cli_args(["--headless=false"])
assert config["headless"] is False
# headless flag still passed through to Chrome
assert "--headless=false" in passthrough
def test_strips_remote_debugging_flags(self):
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
config, passthrough = parse_cli_args(args)
assert passthrough == ["--no-sandbox"]
def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
config, passthrough = parse_cli_args(args)
# --fingerprint=999 is consumed into config["default_seed"], not passed through
assert passthrough == ["--no-sandbox", "--disable-gpu"]
assert config["default_seed"] == "999"
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
assert "--port=9222" not in passthrough
assert "--no-sandbox" in passthrough
def test_custom_data_dir(self):
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
assert config["data_dir"] == "/custom/path"
assert "--data-dir=/custom/path" not in passthrough
def test_data_dir_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
def test_idle_timeout_not_in_passthrough(self):
config, passthrough = parse_cli_args(["--idle-timeout=30", "--no-sandbox"])
assert config["idle_timeout"] == 30.0
assert "--idle-timeout=30" not in passthrough
assert "--no-sandbox" in passthrough
@pytest.mark.parametrize("value", ["0", "off", "false", "none", "disabled"])
def test_idle_timeout_disabled_values(self, value):
config, _ = parse_cli_args([f"--idle-timeout={value}"])
assert config["idle_timeout"] == 0.0
def test_idle_timeout_env_default(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args([])
assert config["idle_timeout"] == 2.5
def test_idle_timeout_cli_overrides_env(self, monkeypatch):
monkeypatch.setenv("CLOAKSERVE_IDLE_TIMEOUT", "2.5")
config, _ = parse_cli_args(["--idle-timeout=9"])
assert config["idle_timeout"] == 9.0
def test_idle_timeout_rejects_negative_values(self):
with pytest.raises(ValueError):
parse_cli_args(["--idle-timeout=-1"])
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@patch("os.path.exists", return_value=False)
def test_default_data_dir_bare_metal(self, _mock):
result = _default_data_dir()
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# External host detection
# ---------------------------------------------------------------------------
class TestExternalHost:
"""Test public host selection for rewritten CDP WebSocket URLs."""
class _Request:
def __init__(self, headers, port=9222, scheme="http", query_string=""):
self.headers = headers
self.app = {"port": port}
self.scheme = scheme
self.query_string = query_string
def test_forwarded_host_overrides_internal_host(self):
request = self._Request({
"Host": "localhost:8080",
"X-Forwarded-Host": "cdp.example.com:443",
})
assert _external_host(request) == "cdp.example.com:443"
def test_forwarded_host_uses_first_value(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "public.example.com, internal:9222",
})
assert _external_host(request) == "public.example.com"
def test_blank_forwarded_host_falls_back_to_host_header(self):
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": " ",
})
assert _external_host(request) == "internal:9222"
def test_falls_back_to_host_header(self):
request = self._Request({"Host": "localhost:9222"})
assert _external_host(request) == "localhost:9222"
def test_falls_back_to_app_port_without_host_header(self):
request = self._Request({}, port=9333)
assert _external_host(request) == "localhost:9333"
def test_forwarded_proto_selects_wss(self):
request = self._Request({"X-Forwarded-Proto": "https"}, scheme="http")
assert _ws_scheme(request) == "wss"
def test_forwarded_proto_uses_first_value(self):
request = self._Request({"X-Forwarded-Proto": "https, http"}, scheme="http")
assert _ws_scheme(request) == "wss"
class TestHandlerURLRewriting:
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
class _Request:
def __init__(self, headers, query_string="fingerprint=seed1", port=9222, scheme="http"):
self.headers = headers
self.query_string = query_string
self.scheme = scheme
self.app = {"port": port, "pool": self._Pool()}
class _Pool:
async def get_or_launch(self, **_kwargs):
return SimpleNamespace(cdp_port=5100)
class _FakeResponse:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
async def json(self):
return self._data
class _FakeSession:
def __init__(self, data):
self._data = data
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return None
def get(self, *_args, **_kwargs):
return TestHandlerURLRewriting._FakeResponse(self._data)
def _patch_session(self, monkeypatch, data):
monkeypatch.setattr(
_mod.aiohttp,
"ClientSession",
lambda *_args, **_kwargs: self._FakeSession(data),
)
def test_json_version_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, {
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/browser/browser-guid",
})
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_version(request))
payload = json.loads(response.text)
assert payload["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/browser/browser-guid"
)
def test_json_list_uses_forwarded_host_and_proto(self, monkeypatch):
self._patch_session(monkeypatch, [{
"webSocketDebuggerUrl": "ws://127.0.0.1:5100/devtools/page/page-guid",
}])
request = self._Request({
"Host": "internal:9222",
"X-Forwarded-Host": "cdp.example.com",
"X-Forwarded-Proto": "https",
})
response = asyncio.run(_mod.handle_json_list(request))
payload = json.loads(response.text)
assert payload[0]["webSocketDebuggerUrl"] == (
"wss://cdp.example.com/fingerprint/seed1/devtools/page/page-guid"
)
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
class TestWebSocketOriginGuard:
"""Verify cloakserve rejects browser-origin CDP WebSocket hijacks."""
def test_absent_origin_allowed_for_non_browser_cdp_clients(self):
assert _mod._origin_is_allowed(None, "127.0.0.1:9555")
def test_matching_origin_host_allowed(self):
assert _mod._origin_is_allowed("http://127.0.0.1:9555", "127.0.0.1:9555")
def test_chrome_devtools_origin_allowed(self):
assert _mod._origin_is_allowed("devtools://devtools", "127.0.0.1:9555")
assert _mod._origin_is_allowed("chrome-devtools://devtools", "127.0.0.1:9555")
@pytest.mark.parametrize("origin", [
"http://attacker.example",
"https://attacker.example",
"http://PUBLIC_HOST:9555",
"http://attacker.example:9555",
"http://127.0.0.1:9555/",
"http://127.0.0.1:9555/path",
"http://127.0.0.1:9555?q=1",
"http://127.0.0.1:9555#fragment",
"http://user@127.0.0.1:9555",
"http://@127.0.0.1:9555",
"http://:@127.0.0.1:9555",
"http://127.0.0.1:",
"null",
"file://",
])
def test_untrusted_browser_origins_rejected(self, origin):
assert not _mod._origin_is_allowed(origin, "127.0.0.1:9555")
def test_public_origin_matching_host_is_still_rejected(self):
assert not _mod._origin_is_allowed("http://attacker.example:9555", "attacker.example:9555")
@pytest.mark.parametrize("host", [
"user@127.0.0.1:9555",
"127.0.0.1:9555/path",
"127.0.0.1:9555?x=1",
"127.0.0.1:9555#fragment",
"127.0.0.1:9555, attacker.example:9555",
"@127.0.0.1:9555",
":@127.0.0.1:9555",
"127.0.0.1:",
"[::1]:",
])
def test_malformed_host_is_rejected_even_when_hostname_is_loopback(self, host):
assert not _mod._origin_is_allowed("http://127.0.0.1:9555", host)
def test_request_scheme_controls_host_default_port(self):
assert _mod._origin_is_allowed("https://localhost", "localhost", request_scheme="https")
assert not _mod._origin_is_allowed("https://localhost", "localhost", request_scheme="http")
def test_ws_handler_rejects_untrusted_origin_before_launching_chrome(self):
class RejectingPool:
async def get_or_launch(self, **_kwargs):
raise AssertionError("untrusted origin should be rejected before launching Chrome")
request = SimpleNamespace(
headers={"Host": "127.0.0.1:9555", "Origin": "http://attacker.example"},
app={"pool": RejectingPool()},
match_info={"path": "browser/browser-guid"},
)
response = asyncio.run(_mod.handle_ws_default(request))
assert response.status == 403
assert "untrusted" in response.text.lower()
def test_seed_ws_handler_rejects_untrusted_origin_before_launching_chrome(self):
class RejectingPool:
async def get_or_launch(self, **_kwargs):
raise AssertionError("untrusted origin should be rejected before launching Chrome")
request = SimpleNamespace(
headers={"Host": "127.0.0.1:9555", "Origin": "http://attacker.example"},
app={"pool": RejectingPool()},
match_info={"seed": "abc123", "path": "page/page-guid"},
)
response = asyncio.run(_mod.handle_ws_seed(request))
assert response.status == 403
assert "untrusted" in response.text.lower()
class TestHandlerURLRewriting:
"""Verify handlers rewrite CDP WebSocket URLs to the public cloakserve endpoint."""
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_version."""
if seed:
ws_path = f"fingerprint/{seed}/devtools/browser"
else:
ws_path = "devtools/browser"
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
return f"{scheme}://{host}/{ws_path}/{guid}"
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_list."""
ws_tail = orig_ws.split("/devtools/")[-1]
if seed:
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
else:
return f"{scheme}://{host}/devtools/{ws_tail}"
def test_version_rewrite_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", "12345")
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
def test_version_rewrite_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", None)
assert result == "ws://container:9222/devtools/browser/abc-123"
def test_list_rewrite_page_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", "99")
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
def test_list_rewrite_page_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", None)
assert result == "ws://host:9222/devtools/page/DEF-456"
def test_list_rewrite_browser(self):
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
def test_wss_scheme_version(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
def test_wss_scheme_list(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
# ---------------------------------------------------------------------------
# Connection refcounting
# ---------------------------------------------------------------------------
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self, idle_timeout: float = 0.0):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
idle_timeout=idle_timeout,
)
def _track_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace()
def _track_live_process(self, pool, seed="seed1"):
pool._processes[seed] = SimpleNamespace(
process=SimpleNamespace(poll=lambda: None),
)
def test_connect_increments(self):
pool = self._make_pool()
pool.connect("seed1")
assert pool._connections["seed1"] == 1
pool.connect("seed1")
assert pool._connections["seed1"] == 2
def test_disconnect_decrements(self):
pool = self._make_pool()
pool.connect("seed1")
pool.connect("seed1")
pool.disconnect("seed1")
assert pool._connections["seed1"] == 1
def test_disconnect_to_zero_removes_key(self):
pool = self._make_pool()
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" not in pool._connections
def test_disconnect_below_zero_safe(self):
pool = self._make_pool()
pool.disconnect("nonexistent")
assert "nonexistent" not in pool._connections
def test_multiple_seeds_independent(self):
pool = self._make_pool()
pool.connect("a")
pool.connect("b")
pool.connect("a")
pool.disconnect("a")
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
def test_idle_cleanup_disabled_by_default(self):
async def run():
pool = self._make_pool()
self._track_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
await asyncio.sleep(0)
assert pool._idle_tasks == {}
asyncio.run(run())
def test_disconnect_to_zero_schedules_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.01)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
await asyncio.sleep(0.05)
assert cleaned == ["seed1"]
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_reconnect_cancels_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=0.03)
self._track_process(pool)
cleaned = []
async def fake_cleanup(seed):
cleaned.append(seed)
pool._processes.pop(seed, None)
pool._cleanup_process = fake_cleanup
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" in pool._idle_tasks
pool.connect("seed1")
await asyncio.sleep(0.06)
assert cleaned == []
assert pool._connections["seed1"] == 1
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
def test_discovery_refreshes_pending_idle_cleanup(self):
async def run():
pool = self._make_pool(idle_timeout=1.0)
self._track_live_process(pool)
pool.connect("seed1")
pool.disconnect("seed1")
first_task = pool._idle_tasks["seed1"]
await pool.get_or_launch("seed1")
second_task = pool._idle_tasks["seed1"]
assert second_task is not first_task
pool._cancel_idle_cleanup("seed1")
await asyncio.sleep(0)
assert "seed1" not in pool._idle_tasks
asyncio.run(run())
# ---------------------------------------------------------------------------
# Seed validation (CVE fix — path traversal via fingerprint param)
# ---------------------------------------------------------------------------
class TestSeedValidation:
"""Verify SAFE_SEED_RE rejects path traversal and reserved names."""
@pytest.mark.parametrize("seed", [
"../foo", "../../etc", "/etc/passwd", "..", ".", "foo/bar",
"foo\\bar", "\x00evil", "", "a" * 129,
])
def test_malicious_seeds_rejected(self, seed):
assert not SAFE_SEED_RE.match(seed)
@pytest.mark.parametrize("seed", [
"__default__",
])
def test_reserved_seeds_rejected(self, seed):
assert seed in RESERVED_SEEDS
@pytest.mark.parametrize("seed", [
"12345", "my-seed_01", "ABC", "a" * 128, "0", "test-seed",
])
def test_valid_seeds_accepted(self, seed):
assert SAFE_SEED_RE.match(seed)
assert seed not in RESERVED_SEEDS
# ---------------------------------------------------------------------------
# Path containment (_safe_rmtree)
# ---------------------------------------------------------------------------
class TestSafeRmtree:
"""Verify _safe_rmtree refuses to delete outside data_dir."""
def _make_pool(self, data_dir: str):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir=data_dir,
)
def test_refuses_path_outside_data_dir(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
victim = tmp_path / "victim"
victim.mkdir()
(victim / "sentinel").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(victim))
assert victim.exists(), "Directory outside data_dir must not be deleted"
def test_refuses_data_dir_itself(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
(data_dir / "sentinel").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(data_dir))
assert data_dir.exists(), "data_dir itself must not be deleted"
def test_deletes_valid_subdirectory(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
subdir = data_dir / "seed-12345"
subdir.mkdir()
(subdir / "data").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(subdir))
assert not subdir.exists(), "Valid subdirectory should be deleted"
def test_refuses_traversal_path(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
victim = tmp_path / "victim"
victim.mkdir()
traversal = str(data_dir / ".." / "victim")
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(traversal)
assert victim.exists(), "Traversal path must not be deleted"
+6 -2
View File
@@ -133,10 +133,14 @@ class TestStealthArgs:
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
args = get_default_stealth_args()
assert "--fingerprint-platform=macos" in args
assert any("Apple" in a for a in args)
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
def test_linux_windows_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
args = get_default_stealth_args()
assert "--fingerprint-platform=windows" in args
assert any("NVIDIA" in a for a in args)
# GPU flags removed — binary auto-generates from seed + platform
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
+30
View File
@@ -0,0 +1,30 @@
import os
from unittest.mock import MagicMock, patch
from cloakbrowser import launch
@patch("cloakbrowser.browser.ensure_binary")
@patch("playwright.sync_api.sync_playwright")
def test_extension_loading(mock_sync_playwright, mock_ensure_binary):
mock_ensure_binary.return_value = "/fake/chrome"
mock_browser = MagicMock()
mock_pw = MagicMock()
mock_pw.chromium.launch.return_value = mock_browser
mock_sync_playwright.return_value.start.return_value = mock_pw
launch(extension_paths=["./ext"])
mock_pw.chromium.launch.assert_called_once()
launch_call = mock_pw.chromium.launch.call_args
args = launch_call.kwargs["args"]
abs_path = os.path.abspath("./ext")
assert f"--load-extension={abs_path}" in args
assert f"--disable-extensions-except={abs_path}" in args
+32 -12
View File
@@ -1,10 +1,11 @@
"""Unit tests for GeoIP-based timezone/locale detection."""
from unittest.mock import patch
import time
import pytest
from cloakbrowser.browser import _maybe_resolve_geoip
from cloakbrowser.browser import maybe_resolve_geoip
from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP,
_is_private_ip,
@@ -92,51 +93,70 @@ def test_resolve_geo_returns_none_when_db_missing():
# ---------------------------------------------------------------------------
# _maybe_resolve_geoip (browser.py helper)
# maybe_resolve_geoip (browser.py helper)
# ---------------------------------------------------------------------------
def test_maybe_resolve_skips_when_geoip_false():
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None)
tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
"""Explicit values should still resolve exit IP for WebRTC."""
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "1.2.3.4"
def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins
def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US"
def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin"
assert loc == "de-DE"
assert ip == "5.6.7.8"
def test_maybe_resolve_geoip_timeout_returns_existing_values(monkeypatch):
"""A stalled proxy lookup should not block launch indefinitely."""
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
monkeypatch.setenv("CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS", "0.05")
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=object()):
start = time.monotonic()
tz, loc, ip = maybe_resolve_geoip(True, "http://203.0.113.10:8080", None, "fr-FR")
elapsed = time.monotonic() - start
assert (tz, loc, ip) == (None, "fr-FR", None)
assert elapsed < 0.5
# ---------------------------------------------------------------------------
+61
View File
@@ -279,6 +279,67 @@ if __name__ == "__main__":
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
time.sleep(1)
# ============================================================
# SCENARIO 7: ElementHandle — query_selector interactions
# ============================================================
step("ElementHandle — query_selector click, type, fill, hover")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: get element via query_selector, cursor moves smoothly")
el = page.query_selector('#searchInput')
assert el is not None, "query_selector returned None"
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
t0 = time.time()
el.click()
eh_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
time.sleep(0.5)
print(" Watch: ElementHandle type — characters appear one by one")
t0 = time.time()
el.type('ElementHandle typing')
eh_type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle fill — clears then types")
t0 = time.time()
el.fill('Filled via EH')
eh_fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle hover — cursor moves without clicking")
btn_el = page.query_selector('button[type="submit"]')
t0 = time.time()
btn_el.hover()
eh_hover_ms = int((time.time() - t0) * 1000)
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
time.sleep(0.5)
print(" Watch: query_selector_all returns patched handles")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
els = page.query_selector_all('input[type="checkbox"]')
all_patched = all(getattr(e, '_human_patched', False) for e in els)
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
if els:
print(" Watch: click checkbox via ElementHandle")
t0 = time.time()
els[0].click()
cb_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
time.sleep(1)
# ============================================================
# SUMMARY
# ============================================================
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
"""Security tests for the AWS Lambda handler URL validation."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(
0, str(Path(__file__).resolve().parent.parent / "examples" / "integrations" / "aws_lambda")
)
from lambda_handler import _build_launch_kwargs, _classify_error, _validate_url
class TestSchemeValidation:
"""Fix 1: only http:// and https:// are accepted."""
@pytest.mark.parametrize("url", [
"file:///etc/passwd",
"file:///proc/self/environ",
"data:text/html,<h1>pwned</h1>",
"javascript:alert(1)",
"chrome://settings",
"about:blank",
"ftp://example.com/file",
"",
])
def test_rejects_non_http_schemes(self, url):
with pytest.raises(ValueError, match="Only http"):
_validate_url(url)
@pytest.mark.parametrize("url", [
"https://example.com",
"http://example.com",
"https://example.com/path?q=1",
"HTTP://EXAMPLE.COM",
])
def test_accepts_http_and_https(self, url):
_validate_url(url)
def test_rejects_missing_hostname(self):
with pytest.raises(ValueError, match="no hostname"):
_validate_url("http://")
class TestSSRFProtection:
"""Fix 2: block private, loopback, link-local, reserved, and metadata IPs."""
@pytest.mark.parametrize("url,label", [
("http://169.254.169.254", "AWS metadata"),
("http://169.254.169.254/latest/meta-data/", "AWS metadata path"),
("http://127.0.0.1", "loopback"),
("http://127.0.0.2", "loopback range"),
("http://localhost", "localhost"),
("http://10.0.0.1", "private 10.x"),
("http://172.16.0.1", "private 172.16"),
("http://192.168.1.1", "private 192.168"),
("http://0.0.0.0", "unspecified"),
("http://[::1]", "IPv6 loopback"),
])
def test_rejects_private_ips(self, url, label):
with pytest.raises(ValueError, match="private/internal"):
_validate_url(url)
def test_rejects_carrier_grade_nat(self):
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://100.64.0.1")
def test_rejects_unresolvable_hostname(self):
with pytest.raises(ValueError, match="Cannot resolve"):
_validate_url("http://this-host-does-not-exist-cb-test.invalid")
def test_rejects_ipv4_mapped_ipv6(self):
"""::ffff:127.0.0.1 should be blocked even though it's technically IPv6."""
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://[::ffff:127.0.0.1]")
class TestExtraArgsRemoval:
"""Fix 3: caller-controlled extra_args are ignored; internal _strategy_args work."""
def test_ignores_caller_extra_args(self):
event = {"url": "https://example.com", "extra_args": ["--remote-debugging-port=9222"]}
kwargs = _build_launch_kwargs(event)
assert "--remote-debugging-port=9222" not in kwargs["args"]
def test_includes_strategy_args(self):
event = {"url": "https://example.com", "_strategy_args": ["--ignore-certificate-errors"]}
kwargs = _build_launch_kwargs(event)
assert "--ignore-certificate-errors" in kwargs["args"]
def test_classify_error_uses_strategy_args(self):
result = _classify_error(Exception("ERR_CERT_AUTHORITY_INVALID"))
assert "_strategy_args" in result
assert "extra_args" not in result
def test_always_includes_lambda_hardening_flags(self):
kwargs = _build_launch_kwargs({"url": "https://example.com"})
assert "--disable-dev-shm-usage" in kwargs["args"]
assert "--no-zygote" in kwargs["args"]
def test_caller_cannot_inject_strategy_args(self):
"""_strategy_args in the caller event must be stripped by _run() before launch."""
from lambda_handler import _run
import inspect
source = inspect.getsource(_run)
assert '"_strategy_args"' in source and "extra_args" in source, \
"_run must strip both _strategy_args and extra_args from caller event"
class TestRedirectSSRF:
"""Fix 5: post-navigation re-validation catches redirects to blocked IPs.
These mock socket.getaddrinfo to simulate redirect scenarios without
needing a real browser or HTTP server.
"""
def test_validate_url_catches_redirect_target(self):
"""If Chromium followed a redirect to 169.254.169.254, the post-nav
_validate_url(page.url) call should reject it."""
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
def test_validate_url_catches_localhost_redirect(self):
with pytest.raises(ValueError, match="private/internal"):
_validate_url("http://127.0.0.1:8080/admin")
def test_code_flow_validates_before_content(self):
"""Verify that _attempt_scrape calls _validate_url(page.url) at line 282
BEFORE building the result dict at line 290 (sequential code path)."""
import ast
handler_path = (
Path(__file__).resolve().parent.parent
/ "examples" / "integrations" / "aws_lambda" / "lambda_handler.py"
)
source = handler_path.read_text()
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef) and node.name == "_attempt_scrape":
body = node.body
# Find the try block
for stmt in body:
if isinstance(stmt, ast.Try):
try_body = stmt.body
validate_lines = []
content_line = None
for s in try_body:
if isinstance(s, ast.Expr) and isinstance(s.value, ast.Call):
func = s.value.func
if isinstance(func, ast.Name) and func.id == "_validate_url":
validate_lines.append(s.lineno)
if isinstance(s, ast.AnnAssign):
if isinstance(s.target, ast.Name) and s.target.id == "result":
content_line = s.lineno
elif isinstance(s, ast.Assign):
for target in s.targets:
if isinstance(target, ast.Name) and target.id == "result":
content_line = s.lineno
assert len(validate_lines) >= 2, (
f"Expected 2 _validate_url calls, found {len(validate_lines)}"
)
assert content_line is not None
assert all(v < content_line for v in validate_lines), (
f"_validate_url (lines {validate_lines}) must come before "
f"result assignment (line {content_line})"
)
return
pytest.fail("Could not find _attempt_scrape function in source")
+24 -1
View File
@@ -1,10 +1,33 @@
"""Basic launch tests for cloakbrowser."""
import pytest
from cloakbrowser import launch, launch_async, binary_info
from cloakbrowser import (
launch,
launch_async,
launch_context,
launch_persistent_context,
binary_info,
)
from cloakbrowser.config import get_chromium_version
@pytest.mark.parametrize("env", [None, "patchright"])
def test_removed_backend_kwarg_raises(env, monkeypatch):
"""The removed `backend` parameter raises a clear TypeError before any
launch side effects, regardless of the (also removed) CLOAKBROWSER_BACKEND
env var. Guards the patchright removal."""
if env is None:
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
else:
monkeypatch.setenv("CLOAKBROWSER_BACKEND", env)
with pytest.raises(TypeError, match="backend"):
launch(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_context(backend="patchright")
with pytest.raises(TypeError, match="backend"):
launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright")
def test_binary_info():
"""binary_info() returns expected structure."""
info = binary_info()

Some files were not shown because too many files have changed in this diff Show More