3proxy/tests/cases/httpsrv_parsing.py

90 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):
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")