strix/containers/cdp-auth-proxy.py
2026-04-01 13:49:30 -07:00

193 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""CDP authentication proxy.
Replaces the raw socat forwarder with an auth-aware TCP proxy. Every
connection (HTTP *and* WebSocket upgrade) must carry a valid token —
either as an ``Authorization: Bearer <token>`` header or a
``?token=<token>`` query parameter. The latter is needed for WebSocket
clients (e.g. Playwright) that don't support custom headers on the
upgrade request.
Auth credentials are stripped before the request is forwarded to
Chromium so the upstream sees a clean, standard CDP request.
Environment variables:
TOOL_SERVER_TOKEN — required, shared secret
CDP_PORT — listen port (default 9222)
CDP_INTERNAL_PORT — upstream Chromium port (default 19222)
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
TOKEN: str = os.environ["TOOL_SERVER_TOKEN"]
LISTEN_PORT: int = int(os.environ.get("CDP_PORT", "9222"))
UPSTREAM_PORT: int = int(os.environ.get("CDP_INTERNAL_PORT", "19222"))
# Maximum bytes to buffer while looking for the end-of-headers marker.
# Prevents memory exhaustion from slow-loris / oversized-header attacks.
_MAX_HEADER_SIZE: int = 16 * 1024 # 16 KiB — plenty for CDP requests
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _check_auth(header_bytes: bytes) -> bool:
text = header_bytes.decode("latin-1", errors="replace")
# 1) Authorization: Bearer <token>
m = re.search(r"(?i)Authorization:\s*Bearer\s+(\S+)", text)
if m and m.group(1) == TOKEN:
return True
# 2) ?token=<token> or &token=<token> query param
m = re.search(r"[?&]token=([^&\s]+)", text)
return bool(m and m.group(1) == TOKEN)
def _sanitize(header_bytes: bytes) -> bytes:
"""Strip auth credentials before forwarding upstream."""
text = header_bytes.decode("latin-1", errors="replace")
# Remove Authorization header line
text = re.sub(r"(?im)^Authorization:[^\r\n]*\r\n", "", text)
# Remove token query param — handle all positions:
# ?token=val&rest → ?rest (first of many)
# ?token=val → (nothing) (sole param)
# &token=val → (nothing) (not first)
text = re.sub(r"\?token=[^&\s]+&", "?", text)
text = re.sub(r"\?token=[^&\s]+", "", text)
text = re.sub(r"&token=[^&\s]+", "", text)
return text.encode("latin-1")
_REJECT = (
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 12\r\nConnection: close\r\n\r\nUnauthorized"
)
# ---------------------------------------------------------------------------
# TCP proxy
# ---------------------------------------------------------------------------
async def _pipe(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Forward bytes until EOF or error."""
try:
while True:
data = await reader.read(65536)
if not data:
break
writer.write(data)
await writer.drain()
except (ConnectionResetError, BrokenPipeError, OSError):
pass
finally:
try:
if writer.can_write_eof():
writer.write_eof()
except OSError:
pass
async def _handle(
client_reader: asyncio.StreamReader,
client_writer: asyncio.StreamWriter,
) -> None:
up_writer: asyncio.StreamWriter | None = None
try:
# --- Read HTTP headers (up to the blank line) ---
buf = b""
while b"\r\n\r\n" not in buf:
chunk = await asyncio.wait_for(client_reader.read(8192), timeout=10)
if not chunk:
return
buf += chunk
if len(buf) > _MAX_HEADER_SIZE:
client_writer.write(
b"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n"
)
await client_writer.drain()
return
sep = buf.index(b"\r\n\r\n") + 4
headers = buf[:sep]
remainder = buf[sep:]
# --- Authenticate ---
if not _check_auth(headers):
client_writer.write(_REJECT)
await client_writer.drain()
return
# --- Strip credentials & forward ---
headers = _sanitize(headers)
up_reader, up_writer = await asyncio.open_connection("127.0.0.1", UPSTREAM_PORT)
up_writer.write(headers)
if remainder:
up_writer.write(remainder)
await up_writer.drain()
# --- Bidirectional pipe ---
done, pending = await asyncio.wait(
[
asyncio.create_task(_pipe(client_reader, up_writer)),
asyncio.create_task(_pipe(up_reader, client_writer)),
],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
except (ConnectionResetError, BrokenPipeError, OSError, TimeoutError):
pass
finally:
for w in (client_writer, up_writer):
if w is not None:
with _suppress_os():
w.close()
class _suppress_os: # noqa: N801
"""Tiny context manager — cheaper than contextlib.suppress(OSError)."""
def __enter__(self) -> None:
pass
def __exit__(self, *exc: object) -> bool:
return isinstance(exc[1], OSError) if exc[1] is not None else False
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def _main() -> None:
server = await asyncio.start_server(_handle, "0.0.0.0", LISTEN_PORT) # nosec B104
print(
f"CDP auth proxy: 0.0.0.0:{LISTEN_PORT} -> 127.0.0.1:{UPSTREAM_PORT}",
flush=True,
)
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
asyncio.run(_main())
except KeyboardInterrupt:
sys.exit(0)