3proxy/tests/cases/httpsrv_parsing.py
Vladimir Dubrovin 629a978c00
Some checks failed
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Has been cancelled
help: the features of the build listed, and the tests skip what they need
3proxy --help (and the usage message of a bad invocation) now lists
what the build was made with: TLS and its backend, RADIUS, PCRE, IPv6,
UNIX sockets, the built-in HTTP server, transparency, ODBC and the
optional proxy services. The test runner reads the list before the
first case and the cases state the features they need with
t.skip_unless(), so a build without a feature skips its cases instead
of failing them; a binary too old to report anything skips the cases
with needs and runs the rest.
2026-10-30 14:24:46 +03:00

92 lines
3.9 KiB
Python

"""Request parsing: decoding, path safety, malformed and oversized input.
These go over a raw socket, because a well-behaved client would normalise
most of them away before they ever reached the server.
"""
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
t.start("httpsrv_parsing", f"""
log
auth iponly
allow *
http echo * /echo**
http echo * /safe/**
httpsrv -p{srv}
""", ports=[srv])
def request(path, host="t", extra=""):
return t.raw(srv, f"GET {path} HTTP/1.0\r\nHost: {host}\r\n{extra}\r\n")
# --- percent-decoding ---------------------------------------------
reply = request("/%65cho")
t.contains(reply, "200 OK", "a percent-encoded path is decoded before matching")
t.contains(reply, "path=/echo", "the decoded path is what gets reported")
t.contains(request("/echo%20space"), "glob= space",
"an encoded space decodes into the glob")
# --- traversal -----------------------------------------------------
for path in ("/safe/../etc/passwd", "/safe/%2e%2e/etc", "/safe/..%2fetc",
"/echo/../../x"):
t.not_contains(request(path), "200 OK", f"traversal is refused: {path}")
t.contains(request("/safe/./ok"), "200 OK",
"a harmless dot segment is still served")
# --- injection ------------------------------------------------------
t.not_contains(request("/echo%0d%0aInjected:%20yes"), "Injected: yes",
"an encoded CRLF cannot inject a header")
t.not_contains(request("/echo%00cut"), "200 OK", "an encoded NUL is refused")
# a header value cannot smuggle a newline into the echoed output
reply = request("/echo", host="evil", extra="X-Injected: yes\r\n")
t.not_contains(reply, "host=evil\nX-Injected",
"header values stay in their own fields")
# --- malformed ------------------------------------------------------
t.not_contains(t.raw(srv, "GARBAGE\r\n\r\n"), "200 OK",
"a malformed request line is not served")
t.not_contains(t.raw(srv, "GET\r\n\r\n"), "200 OK",
"a request line with no URL is not served")
# an over-long path has to be refused rather than quietly truncated to
# something shorter that might match another rule
t.not_contains(request("/echo" + "a" * 9000), "200 OK",
"an over-long path is refused, not truncated")
# --- methods --------------------------------------------------------
url = f"http://127.0.0.1:{srv}"
t.eq(200, t.http(url + "/echo", method="HEAD").status, "HEAD is accepted")
r = t.http(url + "/echo", method="POST", body="payload=1",
headers={"Content-Type": "application/x-www-form-urlencoded"})
t.contains(r, "method=POST", "POST reaches the handler")
t.contains(r, "content.length=9", "the POST content length is parsed")
# --- dollars in the configuration ------------------------------------
# Outside quotes a dollar begins the name of a file to include, so an
# argument holding one is quoted. Two dollars stand for one, which is how
# a dollar reaches a rule as text.
dsrv = t.free_port()
t.start("httpsrv_dollar", f"""
log
auth iponly
allow *
http redir * /old** 301 "http://example.org/x$$y/$1"
http redir * "pcre:^/re/([a-z]+)$" 302 "http://example.org/re/$1"
http echo * /**
httpsrv -p{dsrv}
""", ports=[dsrv])
durl = f"http://127.0.0.1:{dsrv}"
r = t.http(durl + "/old/a")
t.eq(301, r.status, "a rule holding a doubled dollar loads")
t.eq("http://example.org/x$y//a", r.header("Location"),
"and two dollars reach the location as one")
t.eq(302, t.http(durl + "/re/abc").status,
"a quoted regular expression keeps its anchor")
t.eq(200, t.http(durl + "/re/ab9").status,
"and the anchor is real: what it excludes falls through")