feat: auth-capture — WS frame stream, drag events, continuous CDP, profile cleanup
- AuthCaptureDialog: real WebSocket for binary JPEG frame stream (no polling) - Pointer drag: mousedown/mousemove/mouseup events for slider-captcha - CDP capture starts at session creation, caches headers in session.captured_headers - Ephemeral profile dir deleted on session close (shutil.rmtree) - Candidate types unified: bearer_token / cookie / api_key / credential - Frontend handleAuthCaptureSelect maps all 4 types to correct form fields
This commit is contained in:
@@ -59,51 +59,17 @@ async def extract_session_storage(page: Any) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
async def extract_request_headers(page: Any) -> list[dict[str, str]]:
|
||||
"""Capture Authorization headers from network requests via CDP.
|
||||
async def extract_request_headers(session: Any) -> list[dict[str, str]]:
|
||||
"""Return Authorization / API-Key headers captured continuously by CDP.
|
||||
|
||||
Uses Chrome DevTools Protocol to subscribe to Network.requestWillBeSent
|
||||
events and extract Authorization / X-API-Key headers from captured
|
||||
requests. Only catches requests made *after* CDP is enabled.
|
||||
The CDP Network listener is started when the ephemeral session is created
|
||||
(in BrowserSessionService.create_ephemeral), so headers from the login
|
||||
flow are captured in real-time without needing a fresh CDP attach.
|
||||
"""
|
||||
captured: list[dict[str, str]] = []
|
||||
cdp = None
|
||||
try:
|
||||
cdp = await page.context.new_cdp_session(page)
|
||||
await cdp.send("Network.enable")
|
||||
|
||||
def on_request(params: dict) -> None:
|
||||
headers = params.get("request", {}).get("headers", {})
|
||||
auth = (headers.get("authorization") or headers.get("Authorization"))
|
||||
api_key = (headers.get("x-api-key") or headers.get("X-API-Key"))
|
||||
if auth:
|
||||
captured.append({
|
||||
"type": "authorization",
|
||||
"value": auth,
|
||||
"url": params.get("request", {}).get("url", ""),
|
||||
})
|
||||
logger.debug("auth-capture CDP: captured Authorization header")
|
||||
if api_key:
|
||||
captured.append({
|
||||
"type": "api_key",
|
||||
"value": api_key,
|
||||
"url": params.get("request", {}).get("url", ""),
|
||||
})
|
||||
logger.debug("auth-capture CDP: captured X-API-Key header")
|
||||
|
||||
cdp.on("Network.requestWillBeSent", on_request)
|
||||
# Give a moment for any in-flight requests to be captured
|
||||
import asyncio
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as exc:
|
||||
logger.debug("CDP network capture not available: %s", exc)
|
||||
finally:
|
||||
if cdp:
|
||||
try:
|
||||
await cdp.detach()
|
||||
except Exception:
|
||||
pass
|
||||
return captured
|
||||
if hasattr(session, "captured_headers") and session.captured_headers:
|
||||
logger.debug("auth-capture: returning %d cached headers", len(session.captured_headers))
|
||||
return list(session.captured_headers)
|
||||
return []
|
||||
|
||||
|
||||
async def extract_all(session: Any) -> dict[str, Any]:
|
||||
@@ -116,7 +82,7 @@ async def extract_all(session: Any) -> dict[str, Any]:
|
||||
cookies = await extract_cookies(session)
|
||||
local_storage = await extract_local_storage(page)
|
||||
session_storage = await extract_session_storage(page)
|
||||
auth_headers = await extract_request_headers(page)
|
||||
auth_headers = await extract_request_headers(session)
|
||||
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers)
|
||||
|
||||
return {
|
||||
@@ -137,22 +103,21 @@ def _curate_candidates(
|
||||
"""Scan extracted data for likely credentials with confidence scoring."""
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
# 1. CDP-captured Authorization headers (highest confidence)
|
||||
# 1. CDP-captured network headers (highest confidence)
|
||||
seen = set()
|
||||
for h in auth_headers:
|
||||
dedup_key = h["value"]
|
||||
if dedup_key in seen:
|
||||
continue
|
||||
seen.add(dedup_key)
|
||||
htype = h.get("type", "authorization")
|
||||
preview = _preview(h["value"])
|
||||
candidates.append({
|
||||
"type": "bearer_token",
|
||||
"source": f"network:{h['url'][:60]}",
|
||||
"value": h["value"],
|
||||
"preview": preview,
|
||||
"label": f"Authorization — {h['url'][:40]}",
|
||||
"confidence": 95,
|
||||
})
|
||||
if htype == "api_key":
|
||||
_add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview,
|
||||
f"X-API-Key — {h['url'][:40]}", 95)
|
||||
else:
|
||||
_add(candidates, "bearer_token", f"network:{h['url'][:60]}", h["value"], preview,
|
||||
f"Authorization — {h['url'][:40]}", 95)
|
||||
|
||||
# 2. localStorage/sessionStorage items
|
||||
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
||||
|
||||
@@ -32,6 +32,8 @@ class BrowserSession:
|
||||
context: Any
|
||||
page: Any
|
||||
lock: asyncio.Lock
|
||||
cdp_session: Any = None
|
||||
captured_headers: list[dict] = None # auth headers from CDP
|
||||
|
||||
|
||||
class BrowserSessionService:
|
||||
@@ -163,10 +165,24 @@ class BrowserSessionService:
|
||||
session = self._discard_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
# Detach CDP session if active
|
||||
if session.cdp_session:
|
||||
try:
|
||||
await session.cdp_session.detach()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up ephemeral (auth-capture) profile directories
|
||||
if session.profile_key and session.profile_key.startswith("auth-capture-"):
|
||||
profile_dir = self._profile_dir(session.profile_key)
|
||||
import shutil
|
||||
try:
|
||||
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
sessions = list(self._sessions)
|
||||
@@ -368,14 +384,46 @@ class BrowserSessionService:
|
||||
context=context,
|
||||
page=page,
|
||||
lock=asyncio.Lock(),
|
||||
captured_headers=[],
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||
# Start CDP network capture immediately — so we don't miss login requests
|
||||
await self._start_cdp_capture(session)
|
||||
except Exception:
|
||||
await self.close(session.id)
|
||||
raise
|
||||
return session
|
||||
|
||||
async def _start_cdp_capture(self, session: BrowserSession) -> None:
|
||||
"""Enable CDP Network domain and capture Authorization headers."""
|
||||
try:
|
||||
cdp = await session.context.new_cdp_session(session.page)
|
||||
await cdp.send("Network.enable")
|
||||
|
||||
def on_request(params: dict) -> None:
|
||||
headers = params.get("request", {}).get("headers", {})
|
||||
auth = headers.get("authorization") or headers.get("Authorization")
|
||||
api_key = headers.get("x-api-key") or headers.get("X-API-Key")
|
||||
url = params.get("request", {}).get("url", "")
|
||||
if auth:
|
||||
session.captured_headers.append({
|
||||
"type": "authorization",
|
||||
"value": auth,
|
||||
"url": url,
|
||||
})
|
||||
if api_key:
|
||||
session.captured_headers.append({
|
||||
"type": "api_key",
|
||||
"value": api_key,
|
||||
"url": url,
|
||||
})
|
||||
|
||||
cdp.on("Network.requestWillBeSent", on_request)
|
||||
session.cdp_session = cdp
|
||||
except Exception as exc:
|
||||
logger.debug("CDP capture not available: %s", exc)
|
||||
|
||||
|
||||
browser_sessions = BrowserSessionService()
|
||||
|
||||
Reference in New Issue
Block a user