blob: ecd6d0d01e6e9e7d4645ab2d9605e734259c1b60 [file]
"""Raw socket helper for protocol-level tests.
Port of the raw-socket side of Apache::TestRequest (``vhost_socket`` / ``getline``
/ ``socket_trace``). Many httpd tests bypass the HTTP client and speak HTTP (or a
malformed approximation of it) directly over a TCP socket -- e.g. CVE regression
tests that send a hand-built request line and assert on the raw status line.
:class:`VhostSocket` wraps a ``socket.socket`` with the small line-oriented API
those tests use: ``print`` (send), ``getline`` (read one CRLF/LF-terminated line),
``read`` (drain), and ``connected``. It is a context manager so callers can use
``with http.vhost_socket(...) as sock:``.
"""
from __future__ import annotations
import socket
import ssl as _ssl
class VhostSocket:
"""A thin line-oriented wrapper over a connected TCP (optionally TLS) socket."""
def __init__(self, sock: socket.socket) -> None:
self._sock = sock
self._buf = b""
self._trace = False
# -- Apache::TestRequest socket API ----------------------------------
@property
def connected(self) -> bool:
try:
self._sock.getpeername()
return True
except OSError:
return False
def socket_trace(self, on: bool = True) -> None:
"""Enable echoing of sent/received data (Apache::TestRequest::socket_trace)."""
self._trace = on
def print(self, data: str | bytes) -> int:
"""Send ``data`` (str encoded latin-1, preserving raw bytes). Returns 1 (ok)."""
raw = data.encode("latin-1") if isinstance(data, str) else data
if self._trace:
print(f"S> {raw!r}")
self._sock.sendall(raw)
return 1
# Perl idiom: $sock->print(...). Keep `send` too for clarity.
send = print
def getline(self, timeout: float | None = 10.0) -> str | None:
"""Read one line (up to and incl. the newline), returned as a str.
Mirrors Apache::TestRequest::getline: returns the line including its
trailing ``\\n``, or None at EOF. Buffers across reads.
"""
if timeout is not None:
self._sock.settimeout(timeout)
while b"\n" not in self._buf:
try:
chunk = self._sock.recv(4096)
except (TimeoutError, socket.timeout):
break
if not chunk:
break
self._buf += chunk
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
line, self._buf = self._buf, b""
else:
line, self._buf = self._buf[: nl + 1], self._buf[nl + 1 :]
text = line.decode("latin-1")
if self._trace:
print(f"C< {text!r}")
return text
def read(self, timeout: float | None = 10.0) -> str:
"""Drain and return everything still readable (decoded latin-1)."""
if timeout is not None:
self._sock.settimeout(timeout)
data = self._buf
self._buf = b""
while True:
try:
chunk = self._sock.recv(4096)
except (TimeoutError, socket.timeout):
break
if not chunk:
break
data += chunk
return data.decode("latin-1")
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def __enter__(self) -> VhostSocket:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def open_vhost_socket(
host: str, port: int, *, use_ssl: bool = False, timeout: float = 10.0
) -> VhostSocket:
"""Open a raw TCP (optionally TLS) connection to ``host:port``.
TLS connections are wrapped without certificate verification -- protocol
tests care about the wire behaviour, not the peer identity (matching the
Perl tests, which use IO::Socket::SSL with the test CA / no verification).
"""
raw = socket.create_connection((host, port), timeout=timeout)
if use_ssl:
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
raw = ctx.wrap_socket(raw, server_hostname=host)
return VhostSocket(raw)