from __future__ import annotations import base64 import binascii import sys from dataclasses import dataclass from .._helpers import is_ipv6_address from .._version import __title__, __version__ from .errors import ReplyError DEFAULT_USER_AGENT = ( f"Python/{sys.version_info[0]}.{sys.version_info[1]} {__title__}/{__version__}" ) CRLF = "\r\n" @dataclass class BasicAuth: login: str password: str encoding: str = "latin1" def __post_init__(self) -> None: if self.login is None: raise ValueError("None is not allowed as login value") if self.password is None: raise ValueError("None is not allowed as password value") if ":" in self.login: raise ValueError('A ":" is not allowed in login (RFC 1945#section-11.1)') @classmethod def decode(cls, auth_header: str, encoding: str = "latin1") -> BasicAuth: """Create a BasicAuth object from an Authorization HTTP header.""" try: auth_type, encoded_credentials = auth_header.split(" ", 1) except ValueError: raise ValueError("Could not parse authorization header.") # noqa: B904 if auth_type.lower() != "basic": raise ValueError(f"Unknown authorization method {auth_type}") try: decoded = base64.b64decode( encoded_credentials.encode("ascii"), validate=True, ).decode(encoding) except binascii.Error: raise ValueError("Invalid base64 encoding.") # noqa: B904 try: # RFC 2617 HTTP Authentication # https://www.ietf.org/rfc/rfc2617.txt # the colon must be present, but the username and password may be # otherwise blank. username, password = decoded.split(":", 1) except ValueError: raise ValueError("Invalid credentials.") # noqa: B904 return cls(login=username, password=password, encoding=encoding) def encode(self) -> str: """Encode credentials.""" creds = f"{self.login}:{self.password}".encode(self.encoding) return f"Basic {base64.b64encode(creds).decode(self.encoding)}" class _Buffer: def __init__(self, encoding: str = "utf-8") -> None: self._encoding = encoding self._buffer = bytearray() def append_line(self, line: str = "") -> None: if line: self._buffer.extend(line.encode(self._encoding)) self._buffer.extend(CRLF.encode("ascii")) def dumps(self) -> bytes: return bytes(self._buffer) @dataclass class ConnectRequest: host: str port: int username: str | None password: str | None def dumps(self) -> bytes: buff = _Buffer() # RFC 3986 § 3.2.2 / RFC 7230 § 5.4: a literal IPv6 address in an # authority component must be enclosed in square brackets so the # `:` between literal and port is unambiguous. Without brackets, # `2001:db8::1:443` could be parsed as either the address # `2001:db8::1` with port `443` or the address `2001:db8::1:443` # with no port — proxies disagree, and several misroute the # unbracketed form (typically returning `200 Connection # established` against a connection that was never actually made). host = f"[{self.host}]" if is_ipv6_address(self.host) else self.host buff.append_line(f"CONNECT {host}:{self.port} HTTP/1.1") buff.append_line(f"Host: {host}:{self.port}") buff.append_line(f"User-Agent: {DEFAULT_USER_AGENT}") if self.username and self.password: auth = BasicAuth(self.username, self.password) buff.append_line(f"Proxy-Authorization: {auth.encode()}") buff.append_line() return buff.dumps() @dataclass class ConnectReply: status_code: int message: str @classmethod def loads(cls, data: bytes) -> ConnectReply: if not data: raise ReplyError("Invalid proxy response") # pragma: no cover line = data.split(CRLF.encode("ascii"), 1)[0] line = line.decode("utf-8", "surrogateescape") try: _version, code, *reason = line.split() except ValueError as e: # pragma: no cover raise ReplyError(f"Invalid status line: {line}") from e try: status_code = int(code) except ValueError as e: # pragma: no cover raise ReplyError(f"Invalid status code: {code}") from e status_message = " ".join(reason) if status_code != 200: # noqa: PLR2004 msg = f"{status_code} {status_message}" raise ReplyError(msg, error_code=status_code) return cls(status_code=status_code, message=status_message) class Connection: def send(self, request: ConnectRequest) -> bytes: return request.dumps() def receive(self, data: bytes) -> ConnectReply: return ConnectReply.loads(data)