From 32e71a43b89324c500571731d92f79a85b0c87c1 Mon Sep 17 00:00:00 2001 From: ed Date: Wed, 21 Sep 2022 22:27:20 +0200 Subject: [PATCH] reinvent fail2ban --- README.md | 2 ++ copyparty/__main__.py | 4 ++- copyparty/httpcli.py | 37 ++++++++++++++++++++- copyparty/httpsrv.py | 4 +++ copyparty/svchub.py | 1 + copyparty/util.py | 77 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 56494d92..0d54f0f8 100644 --- a/README.md +++ b/README.md @@ -1108,6 +1108,8 @@ some notes on hardening * `--hardlink` creates hardlinks instead of symlinks when deduplicating uploads, which is less maintenance * however note if you edit one file it will also affect the other copies * `--vague-403` returns a "404 not found" instead of "403 forbidden" which is a common enterprise meme + * `--ban-404=50,60,1440` ban client for 1440min (24h) if they hit 50 404's in 60min + * **NB:** will ban anyone who enables up2k turbo * `--nih` removes the server hostname from directory listings * option `-sss` is a shortcut for the above plus: diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 862146cf..ba76c83d 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -628,7 +628,7 @@ def run_argparse(argv: list[str], formatter: Any, retry: bool) -> argparse.Names ap2 = ap.add_argument_group('safety options') ap2.add_argument("-s", action="count", default=0, help="increase safety: Disable thumbnails / potentially dangerous software (ffmpeg/pillow/vips), hide partial uploads, avoid crawlers.\n └─Alias of\033[32m --dotpart --no-thumb --no-mtag-ff --no-robots --force-js") - ap2.add_argument("-ss", action="store_true", help="further increase safety: Prevent js-injection, accidental move/delete, broken symlinks, 404 on 403.\n └─Alias of\033[32m -s --no-dot-mv --no-dot-ren --unpost=0 --no-del --no-mv --hardlink --vague-403 -nih") + ap2.add_argument("-ss", action="store_true", help="further increase safety: Prevent js-injection, accidental move/delete, broken symlinks, 404 on 403, ban on excessive 404s.\n └─Alias of\033[32m -s --no-dot-mv --no-dot-ren --unpost=0 --no-del --no-mv --hardlink --vague-403 --ban-404=50,60,1440 -nih") ap2.add_argument("-sss", action="store_true", help="further increase safety: Enable logging to disk, scan for dangerous symlinks.\n └─Alias of\033[32m -ss -lo=cpp-%%Y-%%m%%d-%%H%%M%%S.txt.xz --ls=**,*,ln,p,r") ap2.add_argument("--ls", metavar="U[,V[,F]]", type=u, help="do a sanity/safety check of all volumes on startup; arguments USER,VOL,FLAGS; example [**,*,ln,p,r]") ap2.add_argument("--salt", type=u, default="hunter2", help="up2k file-hash salt; used to generate unpredictable internal identifiers for uploads -- doesn't really matter") @@ -641,6 +641,8 @@ def run_argparse(argv: list[str], formatter: Any, retry: bool) -> argparse.Names ap2.add_argument("--force-js", action="store_true", help="don't send folder listings as HTML, force clients to use the embedded json instead -- slight protection against misbehaving search engines which ignore --no-robots") ap2.add_argument("--no-robots", action="store_true", help="adds http and html headers asking search engines to not index anything") ap2.add_argument("--logout", metavar="H", type=float, default="8086", help="logout clients after H hours of inactivity (0.0028=10sec, 0.1=6min, 24=day, 168=week, 720=month, 8760=year)") + ap2.add_argument("--ban-pw", metavar="N,W,B", type=u, default="9,60,1440", help="more than N wrong passwords in W minutes = ban for B minutes (disable with \"no\")") + ap2.add_argument("--ban-404", metavar="N,W,B", type=u, default="no", help="hitting more than N 404's in W minutes = ban for B minutes (disabled by default since turbo-up2k counts as 404s)") ap2 = ap.add_argument_group('shutdown options') ap2.add_argument("--ign-ebind", action="store_true", help="continue running even if it's impossible to listen on some of the requested endpoints") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 38de8184..b186eb5a 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -24,6 +24,11 @@ try: except: pass +try: + from ipaddress import IPv6Address +except: + pass + from .__init__ import ANYWIN, PY2, TYPE_CHECKING, EnvParams, unicode from .authsrv import VFS # typechk from .bos import bos @@ -111,6 +116,7 @@ class HttpCli(object): self.u2fh = conn.u2fh # mypy404 self.log_func = conn.log_func # mypy404 self.log_src = conn.log_src # mypy404 + self.bans = conn.hsrv.bans self.gen_fk = self._gen_fk if self.args.log_fk else gen_filekey self.tls: bool = hasattr(self.s, "cipher") @@ -264,6 +270,21 @@ class HttpCli(object): self.log_src = self.conn.set_rproxy(self.ip) + if self.bans: + ip = self.ip + if ":" in ip and not PY2: + ip = IPv6Address(ip).exploded[:-20] + + if ip in self.bans: + ban = self.bans[ip] - time.time() + if ban < 0: + self.log("client unbanned", 3) + del self.bans[ip] + else: + self.log("banned for {:.0f} sec".format(ban), 6) + self.reply(b"thank you for playing", 403) + return False + if self.args.ihead: keys = self.args.ihead if "*" in keys: @@ -466,7 +487,13 @@ class HttpCli(object): headers: Optional[dict[str, str]] = None, volsan: bool = False, ) -> bytes: - # TODO something to reply with user-supplied values safely + if status == 404: + g = self.conn.hsrv.g404 + if g.lim: + bonk, ip = g.bonk(self.ip, self.vpath) + if bonk: + self.log("client banned: 404s", 1) + self.conn.hsrv.bans[ip] = bonk if volsan: vols = list(self.asrv.vfs.all_vols.values()) @@ -1230,6 +1257,14 @@ class HttpCli(object): msg = "login ok" dur = int(60 * 60 * self.args.logout) else: + self.log("invalid password: {}".format(pwd), 3) + g = self.conn.hsrv.gpwd + if g.lim: + bonk, ip = g.bonk(self.ip, pwd) + if bonk: + self.log("client banned: invalid passwords", 1) + self.conn.hsrv.bans[ip] = bonk + msg = "naw dude" pwd = "x" # nosec dur = None diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index d58108f3..b13bc2e7 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -33,6 +33,7 @@ from .bos import bos from .httpconn import HttpConn from .util import ( FHC, + Garda, Magician, min_ex, shut_socket, @@ -69,6 +70,9 @@ class HttpSrv(object): nsuf = "-n{}-i{:x}".format(nid, os.getpid()) if nid else "" self.magician = Magician() + self.bans: dict[str, int] = {} + self.gpwd = Garda(self.args.ban_pw) + self.g404 = Garda(self.args.ban_404) self.name = "hsrv" + nsuf self.mutex = threading.Lock() diff --git a/copyparty/svchub.py b/copyparty/svchub.py index b844a463..b3cec559 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -89,6 +89,7 @@ class SvcHub(object): args.no_mv = True args.hardlink = True args.vague_403 = True + args.ban_404 = "50,60,1440" args.nih = True if args.s: diff --git a/copyparty/util.py b/copyparty/util.py index bc4688e4..05314b93 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -36,6 +36,11 @@ try: except: pass +try: + from ipaddress import IPv6Address +except: + pass + try: HAVE_SQLITE3 = True import sqlite3 # pylint: disable=unused-import # typechk @@ -691,6 +696,78 @@ class Magician(object): raise Exception() +class Garda(object): + """ban clients for repeated offenses""" + + def __init__(self, cfg: str) -> None: + try: + a, b, c = cfg.strip().split(",") + self.lim = int(a) + self.win = int(b) * 60 + self.pen = int(c) * 60 + except: + self.lim = self.win = self.pen = 0 + + self.ct: dict[str, list[int]] = {} + self.prev: dict[str, str] = {} + self.last_cln = 0 + + def cln(self, ip: str) -> None: + n = 0 + ok = int(time.time() - self.win) + for v in self.ct[ip]: + if v < ok: + n += 1 + else: + break + if n: + te = self.ct[ip][n:] + if te: + self.ct[ip] = te + else: + del self.ct[ip] + try: + del self.prev[ip] + except: + pass + + def allcln(self) -> None: + for k in list(self.ct): + self.cln(k) + + self.last_cln = int(time.time()) + + def bonk(self, ip: str, prev: str) -> tuple[int, str]: + if not self.lim: + return 0, ip + + if ":" in ip and not PY2: + # assume /64 clients; drop 4 groups + ip = IPv6Address(ip).exploded[:-20] + + if prev: + if self.prev.get(ip) == prev: + return 0, ip + + self.prev[ip] = prev + + now = int(time.time()) + try: + self.ct[ip].append(now) + except: + self.ct[ip] = [now] + + if now - self.last_cln > 300: + self.allcln() + else: + self.cln(ip) + + if len(self.ct[ip]) >= self.lim: + return now + self.pen, ip + else: + return 0, ip + + if WINDOWS and sys.version_info < (3, 8): _popen = sp.Popen