feature: шум canvas с seed профиля — отпечатки профилей различаются (#89)
Минимальный 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c8394eb4c
commit
9dc2c0b619
6 files changed
+176
-19
No files matched your search
@@ -35,6 +35,23 @@ FP_JS = """async () => {
|
||||
}) + '</pre>';
|
||||
}"""
|
||||
|
||||
# пустой 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):
|
||||
|
||||
Reference in new issue
Block a user