3proxy/tests/cases/pcre.py
Vladimir Dubrovin fdd303ee32 Apply a request rewrite to what the server is sent
A rewrite only reached the copy of the request kept for logging. On a
direct connection the server is sent the request line held in the header
buffer, which was parsed and reduced to its path before the filters ran, so
the rewrite showed up in the log and nowhere else. Through an HTTP parent
the rewritten copy is what goes out, which is why it appeared to work
there.

Splice the new path back into that buffer. The destination was chosen, and
the access rules applied to it, before the rewrite happened, so a rewrite
that changes the method or the authority is left alone: acting on it would
send the request somewhere the rules never saw. Say so in the manual.

The copy needed to notice a rewrite is only taken when a request filter
exists, so a proxy without one does no extra work.
2026-08-26 10:28:02 +03:00

175 lines
7.9 KiB
Python

"""PCRE filtering: matching, rewriting, options and rule scope.
A request rewrite is applied to the buffer the server is sent, so it works
on a direct connection as well as through a parent. The destination was
chosen, and the access rules applied to it, before the filter ran, so a
rewrite that moves the request to another host or changes the method is
ignored rather than acted on.
"""
def _has_pcre(t):
"""Whether this build accepts the pcre commands at all.
The last line is nonsense on purpose: it makes 3proxy report and exit
instead of waiting, and what it says about the line above is the answer.
"""
out = t.run_config("pcre_probe",
'log\npcre request deny "x"\nnot_a_command\n')
return "'pcre'" not in out
def run(t):
if not _has_pcre(t):
t.skip("PCRE (this build has no PCRE support)")
return
origin = t.free_port()
t.start("pcre_origin", f"""
log
auth iponly
allow *
http * /echo* echo
http * /secret* echo
http * /data data
httpsrv -p{origin}
""", ports=[origin])
url = f"http://127.0.0.1:{origin}"
def proxy_with(name, *rules):
port = t.free_port()
t.start(name, "\n".join([
"log", "flush", "auth iponly", "allow *", *rules, f"proxy -p{port}"]),
ports=[port])
return f"127.0.0.1:{port}"
# --- matching and denial ---------------------------------------------
p = proxy_with("deny", 'pcre request deny "/secret"')
t.eq(200, t.http(url + "/echo", proxy=p).status, "an unmatched request passes")
t.ne(200, t.http(url + "/secret/page", proxy=p).status, "a matched request is denied")
# the rules are ordered, and the first decision wins
p = proxy_with("allow_first", 'pcre request allow "/echo"', 'pcre request deny "/"')
t.eq(200, t.http(url + "/echo", proxy=p).status, "allow short-circuits a later deny")
p = proxy_with("deny_first", 'pcre request deny "/"', 'pcre request allow "/echo"')
t.ne(200, t.http(url + "/echo", proxy=p).status, "deny short-circuits a later allow")
# --- what the pattern is matched against ------------------------------
p = proxy_with("cliheader", 'pcre cliheader deny "BadBot"')
t.eq(200, t.http(url + "/echo", proxy=p).status, "a header rule ignores other requests")
t.ne(200, t.http(url + "/echo", proxy=p, headers={"User-Agent": "BadBot/1.0"}).status,
"a client header can be matched")
# --- options ------------------------------------------------------------
p = proxy_with("caseless", "pcre_options PCRE2_CASELESS",
'pcre request deny "/SECRET"')
t.ne(200, t.http(url + "/secret/page", proxy=p).status,
"PCRE2_CASELESS makes the match case-insensitive")
p = proxy_with("cased", 'pcre request deny "/SECRET"')
t.eq(200, t.http(url + "/secret/page", proxy=p).status,
"without it the match is case-sensitive")
# --- the access rule a pcre rule carries --------------------------------
p = proxy_with("ace_here", f'pcre request deny "/echo" * * * {origin}')
t.ne(200, t.http(url + "/echo", proxy=p).status,
"a rule applies where its access rule matches")
p = proxy_with("ace_elsewhere", 'pcre request deny "/echo" * * * 1')
t.eq(200, t.http(url + "/echo", proxy=p).status,
"and not where it does not")
# pcre_extend appends another access rule to the one just defined
p = proxy_with("extend", 'pcre request deny "/echo" * * * 1',
f"pcre_extend * * * {origin}")
t.ne(200, t.http(url + "/echo", proxy=p).status,
"pcre_extend widens the rule to another destination")
p = proxy_with("extend_other", 'pcre request deny "/echo" * * * 1',
"pcre_extend * * * 2")
t.eq(200, t.http(url + "/echo", proxy=p).status,
"an extension that matches nothing changes nothing")
# --- rewriting the reply ------------------------------------------------
p = proxy_with("rewrite_srv",
'pcre_rewrite srvheader dunno "text/plain" "text/rewritten"',
'pcre_rewrite srvdata dunno "peer.addr" "PEER.ADDR"')
r = t.http(url + "/echo", proxy=p)
t.eq(200, r.status, "a rewritten reply still arrives")
t.eq("text/rewritten", r.header("Content-Type"), "a reply header can be rewritten")
t.contains(r, "PEER.ADDR", "reply data can be rewritten")
t.not_contains(r, "peer.addr", "the original text is gone")
# --- rewriting the request ------------------------------------------------
p = proxy_with("rewrite_req", 'pcre_rewrite request dunno "/echo/old" "/echo/new"')
r = t.http(url + "/echo/old", proxy=p)
t.eq(200, r.status, "a rewritten request still arrives")
t.contains(r, "path=/echo/new", "the origin sees the rewritten path")
# the replacement may be longer or shorter than what it replaces
p = proxy_with("rewrite_long", 'pcre_rewrite request dunno "/echo/x" "/echo/deeper/still"')
t.contains(t.http(url + "/echo/x", proxy=p), "path=/echo/deeper/still",
"a longer replacement is spliced in")
p = proxy_with("rewrite_short", 'pcre_rewrite request dunno "/echo/aaaaaaaaaa" "/echo/b"')
t.contains(t.http(url + "/echo/aaaaaaaaaa", proxy=p), "path=/echo/b",
"a shorter replacement is spliced in")
p = proxy_with("rewrite_query", 'pcre_rewrite request dunno "token=old" "token=new"')
t.contains(t.http(url + "/echo?token=old", proxy=p), "query=token=new",
"the query can be rewritten")
p = proxy_with("rewrite_none", 'pcre_rewrite request dunno "/nothing" "/else"')
t.contains(t.http(url + "/echo/keep", proxy=p), "path=/echo/keep",
"a request that does not match is left alone")
# what follows the request line has to survive the splice
p = proxy_with("rewrite_post", 'pcre_rewrite request dunno "/echo/old" "/echo/new"')
r = t.http(url + "/echo/old", proxy=p, method="POST", body="hello",
headers={"Content-Type": "text/plain"})
t.contains(r, "path=/echo/new", "a POST is rewritten too")
t.contains(r, "content.length=5", "its body is still described correctly")
conn = t.connection("127.0.0.1", origin, proxy=p)
try:
first = t.http(url + "/echo/old", proxy=p, conn=conn)
second = t.http(url + "/echo/old", proxy=p, conn=conn)
t.contains(first, "path=/echo/new", "the first of two on a connection is rewritten")
t.contains(second, "path=/echo/new", "and so is the second")
finally:
conn.close()
# --- rewrites that would change where the request goes --------------------
elsewhere = t.free_port()
t.start("pcre_elsewhere", f"""
log
flush
auth iponly
allow *
http * /echo* echo
httpsrv -p{elsewhere}
""", ports=[elsewhere])
p = proxy_with("rewrite_host",
f'pcre_rewrite request dunno "127.0.0.1:{origin}" "127.0.0.1:{elsewhere}"')
r = t.http(url + "/echo", proxy=p)
t.eq(200, r.status, "a rewrite naming another host still answers")
t.contains(r, f"host=127.0.0.1:{origin}",
"but the request goes where the access rules allowed")
p = proxy_with("rewrite_method", 'pcre_rewrite request dunno "^GET" "HEAD"')
t.contains(t.http(url + "/echo", proxy=p), "method=GET",
"a rewrite of the method is ignored")
# --- and the same rewrite through an HTTP parent --------------------------
parent = t.free_port()
t.start("pcre_parent", f"""
log
flush
auth iponly
allow *
proxy -p{parent}
""", ports=[parent])
p = proxy_with("rewrite_parent", 'pcre_rewrite request dunno "/echo/old" "/echo/new"',
f"parent 1000 http 127.0.0.1 {parent}")
r = t.http(url + "/echo/old", proxy=p)
t.eq(200, r.status, "a rewritten request through a parent arrives")
t.contains(r, "path=/echo/new", "the origin sees the rewritten path through a parent")