3proxy/tests/cases/proxy_headers.py
Vladimir Dubrovin aee69bf679 proxy: headers read and rewritten in the connection's buffer
The HTTP proxy read each request into a buffer of 64 KB of its own,
cleared it with memset, and read the answer's headers into it again;
the header lines were copied there one by one from clibuf or srvbuf.
Now the header block of a request is read whole into clibuf, and that
of an answer into srvbuf, and worked on where it is (hdrread()):

- the block is moved to one byte into the buffer, and its lines, as
  they are kept, are moved down to its start, so the line being worked
  on, and the 0 written after it, never reach the next one; headers
  taken out are left behind;
- HDRRESERVE is left free after it, for the headers this proxy adds,
  before whatever came after it, a body or another request, from
  where the connection is read on;
- the buffer is grown only for a block bigger than it, up to the
  limits there were (512 KB for a request, 64 KB for an answer);
- the headers are copied to a buffer of their own only when they need
  more room than that, when a filter hands back one, when a body is to
  be read whole into the same buffer for a data filter, and for an ftp
  listing, which is written where the headers were.

Whether buf is the headers' own buffer is kept with it (bufown), not
found from where it points: the side's buffer may be a bigger one by
then let go of. filterswap() leaves alone a buffer in either side's
buffer, which may be one grown by malloc, not only their fixed arrays.

What took buf for a buffer of LINESIZE does no longer: the request is
logged from a copy of its line, and the rest of a request refused
before its headers were all read is drained through a small buffer of
its own, keeping track of where its lines begin.

Tests: a POST whose body comes in the same packet as its headers,
which are rewritten, with a header added and one taken out; 45 KB of
request headers; two requests pipelined in one packet; empty lines
before a request; an answer with a 30 KB header; a 600 KB header block
refused, the proxy answering after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:49:46 +03:00

157 lines
6.6 KiB
Python

"""HTTP proxy headers, read whole into the connection's buffer and rewritten
there: what reaches the server and what comes back are checked byte for
byte where it matters, for header blocks bigger than the buffer, a body or
another request in the same packet as the headers, and answers with big
headers."""
import socket
import threading
import time
class Origin:
"""A server recording every request it gets, headers and body, and
answering each with the answer given, keeping the connection."""
def __init__(self, answer):
self.answer = answer
self.requests = []
self.sock = socket.socket()
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(16)
self.port = self.sock.getsockname()[1]
threading.Thread(target=self.serve, daemon=True).start()
def serve(self):
while True:
try:
c, _ = self.sock.accept()
except OSError:
return
threading.Thread(target=self.conn, args=(c,), daemon=True).start()
def conn(self, c):
data = b""
c.settimeout(10)
try:
while True:
while b"\r\n\r\n" not in data:
got = c.recv(65536)
if not got:
return
data += got
head, _, data = data.partition(b"\r\n\r\n")
length = 0
for line in head.split(b"\r\n")[1:]:
name, _, value = line.partition(b":")
if name.strip().lower() == b"content-length":
length = int(value.strip())
while len(data) < length:
got = c.recv(65536)
if not got:
return
data += got
body, data = data[:length], data[length:]
self.requests.append((head, body))
c.sendall(self.answer)
except OSError:
pass
finally:
c.close()
def close(self):
self.sock.close()
def session(port, data, until, timeout=5):
"""Sends data at once and reads until `until` answers have come (each
ending with the 'ok' body), or the time is up."""
s = socket.create_connection(("127.0.0.1", port), timeout)
s.sendall(data)
got = b""
deadline = time.time() + timeout
try:
while got.count(b"\r\n\r\nok") < until and time.time() < deadline:
chunk = s.recv(65536)
if not chunk:
break
got += chunk
except OSError:
pass
s.close()
return got
def run(t):
ok = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"
origin = Origin(ok)
proxy = t.free_port()
t.start("proxy_headers", f"""
log
auth iponly
allow *
proxy -p{proxy}
""", ports=[proxy])
base = f"http://127.0.0.1:{origin.port}"
def request(path, extra=b"", body=b"", method=b"GET"):
r = (method + b" " + base.encode() + path + b" HTTP/1.1\r\nHost: 127.0.0.1\r\n" + extra)
if body:
r += b"Content-Length: " + str(len(body)).encode() + b"\r\n"
return r + b"\r\n" + body
# --- a body in the same packet as the headers, headers added and taken
# out on the way ------------------------------------------------------
origin.requests.clear()
body = b"0123456789" * 50
got = session(proxy, request(b"/post", b"Proxy-Connection: keep-alive\r\n", body, b"POST"), 1)
t.contains(got.decode("latin-1"), "200 OK", "a POST with its body in the headers' packet is answered")
head, rbody = origin.requests[0] if origin.requests else (b"", b"")
t.eq(body, rbody, "its body arrives whole, after the headers rewritten in place")
t.ok("the proxy's Forwarded header is added") if b"\r\nForwarded: for=" in head else \
t.fail("the proxy's Forwarded header is added", "Forwarded", head[-200:])
t.ok("and Proxy-Connection taken out") if b"proxy-connection" not in head.lower() else \
t.fail("and Proxy-Connection taken out", "no Proxy-Connection", head[-200:])
# --- a header block bigger than the connection's buffer --------------
origin.requests.clear()
big = b"".join(b"X-Big-%d: %s\r\n" % (i, bytes([65 + i]) * 15000) for i in range(3))
got = session(proxy, request(b"/big", big), 1)
t.contains(got.decode("latin-1"), "200 OK", "a request with 45 KB of headers is answered")
head = origin.requests[0][0] if origin.requests else b""
whole = all((b"X-Big-%d: %s\r\n" % (i, bytes([65 + i]) * 15000)) in head + b"\r\n" for i in range(3))
t.ok("and every big header arrives whole") if whole else \
t.fail("and every big header arrives whole", "3 headers of 15000", len(head))
# --- two requests in one packet, kept alive ---------------------------
origin.requests.clear()
got = session(proxy, request(b"/one", b"Connection: keep-alive\r\n") +
request(b"/two", b"Connection: keep-alive\r\n"), 2)
t.eq(2, got.count(b"\r\n\r\nok"), "two pipelined requests are both answered")
paths = [h.split(b" ")[1] for h, _ in origin.requests]
t.eq([b"/one", b"/two"], paths, "in order, each with its own path")
# --- empty lines before a request -------------------------------------
got = session(proxy, b"\r\n\r\n" + request(b"/after"), 1)
t.contains(got.decode("latin-1"), "200 OK", "a request after empty lines is answered")
# --- an answer with headers bigger than the connection's buffer -------
origin.answer = (b"HTTP/1.1 200 OK\r\nX-Long: " + b"L" * 30000 +
b"\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok")
got = session(proxy, request(b"/longanswer"), 1)
t.ok("an answer with a 30 KB header arrives with it whole") if b"X-Long: " + b"L" * 30000 + b"\r\n" in got else \
t.fail("an answer with a 30 KB header arrives with it whole", "the header", len(got))
t.ok("and its body after it") if got.endswith(b"\r\n\r\nok") else \
t.fail("and its body after it", "ok at the end", got[-40:])
# --- a request too big for any buffer is refused, and the proxy stays --
huge = b"".join(b"X-Huge-%d: %s\r\n" % (i, b"H" * 30000) for i in range(20))
got = session(proxy, request(b"/huge", huge), 1, timeout=3)
t.ok("a 600 KB header block is refused") if b"200 OK" not in got else \
t.fail("a 600 KB header block is refused", "no 200", got[:60])
origin.answer = ok
got = session(proxy, request(b"/again"), 1)
t.contains(got.decode("latin-1"), "200 OK", "and the proxy answers after it")
origin.close()