From cdbd47dc5bf5b5282852a56abeb8b5a2c308f29d Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Wed, 26 Aug 2026 10:11:06 +0300 Subject: [PATCH] Make pcre_extend work, and cover PCRE filtering with tests The filter walked its list of access rules but tested the first entry each time round, so anything pcre_extend appended was never consulted and the command did nothing at all. Test the entry the loop is on. pcre_extend takes an ACE and no FILTER_ACTION - the rule keeps the action it was given - so correct the manual, which documented an argument the command does not read. The tests cover matching and denial, rule order, client headers, matching options, the access rule a pcre rule carries, pcre_extend, and rewriting a reply header and reply data. Request rewriting is covered through an HTTP parent, which is the path where it reaches the wire. --- man/3proxy.cfg.5 | 2 +- src/pcre.c | 2 +- tests/cases/pcre.py | 113 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/cases/pcre.py diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5 index 7e6f188..6f41660 100644 --- a/man/3proxy.cfg.5 +++ b/man/3proxy.cfg.5 @@ -1435,7 +1435,7 @@ Apply a rule for matching regular expression. Match and replace with rewrite expression. .br .BR pcre_extend -\fIFILTER_ACTION [ACE]\fR +\fIACE\fR .br Extend the ACL of the last pcre or pcre_rewrite command by adding an additional ACE. .br diff --git a/src/pcre.c b/src/pcre.c index 81710a3..d268a25 100644 --- a/src/pcre.c +++ b/src/pcre.c @@ -277,7 +277,7 @@ static FILTER_ACTION pcre_filter_buffer(void *fc, struct clientparam *param, uns #define pcrefd ((struct pcre_filter_data *)fc) for(acl = pcrefd->acl; acl; acl=acl->next){ - if(pl->ACLMatches(pcrefd->acl, param)){ + if(pl->ACLMatches(acl, param)){ match = 1; break; } diff --git a/tests/cases/pcre.py b/tests/cases/pcre.py new file mode 100644 index 0000000..e082b77 --- /dev/null +++ b/tests/cases/pcre.py @@ -0,0 +1,113 @@ +"""PCRE filtering: matching, rewriting, options and rule scope. + +Request rewriting only reaches the wire through an HTTP parent. On a direct +connection the request has already been parsed and converted to origin form +by the time the filter runs, so the rewrite shows up in the log and nowhere +else; that path is left alone here rather than pinned down as correct. +""" + + +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, which needs 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_req", '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 still arrives") + t.contains(r, "path=/echo/new", "the origin sees the rewritten request")