3proxy/tests/cases/proxy_headers.py
Vladimir Dubrovin 96adc6d9cd proxy: the line for a parent put back in the buffer, req only logged
A request to a parent HTTP proxy went as two writes, the line as the
client sent it from req and then the rest of the headers from buf,
held together with TCP_CORK, which does not help over TLS nor with
headers bigger than a segment. The line now goes back to absolute form
where it is: a request's header block is read HDRFRONT (300) bytes
into clibuf, room for http://, a host name of 256 characters and a
port; the method moves down into it and the scheme and authority go
in after it, and line and headers are sent in one piece. Where they
do not fit, or the headers are in a buffer of their own by then, both
are put together in one. TCP_CORK is gone.

The scheme and authority of a request are kept for that (auth), from
its line or, for a request with a path only, its Host header; the last
one is what the next request on the connection is compared with. A
CONNECT line is kept whole in buf. The request filters are given the
line in absolute form in a buffer of their own, made only when there
are some, as req was. req is the copy logged and nothing else: at most
REQMAX (6 KB) of the line, "..." after what is cut, never a reason to
refuse the request.

The headers this proxy adds are built in a buffer on the stack, of
bounded parts, and put after the header block once there is room for
them, in a standalone proxy too, which never asked for it. HDRRESERVE
is 1 KB.

Tests: through a parent a POST arrives with its line in absolute form,
its headers and body whole; user and password in the URL; headers in a
buffer of their own (a data filter) put together with the line; a 7 KB
request line served whole and logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:14:35 +03:00

231 lines
11 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()
server = 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:])
# --- through a parent proxy: the line goes back to absolute form, put
# in front of the headers in the buffer, one piece with them -----------
origin.answer = ok
viaparent = t.free_port()
t.start("proxy_headers_parent", f"""
log
auth iponly
allow *
parent 1000 http 127.0.0.1 {origin.port}
proxy -p{viaparent}
""", ports=[viaparent])
origin.requests.clear()
body = b"abcdefghij" * 30
got = session(viaparent, request(b"/viaparent", b"", body, b"POST"), 1)
t.contains(got.decode("latin-1"), "200 OK", "a request through an HTTP parent is answered")
head, rbody = origin.requests[0] if origin.requests else (b"", b"")
want = b"POST " + base.encode() + b"/viaparent HTTP/1.1\r\n"
t.ok("the parent gets the request line in absolute form") if head.startswith(want) else \
t.fail("the parent gets the request line in absolute form", want, head[:80])
t.ok("and the headers after it, with Forwarded added") if b"\r\nHost: 127.0.0.1" in head and b"\r\nForwarded: for=" in head else \
t.fail("and the headers after it, with Forwarded added", "Host, Forwarded", head[:300])
t.eq(body, rbody, "and the body whole")
# user and password in the URL: the line put back is the longer for them
origin.requests.clear()
user, password = "u" * 120, "p" * 120
long = (b"GET http://" + user.encode() + b":" + password.encode() + b"@127.0.0.1:" +
str(origin.port).encode() + b"/longline HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
got = session(viaparent, long, 1)
t.contains(got.decode("latin-1"), "200 OK", "a request with user and password in its URL is answered")
head = origin.requests[0][0] if origin.requests else b""
t.ok("its line arrives in absolute form") if head.startswith(b"GET http://") and b"/longline HTTP/1.1\r\n" in head else \
t.fail("its line arrives in absolute form", "GET http://...", head[:80])
t.ok("with the credentials of the URL as Proxy-Authorization for the parent") if b"\r\nProxy-Authorization: Basic " in head else \
t.fail("with the credentials of the URL as Proxy-Authorization for the parent", "Proxy-Authorization", head[-200:])
# headers which went to a buffer of their own, here for a data filter
# to read the body into the connection's: line and headers are put
# together there, still in one piece
filtered = t.free_port()
t.start("proxy_headers_filtered", f"""
log
auth iponly
allow *
pcre clidata dunno *
parent 1000 http 127.0.0.1 {origin.port}
proxy -p{filtered}
""", ports=[filtered])
origin.requests.clear()
body = b"klmnopqrst" * 20
got = session(filtered, request(b"/filtered", b"", body, b"POST"), 1)
t.contains(got.decode("latin-1"), "200 OK", "a request with a data filter, through a parent, is answered")
head, rbody = origin.requests[0] if origin.requests else (b"", b"")
want = b"POST " + base.encode() + b"/filtered HTTP/1.1\r\n"
t.ok("its line is in absolute form, put together with the headers") if head.startswith(want) and b"\r\nHost: 127.0.0.1" in head else \
t.fail("its line is in absolute form, put together with the headers", want, head[:120])
t.eq(body, rbody, "and its body whole")
# --- a long request line: served whole, logged up to REQMAX (6 KB) ----
origin.answer = ok
origin.requests.clear()
got = session(proxy, request(b"/" + b"q" * 7000), 1)
t.contains(got.decode("latin-1"), "200 OK", "a request line of 7 KB is answered")
head = origin.requests[0][0] if origin.requests else b""
t.ok("and reaches the server whole") if (b"/" + b"q" * 7000 + b" HTTP/1.1") in head else \
t.fail("and reaches the server whole", "7000 q", len(head))
deadline = time.time() + 3
while time.time() < deadline and "q" * 3000 not in server.output():
time.sleep(0.1)
# the log line holds 4000 characters (log.c): cut there before REQMAX
logged = [l for l in server.output().splitlines() if "qqqq" in l]
t.ok("and it is logged, cut") if logged and "q" * 7000 not in logged[-1] else \
t.fail("and it is logged, cut", "a log line", logged[-1][-60:] if logged else None)
# --- 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()