reinvent fail2ban

This commit is contained in:
ed 2022-09-21 22:27:20 +02:00
parent 47a1e6ddfa
commit 32e71a43b8
6 changed files with 123 additions and 2 deletions

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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:

View file

@ -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