mirror of
https://github.com/9001/copyparty.git
synced 2025-08-17 09:02:15 -06:00
reinvent fail2ban
This commit is contained in:
parent
47a1e6ddfa
commit
32e71a43b8
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue