Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

### Behavior change — camoufox JS-execution world model

The camoufox dep swap to cloverlabs-camoufox 0.6.0 + Firefox 150 (chore branch `cloverlabs-camoufox-v150`) removes the Juggler-scope JS isolation that daijro/FF135 provided. Both `interceptor_browser_evaluate` (any `world`) and `interceptor_browser_inject_init_script` now run in the page's main world.

Consequences for callers:

- `inject_init_script` patches NOW reach the page (`Object.defineProperty(navigator, 'webdriver', ...)` actually affects what site scripts see) — the camoufox#48 limitation no longer applies on this build. The trade: those patches are observable by anti-bot code via `Function.prototype.toString` and `window` enumeration.
- `interceptor_browser_evaluate` mutations (writes to `window`, prototype patches) become observable to page scripts. Read-only evals stay safe.
- `world: "main"` and `world: "isolated"` accept the same args for API compatibility but run in the same realm on cloverlabs/FF150. `mw:` prefix and `main_world_eval: true` launch flag are inert.

Tool descriptions, README "Worlds and isolation" section, and `test/integration/browser-js-inject.test.ts` were updated to reflect the new behavior. The probe at `scripts/camoufox-world-probe.ts` re-verifies the model on any installed build.

## 3.2.0

### New Features
Expand Down
53 changes: 26 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,8 @@ Camoufox is a patched Firefox with source-level fingerprint controls (OS, WebGL
**Host requirements:**

```bash
pip install "camoufox[geoip]"
python3 -m camoufox fetch # downloads patched Firefox binary (~200 MB)
pip install "cloverlabs-camoufox[geoip]" # active fork; daijro/camoufox stale on Firefox 135 → DataDome distrusts
python3 -m camoufox fetch official/150.0.2-alpha.26 # Firefox 150; default `fetch` still picks v135 due to repos.yml constraint

# For TLS MITM trust (NSS profile is created per-launch and the proxy CA is imported):
sudo apt install libnss3-tools # Debian/Ubuntu
Expand Down Expand Up @@ -680,54 +680,53 @@ Playwright-driven tools for the browser target. Each takes a `target_id` directl
| `interceptor_browser_get_storage_value` | Get one storage value by `item_id` |
| `interceptor_browser_list_network_fields` | Header field listing from proxy-captured traffic since the browser was launched |
| `interceptor_browser_get_network_field` | Get one full header field value by `field_id` |
| `interceptor_browser_evaluate` | Run a JS file in the page (file body wrapped as `(__args) => { ... }`); returns the result. `world: "isolated"` (default, stealthy) or `world: "main"` (camoufox-only, requires `main_world_eval: true` at launch) |
| `interceptor_browser_inject_init_script` | Inject a JS file as `page.addInitScript` — runs before every page script on the next navigation. Safest stealth primitive on cloakbrowser; on camoufox runs in privileged Juggler scope and does NOT patch main world ([camoufox#48](https://github.com/daijro/camoufox/issues/48)) |
| `interceptor_browser_evaluate` | Run a JS file in the page (file body wrapped as `(__args) => { ... }`); returns the result. `world: "isolated"` (default) or `world: "main"` (camoufox-only, requires `main_world_eval: true` at launch). On current camoufox build (cloverlabs/FF150) both args run in page main world — mutations are page-visible |
| `interceptor_browser_inject_init_script` | Inject a JS file as `page.addInitScript` — runs before every page script on the next navigation. Cloakbrowser: isolated utility world. Camoufox (cloverlabs/FF150): page main world directly — patches reach the page but are observable by page scripts (`Function.prototype.toString` leak applies) |
| `interceptor_browser_add_script_tag` | Append a `<script>` to the current page. **DOM-visible — avoid for stealth.** Use for benign payloads where main-world execution + page visibility is intentional |

Network data is sourced from the MITM proxy rather than a browser-side protocol — the proxy sees every wire request regardless of what the browser reported.

**Stealth tradeoffs for JS injection:**

| Method | Cloakbrowser | Camoufox |
| Method | Cloakbrowser | Camoufox (cloverlabs/FF150) |
|---|---|---|
| `evaluate` isolated | Safe (isolated utility world) — rate-limit before reCAPTCHA, each call is CDP traffic | Safe (Juggler isolated world, invisible to page JS) |
| `evaluate` main | Not supported by Playwright API | Supported via `mw:` prefix, requires `main_world_eval: true` at launch; fully observable from page |
| `inject_init_script` | **Best for stealth** — pre-document, no DOM artifact | Stealthy but inert for main-world patching ([camoufox#48](https://github.com/daijro/camoufox/issues/48)); use [camoufox-add_init_script](https://github.com/techinz/camoufox-add_init_script) WebExtension instead |
| `evaluate` isolated | Safe (isolated utility world) — rate-limit before reCAPTCHA, each call is CDP traffic | Runs in page main world; reads are invisible, mutations are page-observable |
| `evaluate` main | Not supported by Playwright API | Same realm as `isolated` on this build (`mw:` prefix is a no-op) |
| `inject_init_script` | **Best for stealth** — pre-document, no DOM artifact | Patches reach the page (good) but are observable via `Function.prototype.toString` and `window` enumeration; not stealth-safe for high-tier WAFs |
| `add_script_tag` | Detectable (DOM node, MutationObserver, CSP) | Detectable (same) |

References: [Playwright evaluate](https://playwright.dev/docs/evaluating), [Playwright addInitScript](https://playwright.dev/docs/api/class-page#page-add-init-script), [Camoufox main-world eval](https://camoufox.com/python/main-world-eval/), [Camoufox stealth](https://camoufox.com/stealth/).
References: [Playwright evaluate](https://playwright.dev/docs/evaluating), [Playwright addInitScript](https://playwright.dev/docs/api/class-page#page-add-init-script), [Camoufox stealth](https://camoufox.com/stealth/).

#### Worlds and isolation — what your JS can and can't see

The two backends ship different world models. Picking the wrong tool is the most common stealth footgun on Camoufox, so the boundary matters.
The two backends ship different world models. Picking the wrong tool is the most common stealth footgun, so the boundary matters.

**Cloakbrowser (Chromium).** Playwright's `evaluate` runs in an isolated "utility" world that *shares globals with the page's main world*. An `addInitScript` patch to `navigator.webdriver` is visible to (a) your subsequent `evaluate` probes AND (b) anti-bot code the site loads. This is the model most "stealth playbooks" assume. Detection vectors are CDP-side (`Runtime.evaluate` chatter) — cloakbrowser's C++ patches mitigate those. The 48 source patches scrub the JS-observable leaks (`__playwright__binding__`, stack-trace `sourceURL` hints) at compile time.
**Cloakbrowser (Chromium).** Playwright's `evaluate` runs in an isolated "utility" world that *shares globals with the page's main world*. An `addInitScript` patch to `navigator.webdriver` is visible to (a) your subsequent `evaluate` probes AND (b) anti-bot code the site loads. This is the model most "stealth playbooks" assume. Detection vectors are CDP-side (`Runtime.evaluate` chatter) — cloakbrowser's C++ patches mitigate those.

**Camoufox (Firefox via Juggler).** Two strictly separated JS heaps:
**Camoufox (cloverlabs ≥0.6 + Firefox 150 — current build).** No separate JS world. `page.evaluate` and `page.addInitScript` both run in the page's main world, the same realm as a real `<script>` tag. Implications:

- **Main world** — the page's real `window`. Site scripts, anti-bot fingerprint code, and tags injected via `addScriptTag` run here.
- **Isolated/Juggler world** — Playwright's private scope. Different `window` object, same DOM. `evaluate` (without `mw:`) and `addInitScript` both land here.
- `inject_init_script` patches reach the page (e.g. `Object.defineProperty(navigator, 'webdriver', ...)` does affect what site scripts see). The DOWNSIDE: the patch is observable to anti-bot code on the page — `Function.prototype.toString.toString()` reveals replaced functions, `Object.defineProperty` hooks see the call.
- `interceptor_browser_evaluate` reads are invisible (no `window` writes, no prototype changes). Mutating evals (`() => { window.x = 1 }`) are observable.
- `world: "isolated"` and `world: "main"` accept the same args for API compatibility but run in the same realm. The `mw:` prefix and `main_world_eval: true` launch flag are accepted for backward-compat but have no observable effect on this build.

Consequence: an `addInitScript` that does `Object.defineProperty(navigator, 'webdriver', { get: () => false })` patches the *isolated* `navigator`. Your subsequent `evaluate` probe reads from the *same* isolated scope, sees the patched value, and returns `false`. The test passes. **But the site's detection code runs in main world and reads the unpatched `navigator`.** The patch is invisible to it. This is exactly [camoufox#48](https://github.com/daijro/camoufox/issues/48).
Verify behavior on your installed build:

| Read | Reads from | Sees init-script patch? |
|---|---|---|
| `interceptor_browser_evaluate` (no `world`) | isolated | yes |
| `interceptor_browser_evaluate world: "main"` (camoufox) | main | **no** |
| Anti-bot JS loaded by the site | main | **no** |
```bash
npx tsx scripts/camoufox-world-probe.ts --venv=/path/to/camoufox-venv
```

This isn't a Camoufox bug; it's the design. Camoufox spoofs fingerprints in the **C++ binary** (configured at launch via `os`, `fonts`, `webgl_config`, `humanize`, etc.) so main-world JS sees the spoofed values *as if they were the real ones*. The Chromium-era playbook of "patch at runtime via `addInitScript`" is what Camoufox is replacing.
**Historical note.** Earlier daijro/camoufox (Firefox 135 line) ran `evaluate` and `addInitScript` in a separate Juggler scope that was invisible to the page — patches there did NOT reach site scripts ([camoufox#48](https://github.com/daijro/camoufox/issues/48)), but automation JS was equally invisible to anti-bot code. Cloverlabs/FF150 dropped that isolation. If your workflow depends on Juggler-scope invisibility, stay on daijro/FF135.

**Practical rules:**

| Use case | Cloakbrowser | Camoufox |
| Use case | Cloakbrowser | Camoufox (cloverlabs/FF150) |
|---|---|---|
| Read DOM / extract data | `interceptor_browser_evaluate` (isolated) | `interceptor_browser_evaluate` (isolated) |
| Modify page state, click via JS | `interceptor_browser_evaluate` (isolated; globals are shared) | `interceptor_browser_evaluate` with `world: "main"` + launch `main_world_eval: true` |
| Spoof navigator / window fingerprints | `interceptor_browser_inject_init_script` | **Configure at launch (`os`, `fonts`, `webgl_config`, `humanize`, `firefox_user_prefs`).** `inject_init_script` will look like it worked from your isolated probes, but the page won't see it. |
| Load a 3rd-party JS lib into the page | `interceptor_browser_add_script_tag` (page sees it — usually OK if intentional) | Same — runs in main world (good for the use case), but DOM node is detectable |
| Read DOM / extract data | `interceptor_browser_evaluate` (isolated) | `interceptor_browser_evaluate` — reads don't leak |
| Modify page state, click via JS | `interceptor_browser_evaluate` (isolated; globals are shared) | `interceptor_browser_evaluate` — mutations are page-visible; use sparingly on stealth-sensitive targets |
| Spoof navigator / window fingerprints | `interceptor_browser_inject_init_script` | **Configure at launch (`os`, `fonts`, `webgl_config`, `humanize`, `firefox_user_prefs`).** Source-level patches are invisible. `inject_init_script` works but its patches are observable. |
| Load a 3rd-party JS lib into the page | `interceptor_browser_add_script_tag` (page sees it — usually OK if intentional) | Same — runs in main world; DOM node is detectable |

Strong stealth corollary on Camoufox: anti-bot code running in main world **literally cannot observe** your `evaluate` reads. No `Function.toString` leak, no stack frames in page scripts, no shadow globals. It's a different world. The same isolation that makes `addInitScript` "fail" makes scraping unobservable.
**Stealth note**: on the current camoufox build, every JS-level mutation from automation is observable by anti-bot code on the page. Prefer source-level configuration over runtime patching. Use `evaluate` for reads, not writes, when stealth matters.

### Sessions (13)

Expand Down
149 changes: 149 additions & 0 deletions scripts/camoufox-world-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Probe camoufox JS-execution world placement.
//
// PoC 1: page.addInitScript writes a marker. A page inline <script> reads it
// into document.title. If we see the marker → init reaches main world.
// PoC 2: page.addInitScript patches Function.prototype.toString. A page inline
// <script> calls it and stores into document.title. If the patch shows
// in title → init script's patch reached main world.
//
// Run: npx tsx scripts/camoufox-world-probe.ts --venv=/tmp/camoufox-venv
// npx tsx scripts/camoufox-world-probe.ts --venv=/tmp/camoufox-daijro-venv

import { firefox } from "playwright-core";
import { spawn } from "node:child_process";
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const venv = (process.argv.find((a) => a.startsWith("--venv=")) ?? "--venv=/tmp/camoufox-venv").slice(7);
const pythonExe = join(venv, "bin", "python3");
const label = venv.endsWith("daijro-venv") ? "daijro" : "cloverlabs";

const launcherPy = (wsFile: string, mainWorldEval: boolean): string => [
"import sys, json, base64, subprocess, re",
"from pathlib import Path",
"import orjson",
"from camoufox.server import LAUNCH_SCRIPT, get_nodejs, to_camel_case_dict",
"from camoufox.utils import launch_options",
`_WS_FILE = ${JSON.stringify(wsFile)}`,
"_ANSI = re.compile(r'\\x1b\\[[0-9;]*m')",
"_WS_RE = re.compile(r'Websocket endpoint:\\s*(ws://\\S+)')",
`config = launch_options(headless=True, main_world_eval=${mainWorldEval ? "True" : "False"}, geoip=False, humanize=False)`,
// daijro 0.4.11 rejects proxy=null; strip it when absent.
"if config.get('proxy') is None: config.pop('proxy', None)",
"nodejs = get_nodejs()",
"data = orjson.dumps(to_camel_case_dict(config))",
"proc = subprocess.Popen([nodejs, str(LAUNCH_SCRIPT)],",
" cwd=str(Path(nodejs).parent / 'package'),",
" stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,",
" text=True, bufsize=1)",
"if proc.stdin: proc.stdin.write(base64.b64encode(data).decode()); proc.stdin.close()",
"for line in proc.stdout:",
" sys.stdout.write(line); sys.stdout.flush()",
" m = _WS_RE.search(_ANSI.sub('', line))",
" if m:",
" with open(_WS_FILE, 'w') as f: f.write(json.dumps({'wsUrl': m.group(1)}))",
"proc.wait()",
].join("\n");

async function launchOnce(mainWorldEval: boolean) {
const dir = await mkdtemp(join(tmpdir(), `cam-probe-${label}-`));
const wsFile = join(dir, "ws.json");
const scriptPath = join(dir, "launch.py");
await writeFile(scriptPath, launcherPy(wsFile, mainWorldEval), "utf-8");

const proc = spawn(pythonExe, [scriptPath], { stdio: ["ignore", "pipe", "pipe"] });
proc.stdout.on("data", () => {});
proc.stderr.on("data", (b) => process.stderr.write(`[py.${label}] ${b}`));

let ws = "";
for (let i = 0; i < 120; i++) {
try { ws = JSON.parse(await readFile(wsFile, "utf-8")).wsUrl; break; }
catch { await new Promise((r) => setTimeout(r, 500)); }
}
if (!ws) { proc.kill("SIGTERM"); await rm(dir, { recursive: true, force: true }); throw new Error(`[${label}] no ws endpoint`); }
return { ws, dir, proc };
}

async function poc1_initMarker(): Promise<{ pass: boolean; raw: unknown }> {
// Init script sets a marker. Page <script> reads it into document.title.
const { ws, dir, proc } = await launchOnce(false);
try {
const browser = await firefox.connect(ws);
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
await page.addInitScript({ content: "window.__init_marker = 'set-by-init';" });
const html = `<html><head><title>pending</title><script>document.title = String(window.__init_marker);<\/script></head><body>x</body></html>`;
await page.goto("data:text/html," + encodeURIComponent(html));
const title = await page.title();
await browser.close();
return { pass: title === "set-by-init", raw: title };
} finally {
proc.kill("SIGTERM");
await rm(dir, { recursive: true, force: true });
}
}

async function poc2_toStringPatch(): Promise<{ pass: boolean; raw: unknown }> {
// Init script patches Function.prototype.toString. Page <script> calls it.
const { ws, dir, proc } = await launchOnce(false);
try {
const browser = await firefox.connect(ws);
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
await page.addInitScript({
content:
"const __orig = Function.prototype.toString;" +
"Function.prototype.toString = function(){ return 'PATCHED:' + __orig.call(this); };",
});
const html = `<html><head><title>pending</title><script>document.title = Function.prototype.toString.toString();<\/script></head><body>x</body></html>`;
await page.goto("data:text/html," + encodeURIComponent(html));
const title = await page.title();
await browser.close();
return { pass: title.startsWith("PATCHED:"), raw: title };
} finally {
proc.kill("SIGTERM");
await rm(dir, { recursive: true, force: true });
}
}

async function poc3_evalIsolation(): Promise<{ pass: boolean; raw: unknown }> {
// Verify the previously-observed "evaluate sees page globals" claim on
// whatever build this is, fresh launch.
const { ws, dir, proc } = await launchOnce(true);
try {
const browser = await firefox.connect(ws);
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await ctx.newPage();
const html = `<html><head><script>window.__page_set='from-page';<\/script></head><body>x</body></html>`;
await page.goto("data:text/html," + encodeURIComponent(html));
const fromDefault = await page.evaluate("(() => window.__page_set)()");
const fromMw = await page.evaluate("mw:(() => window.__page_set)()");
await browser.close();
// pass = isolation holds (default cannot see page global)
return { pass: fromDefault === undefined, raw: { fromDefault, fromMw } };
} finally {
proc.kill("SIGTERM");
await rm(dir, { recursive: true, force: true });
}
}

async function main() {
console.log(`==== camoufox world probe: ${label} (venv=${venv}) ====`);

console.log("\n[PoC 1] page.addInitScript marker visible to page <script>?");
const r1 = await poc1_initMarker();
console.log(` result: title=${JSON.stringify(r1.raw)} → init reaches main world: ${r1.pass ? "YES" : "NO"}`);

console.log("\n[PoC 2] addInitScript patch of Function.prototype.toString visible to page <script>?");
const r2 = await poc2_toStringPatch();
console.log(` result: title=${JSON.stringify(r2.raw)} → stealth patch reaches page: ${r2.pass ? "YES" : "NO"}`);

console.log("\n[PoC 3] default page.evaluate isolated from page main world?");
const r3 = await poc3_evalIsolation();
console.log(` result: ${JSON.stringify(r3.raw)} → isolation holds: ${r3.pass ? "YES" : "NO"}`);

console.log("\nSUMMARY:");
console.log(` ${label} init→main=${r1.pass ? "Y" : "N"} stealth_patch=${r2.pass ? "Y" : "N"} eval_isolated=${r3.pass ? "Y" : "N"}`);
}
main().catch((e) => { console.error(e); process.exit(1); });
Loading
Loading