From 50e01d69046c6598c063f895a2c9cb9a09747aa7 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 26 Aug 2023 13:52:24 +0000 Subject: [PATCH] add more autoban triggers: * --ban-url: URLs which 404 and also match --sus-urls (bot-scan) * --ban-403: trying to access volumes that dont exist or require auth * --ban-422: invalid POST messages, fuzzing and such * --nonsus-urls: regex of 404s which shouldn't trigger --ban-404 in may situations it makes sense to handle this logic inside copyparty, since stuff like cloudflare and running copyparty on another physical box than the nginx frontend is on becomes fairly clunky --- copyparty/__main__.py | 7 ++++++- copyparty/httpcli.py | 30 ++++++++++++++++++++++++------ copyparty/httpsrv.py | 3 +++ copyparty/svchub.py | 25 ++++++++++++++++++++----- copyparty/util.py | 7 ++++--- tests/util.py | 2 ++ 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index fd397368..30dab530 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -959,7 +959,7 @@ def add_hooks(ap): ap2.add_argument("--xbd", metavar="CMD", type=u, action="append", help="execute CMD before a file delete") ap2.add_argument("--xad", metavar="CMD", type=u, action="append", help="execute CMD after a file delete") ap2.add_argument("--xm", metavar="CMD", type=u, action="append", help="execute CMD on message") - ap2.add_argument("--xban", metavar="CMD", type=u, action="append", help="execute CMD if someone gets banned (pw/404)") + ap2.add_argument("--xban", metavar="CMD", type=u, action="append", help="execute CMD if someone gets banned (pw/404/403/url)") def add_stats(ap): @@ -1010,6 +1010,11 @@ def add_safety(ap): ap2.add_argument("--logout", metavar="H", type=float, default="8086", help="logout clients after H hours of inactivity; [\033[32m0.0028\033[0m]=10sec, [\033[32m0.1\033[0m]=6min, [\033[32m24\033[0m]=day, [\033[32m168\033[0m]=week, [\033[32m720\033[0m]=month, [\033[32m8760\033[0m]=year)") ap2.add_argument("--ban-pw", metavar="N,W,B", type=u, default="9,60,1440", help="more than \033[33mN\033[0m wrong passwords in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes; disable with [\033[32mno\033[0m]") ap2.add_argument("--ban-404", metavar="N,W,B", type=u, default="no", help="hitting more than \033[33mN\033[0m 404's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes (disabled by default since turbo-up2k counts as 404s)") + ap2.add_argument("--ban-403", metavar="N,W,B", type=u, default="9,2,1440", help="hitting more than \033[33mN\033[0m 403's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes; [\033[32m1440\033[0m]=day, [\033[32m10080\033[0m]=week, [\033[32m43200\033[0m]=month") + ap2.add_argument("--ban-422", metavar="N,W,B", type=u, default="9,2,1440", help="hitting more than \033[33mN\033[0m 422's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes (422 is server fuzzing, invalid POSTs and so)") + ap2.add_argument("--ban-url", metavar="N,W,B", type=u, default="9,2,1440", help="hitting more than \033[33mN\033[0m sus URL's in \033[33mW\033[0m minutes = ban for \033[33mB\033[0m minutes (decent replacement for --ban-404 if that can't be used)") + ap2.add_argument("--sus-urls", metavar="REGEX", type=u, default=r"\.php$|(^|/)wp-(admin|content|includes)/", help="URLs which are considered sus / eligible for banning; disable with blank or [\033[32mno\033[0m]") + ap2.add_argument("--nonsus-urls", metavar="REGEX", type=u, default=r"^(favicon\.ico|robots\.txt)$|^apple-touch-icon|^\.well-known", help="harmless URLs ignored from 404-bans; disable with blank or [\033[32mno\033[0m]") ap2.add_argument("--aclose", metavar="MIN", type=int, default=10, help="if a client maxes out the server connection limit, downgrade it from connection:keep-alive to connection:close for MIN minutes (and also kill its active connections) -- disable with 0") ap2.add_argument("--loris", metavar="B", type=int, default=60, help="if a client maxes out the server connection limit without sending headers, ban it for B minutes; disable with [\033[32m0\033[0m]") ap2.add_argument("--acao", metavar="V[,V]", type=u, default="*", help="Access-Control-Allow-Origin; list of origins (domains/IPs without port) to accept requests from; [\033[32mhttps://1.2.3.4\033[0m]. Default [\033[32m*\033[0m] allows requests from all sites but removes cookies and http-auth; only ?pw=hunter2 survives") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index beb5c3fc..8b570151 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -625,9 +625,27 @@ class HttpCli(object): headers: Optional[dict[str, str]] = None, volsan: bool = False, ) -> bytes: - if status == 404: - g = self.conn.hsrv.g404 - if g.lim: + if status > 400 and status in (403, 404, 422): + if status == 404: + g = self.conn.hsrv.g404 + elif status == 403: + g = self.conn.hsrv.g403 + else: + g = self.conn.hsrv.g422 + + gurl = self.conn.hsrv.gurl + if ( + gurl.lim + and (not g.lim or gurl.lim < g.lim) + and self.args.sus_urls.search(self.vpath) + ): + g = self.conn.hsrv.gurl + + if g.lim and ( + g == self.conn.hsrv.g422 + or not self.args.nonsus_urls + or not self.args.nonsus_urls.search(self.vpath) + ): bonk, ip = g.bonk(self.ip, self.vpath) if bonk: xban = self.vn.flags.get("xban") @@ -642,9 +660,9 @@ class HttpCli(object): 0, self.ip, time.time(), - "404", + str(status), ): - self.log("client banned: 404s", 1) + self.log("client banned: %ss" % (status,), 1) self.conn.hsrv.bans[ip] = bonk if volsan: @@ -3260,7 +3278,7 @@ class HttpCli(object): dst = "" elif top: if not dst.startswith(top + "/"): - raise Pebkac(400, "arg funk") + raise Pebkac(422, "arg funk") dst = dst[len(top) + 1 :] diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index ca9430d4..dbfa17a8 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -107,6 +107,9 @@ class HttpSrv(object): self.ssdp: Optional["SSDPr"] = None self.gpwd = Garda(self.args.ban_pw) self.g404 = Garda(self.args.ban_404) + self.g403 = Garda(self.args.ban_403) + self.g422 = Garda(self.args.ban_422, False) + self.gurl = Garda(self.args.ban_url) self.bans: dict[str, int] = {} self.aclose: dict[str, int] = {} diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 0c669ffd..1c41cb72 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -100,11 +100,6 @@ class SvcHub(object): self.iphash = HMaccas(os.path.join(self.E.cfg, "iphash"), 8) - # for non-http clients (ftp) - self.bans: dict[str, int] = {} - self.gpwd = Garda(self.args.ban_pw) - self.g404 = Garda(self.args.ban_404) - if args.sss or args.s >= 3: args.ss = True args.no_dav = True @@ -134,6 +129,14 @@ class SvcHub(object): if not self._process_config(): raise Exception(BAD_CFG) + # for non-http clients (ftp) + self.bans: dict[str, int] = {} + self.gpwd = Garda(self.args.ban_pw) + self.g404 = Garda(self.args.ban_404) + self.g403 = Garda(self.args.ban_403) + self.g422 = Garda(self.args.ban_422) + self.gurl = Garda(self.args.ban_url) + self.log_div = 10 ** (6 - args.log_tdec) self.log_efmt = "%02d:%02d:%02d.%0{}d".format(args.log_tdec) self.log_dfmt = "%04d-%04d-%06d.%0{}d".format(args.log_tdec) @@ -400,6 +403,18 @@ class SvcHub(object): if vs and vs.startswith("~"): setattr(al, k, os.path.expanduser(vs)) + for k in "sus_urls nonsus_urls".split(" "): + vs = getattr(al, k) + if not vs or vs == "no": + setattr(al, k, None) + else: + setattr(al, k, re.compile(vs)) + + if not al.sus_urls: + al.ban_url = "no" + elif al.ban_url == "no": + al.sus_urls = None + return True def _setlimits(self) -> None: diff --git a/copyparty/util.py b/copyparty/util.py index 1221675a..2c2666bb 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -926,7 +926,8 @@ class Magician(object): class Garda(object): """ban clients for repeated offenses""" - def __init__(self, cfg: str) -> None: + def __init__(self, cfg: str, uniq: bool = True) -> None: + self.uniq = uniq try: a, b, c = cfg.strip().split(",") self.lim = int(a) @@ -972,7 +973,7 @@ class Garda(object): # assume /64 clients; drop 4 groups ip = IPv6Address(ip).exploded[:-20] - if prev: + if prev and self.uniq: if self.prev.get(ip) == prev: return 0, ip @@ -1447,7 +1448,7 @@ class MultipartParser(object): for buf in iterable: ret += buf if len(ret) > max_len: - raise Pebkac(400, "field length is too long") + raise Pebkac(422, "field length is too long") return ret diff --git a/tests/util.py b/tests/util.py index 5cbb35d7..c51406c8 100644 --- a/tests/util.py +++ b/tests/util.py @@ -189,6 +189,8 @@ class VHttpSrv(object): self.gpwd = Garda("") self.g404 = Garda("") + self.g403 = Garda("") + self.gurl = Garda("") self.ptn_cc = re.compile(r"[\x00-\x1f]")