From 9dc2c0b619b230f40c6986e8b3b6895941d2a506 Mon Sep 17 00:00:00 2001 From: protokey Date: Tue, 13 Oct 2026 21:02:21 +0400 Subject: [PATCH] =?UTF-8?q?feature:=20=D1=88=D1=83=D0=BC=20canvas=20=D1=81?= =?UTF-8?q?=20seed=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=82=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Минимальный init-script (src/api/js/canvas_noise.js): младший бит у ~1/64 непрозрачных пикселей при toDataURL/toBlob/getImageData, детерминирован от seed профиля. Пустой canvas не меняется, подменённые функции неотличимы по toString и length. Старые профили получают seed, экран сохраняется. Проверено: 4 профиля — 4 разных хеша canvas; Cloudflare 6/6 на новых профилях. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/API.md | 16 ++++++--- src/api/js/canvas_noise.js | 71 ++++++++++++++++++++++++++++++++++++++ src/api/profile.py | 31 ++++++++++++----- tests/browser/test_e2e.py | 42 ++++++++++++++++++++-- tests/unit/test_profile.py | 33 +++++++++++++++--- 6 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 src/api/js/canvas_noise.js diff --git a/README.md b/README.md index ad9721b..624da94 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Client → Caddy (TLS) → api (FastAPI) ──task──▶ RabbitMQ ──▶ reCAPTCHA, hCaptcha) are solved via the client's 2captcha key. - **Proxies**: not managed by the service — the client may supply their own proxy per request. Browser locale and timezone are derived from the proxy's exit IP. -- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR) is generated on +- **Sessions**: `session_id` cookie → persistent Chrome profile on disk, removed after `PROFILE_TTL_DAYS` (7) unused days. A fingerprint (screen, DPR, canvas noise seed) is generated on the first request of a session; cookies passed in a request replace the profile's cookies. - **Browser identity is not configurable**: user agent and window size are the real browser's own — a spoofed UA mismatches `userAgentData` and Cloudflare blocks it. Clients take `user_agent` from the response. diff --git a/docs/API.md b/docs/API.md index 8abfb37..3180560 100644 --- a/docs/API.md +++ b/docs/API.md @@ -138,11 +138,19 @@ Cookie: session_id= # необязательно, см. «Сесси (`extra/user_data_dir/`): cookies, localStorage и отпечаток сохраняются, повторно проходить Cloudflare обычно не нужно. Без cookie или с некорректным значением (не UUID) запрос получает новый профиль. +Сессии изолированы друг от друга: у каждой свой профиль Chrome — cookies, localStorage, кэш. + Отпечаток профиля генерируется при первом запросе сессии и дальше не меняется (`robotex_session.json` в каталоге -профиля): размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge). -Применяется нативной эмуляцией Chrome, без JS-подмены (она ломала прохождение Cloudflare); DPR влияет и на хеш -canvas. Ограничение: canvas, WebGL и `hardwareConcurrency` у всех профилей одинаковые, поэтому примерно половина -профилей совпадает по отпечатку. +профиля): + +* размер экрана и `devicePixelRatio` десктопного Chrome на Linux из реального распределения (browserforge) — + нативной эмуляцией Chrome; +* шум canvas: свой seed у каждого профиля, поэтому хеш canvas у профилей разный, а внутри профиля стабильный. + Меняется младший бит у редких непрозрачных пикселей при чтении canvas (`toDataURL`, `toBlob`, `getImageData`); + пустой canvas не меняется. + +Остальное — WebGL, `hardwareConcurrency`, шрифты, UA — не подменяется: это родные значения Chrome, за маскировку +автоматизации отвечает Patchright. Полная JS-подмена отпечатка ломала прохождение Cloudflare. User-Agent и размер окна запросом не задаются — это родные значения браузера (почему — [Usage.md](Usage.md#что-задаёт-сервис-а-не-клиент)). Поле `user_agent` в запросе даёт `422`. diff --git a/src/api/js/canvas_noise.js b/src/api/js/canvas_noise.js new file mode 100644 index 0000000..cfd482f --- /dev/null +++ b/src/api/js/canvas_noise.js @@ -0,0 +1,71 @@ +// Шум canvas профиля (#89): младший бит одного канала у ~1/64 непрозрачных пикселей. +// Детерминирован от seed профиля и номера пикселя: внутри профиля хеш canvas стабилен, +// между профилями — разный. Пустые (прозрачные) пиксели не меняются, иначе пустой canvas +// перестал бы быть пустым. Подменённые функции выглядят нативными для toString. +(() => { + const SEED = __SEED__ | 0; + const MAX_PIXELS = 4096 * 4096; + const fnToString = Function.prototype.toString; + const nativeSource = new WeakMap(); + + const noise = (data) => { + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) continue; + let x = Math.imul((i >>> 2) ^ SEED, 0x9e3779b1); + x ^= x >>> 15; + x = Math.imul(x, 0x85ebca77); + x ^= x >>> 13; + if ((x & 63) === 0) { + const c = i + ((x >>> 6) % 3); + data[c] ^= 1; + } + } + }; + + const replace = (proto, name, make) => { + const desc = Object.getOwnPropertyDescriptor(proto, name); + if (!desc || typeof desc.value !== 'function') return null; + const orig = desc.value; + const impl = make(orig); + const fn = { [name](...args) { return impl.call(this, args); } }[name]; + Object.defineProperty(fn, 'length', { value: orig.length }); + nativeSource.set(fn, fnToString.call(orig)); + Object.defineProperty(proto, name, { ...desc, value: fn }); + return orig; + }; + + const toStringDesc = Object.getOwnPropertyDescriptor(Function.prototype, 'toString'); + const toString = { toString() { return nativeSource.has(this) ? nativeSource.get(this) : fnToString.call(this); } }.toString; + nativeSource.set(toString, fnToString.call(fnToString)); + Object.defineProperty(Function.prototype, 'toString', { ...toStringDesc, value: toString }); + + const ctxProto = CanvasRenderingContext2D.prototype; + const origGetImageData = replace(ctxProto, 'getImageData', (orig) => function (args) { + const img = orig.apply(this, args); + noise(img.data); + return img; + }); + + // toDataURL/toBlob: шумим копию, исходный canvas страницы не трогаем + const noisyCopy = (canvas) => { + const w = canvas.width, h = canvas.height; + if (!w || !h || w * h > MAX_PIXELS) return canvas; + const copy = document.createElement('canvas'); + copy.width = w; + copy.height = h; + const ctx = copy.getContext('2d'); + ctx.drawImage(canvas, 0, 0); + const img = origGetImageData.call(ctx, 0, 0, w, h); + noise(img.data); + ctx.putImageData(img, 0, 0); + return copy; + }; + + const canvasProto = HTMLCanvasElement.prototype; + replace(canvasProto, 'toDataURL', (orig) => function (args) { + return orig.apply(noisyCopy(this), args); + }); + replace(canvasProto, 'toBlob', (orig) => function (args) { + return orig.apply(noisyCopy(this), args); + }); +})(); diff --git a/src/api/profile.py b/src/api/profile.py index 60d8a9a..9c5cd87 100644 --- a/src/api/profile.py +++ b/src/api/profile.py @@ -1,9 +1,11 @@ """ -Отпечаток профиля сессии (рядом с профилем Chrome): экран и devicePixelRatio. Генерируется один раз -при первом запросе сессии и дальше не меняется — реальный человек не меняет монитор между визитами. +Отпечаток профиля сессии (рядом с профилем Chrome): экран, devicePixelRatio и seed шума canvas. +Генерируется один раз при первом запросе сессии и дальше не меняется — реальный человек не меняет +монитор между визитами. -Применяется только нативной эмуляцией Chrome, без JS-инъекций: полная подмена через browserforge -InjectFunction ломала прохождение Cloudflare Turnstile (51a801f). +Экран и DPR применяются нативной эмуляцией Chrome. JS — только минимальный шум canvas (js/canvas_noise.js, +#89): полная подмена через browserforge InjectFunction ломала прохождение Cloudflare Turnstile (51a801f). +Остальное (WebGL, hardwareConcurrency, шрифты) — родное, как его отдаёт Chrome через Patchright. User-Agent не подменяется: строка UA, отличная от реального Chrome, расходится с navigator.userAgentData и движком, и Cloudflare такой браузер не пропускает (замер в #87). UA — всегда @@ -11,6 +13,7 @@ navigator.userAgentData и движком, и Cloudflare такой браузе """ import json import logging +import random from pathlib import Path from browserforge.fingerprints import FingerprintGenerator, Screen @@ -20,6 +23,11 @@ log = logging.getLogger(__name__) SETTINGS_FILE = "robotex_session.json" # окно браузера под Xvfb — 1920x1080, монитор физически не может быть меньше окна MIN_SCREEN = (1920, 1080) +CANVAS_NOISE_JS = (Path(__file__).parent / "js" / "canvas_noise.js").read_text() + + +def new_canvas_seed() -> int: + return random.randint(1, 2**31 - 1) def generate_fingerprint(attempts: int = 10) -> dict: @@ -42,7 +50,7 @@ def generate_fingerprint(attempts: int = 10) -> dict: else: log.warning("browserforge failed %d times, using default screen", attempts) - return {"screen": screen, "device_scale_factor": dpr} + return {"screen": screen, "device_scale_factor": dpr, "canvas_seed": new_canvas_seed()} def load(user_data_dir: Path) -> dict: @@ -55,9 +63,13 @@ def load(user_data_dir: Path) -> dict: def load_or_create(user_data_dir: Path) -> dict: """Настройки профиля: сохранённый отпечаток или новый при первом запросе сессии.""" settings = load(user_data_dir) - if "fingerprint" in settings: + if "fingerprint" in settings and "canvas_seed" in settings["fingerprint"]: return settings - settings = {"fingerprint": generate_fingerprint()} + if "fingerprint" in settings: + # профиль до #89: экран сохраняем, добавляем только seed шума + settings = {"fingerprint": settings["fingerprint"] | {"canvas_seed": new_canvas_seed()}} + else: + settings = {"fingerprint": generate_fingerprint()} (user_data_dir / SETTINGS_FILE).write_text(json.dumps(settings, ensure_ascii=False)) return settings @@ -65,4 +77,7 @@ def load_or_create(user_data_dir: Path) -> dict: def browser_kwargs(settings: dict) -> dict: """kwargs AsyncStealthySession под отпечаток профиля.""" fp = settings["fingerprint"] - return {"additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}} + return { + "additional_args": {"screen": fp["screen"], "device_scale_factor": fp["device_scale_factor"]}, + "init_script_content": CANVAS_NOISE_JS.replace("__SEED__", str(int(fp["canvas_seed"]))), + } diff --git a/tests/browser/test_e2e.py b/tests/browser/test_e2e.py index 2e31b26..94d97e3 100644 --- a/tests/browser/test_e2e.py +++ b/tests/browser/test_e2e.py @@ -35,6 +35,23 @@ FP_JS = """async () => { }) + ''; }""" +# пустой canvas не зашумлён, подменённые функции выглядят нативными +NOISE_JS = """() => { + const blank = document.createElement('canvas'); + blank.width = 50; blank.height = 50; + const empty = document.createElement('canvas'); + empty.width = 50; empty.height = 50; + const data = blank.getContext('2d').getImageData(0, 0, 50, 50).data; + return { + blank_same: blank.toDataURL() === empty.toDataURL(), + blank_zero: data.every(v => v === 0), + native: [HTMLCanvasElement.prototype.toDataURL, HTMLCanvasElement.prototype.toBlob, + CanvasRenderingContext2D.prototype.getImageData, Function.prototype.toString] + .every(f => /\\{ \\[native code\\] \\}$/.test(Function.prototype.toString.call(f)) && /native code/.test(String(f))), + lengths: [HTMLCanvasElement.prototype.toDataURL.length, CanvasRenderingContext2D.prototype.getImageData.length], + }; +}""" + @pytest.fixture def env(monkeypatch, tmp_path): @@ -92,7 +109,8 @@ def test_cookies_replace_profile_cookies(client): def test_fingerprint_applied_and_stable(client, monkeypatch): async def page_fp(page): - await page.evaluate(FP_JS) + # в основном мире страницы, как скрипты сайта (по умолчанию Patchright выполняет в изолированном) + await page.evaluate(FP_JS, isolated_context=False) return json.loads(await page.inner_html("#fp")) results = {} @@ -132,7 +150,27 @@ def test_fingerprint_applied_and_stable(client, monkeypatch): assert all(m == measured[0] for m in measured), "fingerprint changed within a profile" fps[d] = measured[0] assert len(fps) == 4 - print("distinct fingerprints:", len({json.dumps(f, sort_keys=True) for f in fps.values()}), "of", len(fps)) + # шум canvas (#89): у каждого профиля свой хеш canvas, значит и отпечаток целиком + assert len({f["canvas"] for f in fps.values()}) == 4 + assert len({json.dumps(f, sort_keys=True) for f in fps.values()}) == 4 + + +def test_canvas_noise_is_invisible(client, monkeypatch): + """Шум не трогает пустой canvas, а подменённые функции неотличимы от нативных по toString и length.""" + seen = {} + original = windows.make_page_action + + def capture(payload): + action = original(payload) + + async def wrapped(page): + seen.update(await page.evaluate(NOISE_JS, isolated_context=False)) + return await action(page) + return wrapped + + monkeypatch.setattr(windows, "make_page_action", capture) + assert solve(client, url="https://example.com")["status"] == "ok" + assert seen == {"blank_same": True, "blank_zero": True, "native": True, "lengths": [0, 4]} def test_ua_in_response_is_native(client): diff --git a/tests/unit/test_profile.py b/tests/unit/test_profile.py index 4ce472e..91ed946 100644 --- a/tests/unit/test_profile.py +++ b/tests/unit/test_profile.py @@ -11,6 +11,7 @@ def test_fingerprint_generated_once(tmp_path): screen = first["fingerprint"]["screen"] assert screen["width"] >= profile.MIN_SCREEN[0] and screen["height"] >= profile.MIN_SCREEN[1] assert first["fingerprint"]["device_scale_factor"] >= 1 + assert first["fingerprint"]["canvas_seed"] > 0 assert "user_agent" not in first @@ -38,9 +39,33 @@ def test_generate_falls_back_when_browserforge_fails(monkeypatch): raise ValueError("Cannot generate headers") monkeypatch.setattr(profile, "FingerprintGenerator", Failing) - assert profile.generate_fingerprint(attempts=2) == {"screen": {"width": 1920, "height": 1080}, "device_scale_factor": 1} + fp = profile.generate_fingerprint(attempts=2) + assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 1920, "height": 1080}, 1) + assert fp["canvas_seed"] > 0 -def test_browser_kwargs_do_not_touch_user_agent(): - kwargs = profile.browser_kwargs({"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}}) - assert kwargs == {"additional_args": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25}} +def test_browser_kwargs(): + kwargs = profile.browser_kwargs({"fingerprint": { + "screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25, "canvas_seed": 12345, + }}) + assert kwargs["additional_args"] == {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 1.25} + assert "useragent" not in kwargs + script = kwargs["init_script_content"] + assert "const SEED = 12345 | 0;" in script and "__SEED__" not in script + + +def test_profile_before_canvas_noise_gets_seed_and_keeps_screen(tmp_path): + old = {"fingerprint": {"screen": {"width": 2560, "height": 1440}, "device_scale_factor": 2}} + (tmp_path / profile.SETTINGS_FILE).write_text(json.dumps(old)) + fp = profile.load_or_create(tmp_path)["fingerprint"] + assert (fp["screen"], fp["device_scale_factor"]) == ({"width": 2560, "height": 1440}, 2) + assert fp["canvas_seed"] > 0 + assert profile.load_or_create(tmp_path)["fingerprint"] == fp + + +def test_profiles_get_different_canvas_seeds(tmp_path): + seeds = set() + for i in range(20): + (tmp_path / str(i)).mkdir() + seeds.add(profile.load_or_create(tmp_path / str(i))["fingerprint"]["canvas_seed"]) + assert len(seeds) == 20