diff --git a/backend/apps/agents/browser/browser_session_import.py b/backend/apps/agents/browser/browser_session_import.py index 1005a6bd..7f4d6c54 100644 --- a/backend/apps/agents/browser/browser_session_import.py +++ b/backend/apps/agents/browser/browser_session_import.py @@ -48,6 +48,14 @@ P_GOOGLE_SUFFIXES = ("google.com", "youtube.com") # seconds, and an entry with no expiry is session-scoped, so it would evaporate on the next quit. P_CHROMIUM_EPOCH_OFFSET_S = 11644473600 +# Anti-bot clearance tokens are bound to the exact user agent and IP that earned them. Our webview +# keeps an "openswarm/" product token in its UA, so a clearance minted by the user's real Chrome can +# never match ours, and replaying a mismatched one reads as token theft: the edge hands back a fresh +# challenge instead of letting us through, which is WORSE than arriving with no clearance at all. +# Everything else in the jar is the actual session, so we carry that and let the edge re-challenge +# us honestly. +P_FINGERPRINT_BOUND = {"cf_clearance", "__cf_bm", "_cfuvid", "datadome", "incap_ses", "reese84"} + class SessionImportResult(BaseModel): """What happened, in a shape the caller can branch on without parsing prose.""" @@ -88,7 +96,8 @@ def read_site_records(domain: str) -> List[Dict[str, Any]]: # A browser we cannot read is a fallback, never a crash: the run just asks the user instead. logger.info(f"[session-import] read failed for {domain}: {type(exc).__name__}") return [] - return [{**r, "expires": p_unix_expiry(r.get("expires_utc"))} for r in raw] + return [{**r, "expires": p_unix_expiry(r.get("expires_utc"))} for r in raw + if str(r.get("name") or "").lower() not in P_FINGERPRINT_BOUND] @typechecked diff --git a/backend/tests/test_browser_session_import.py b/backend/tests/test_browser_session_import.py index 3d45f70d..b6dcd278 100644 --- a/backend/tests/test_browser_session_import.py +++ b/backend/tests/test_browser_session_import.py @@ -125,6 +125,21 @@ def test_expiry_is_translated_out_of_chromium_time(monkeypatch): assert out[1]["expires"] == 0.0, "a session entry must stay session-scoped, not become 1601" +def test_fingerprint_bound_clearance_is_left_behind(monkeypatch): + """Anti-bot clearance is minted against the UA and IP that earned it, and our webview keeps an + 'openswarm/' token in its UA, so a borrowed clearance can never match. Replaying a mismatched + one reads as token theft and gets us challenged HARDER than arriving with none, while the real + session cookies beside it are perfectly portable.""" + monkeypatch.setattr(si.browser_cookies, "read_provider_cookie_records", lambda d: [ + {"name": "sid", "value": "opaque", "expires_utc": 0}, + {"name": "uid", "value": "opaque", "expires_utc": 0}, + {"name": "cf_clearance", "value": "opaque", "expires_utc": 0}, + {"name": "__cf_bm", "value": "opaque", "expires_utc": 0}, + {"name": "datadome", "value": "opaque", "expires_utc": 0}, + ]) + assert sorted(r["name"] for r in si.read_site_records("medium.com")) == ["sid", "uid"] + + def test_google_reads_go_through_the_named_sso_scope(monkeypatch): """A Gmail borrow must use the reader's named SSO set, never a general sweep of the user's google entries."""