From 62e072a2ed14a96386509581616fde1545454480 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 15 Aug 2025 20:12:17 +0000 Subject: [PATCH] restrict account to ip/subnet; closes #397 --- README.md | 15 +++++++++++++++ copyparty/__main__.py | 2 ++ copyparty/authsrv.py | 1 + copyparty/ftpd.py | 4 ++++ copyparty/httpcli.py | 10 ++++++++-- copyparty/httpsrv.py | 6 ++++++ copyparty/svchub.py | 8 ++++++++ copyparty/util.py | 21 +++++++++++++++++++++ tests/util.py | 2 +- 9 files changed, 66 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f6ed75c6..1d143b6d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ made in Norway 🇳🇴 * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/)) * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/)) * [ip auth](#ip-auth) - autologin based on IP range (CIDR) + * [restrict to ip](#restrict-to-ip) - limit a user to certain IP ranges (CIDR) * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such * [generic header auth](#generic-header-auth) - other ways to auth by header * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords @@ -1897,6 +1898,20 @@ repeat the option to map additional subnets **be careful with this one!** if you have a reverseproxy, then you definitely want to make sure you have [real-ip](#real-ip) configured correctly, and it's probably a good idea to nullmap the reverseproxy's IP just in case; so if your reverseproxy is sending requests from `172.24.27.9` then that would be `--ipu=172.24.27.9/32=` +### restrict to ip + +limit a user to certain IP ranges (CIDR) , using the global-option `--ipr` + +for example, if the user `spartacus` should get rejected if they're not connecting from an IP that starts with `192.168.123` or `172.16`, then you can either specify `--ipr=192.168.123.0/24,172.16.0.0/16=spartacus` as a commandline option, or put this in a config file: + +```yaml +[global] + ipr: 192.168.123.0/24,172.16.0.0/16=spartacus +``` + +repeat the option to map additional users + + ## identity providers replace copyparty passwords with oauth and such diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 3c89af99..e732369b 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1229,7 +1229,9 @@ def add_auth(ap): ap2.add_argument("--no-ses", action="store_true", help="disable sessions; use plaintext passwords in cookies") ap2.add_argument("--grp-all", metavar="NAME", type=u, default="acct", help="the name of the auto-generated group which contains every username which is known") ap2.add_argument("--ipu", metavar="CIDR=USR", type=u, action="append", help="\033[34mREPEATABLE:\033[0m users with IP matching \033[33mCIDR\033[0m are auto-authenticated as username \033[33mUSR\033[0m; example: [\033[32m172.16.24.0/24=dave]") + ap2.add_argument("--ipr", metavar="CIDR=USR", type=u, action="append", help="\033[34mREPEATABLE:\033[0m username \033[33mUSR\033[0m can only connect from an IP matching one or more \033[33mCIDR\033[0m (comma-sep.); example: [\033[32m192.168.123.0/24,172.16.0.0/16=dave]") ap2.add_argument("--have-idp-hdrs", type=u, default="", help=argparse.SUPPRESS) + ap2.add_argument("--have-ipu-or-ipr", type=u, default="", help=argparse.SUPPRESS) def add_chpw(ap): diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 09c845f8..5329ede1 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -1690,6 +1690,7 @@ class AuthSrv(object): raise self.args.have_idp_hdrs = bool(self.args.idp_h_usr or self.args.idp_hm_usr) + self.args.have_ipu_or_ipr = bool(self.args.ipu or self.args.ipr) self.setup_pwhash(acct) defpw = acct.copy() diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 99b37d26..14f8d5b6 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -96,6 +96,10 @@ class FtpAuth(DummyAuthorizer): if args.ipu and uname == "*": uname = args.ipu_iu[args.ipu_nm.map(ip)] + if args.ipr and uname in args.ipr_u: + if not args.ipr_u[uname].map(ip): + logging.warning("username [%s] rejected by --ipr", uname) + uname = "*" if not uname or not (asrv.vfs.aread.get(uname) or asrv.vfs.awrite.get(uname)): g = self.hub.gpwd diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 451f6afe..4710feaf 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -695,8 +695,14 @@ class HttpCli(object): else: self.log("unknown username: %r" % (idp_usr,), 1) - if self.args.ipu and self.uname == "*": - self.uname = self.conn.ipu_iu[self.conn.ipu_nm.map(self.ip)] + if self.args.have_ipu_or_ipr: + if self.args.ipu and self.uname == "*": + self.uname = self.conn.ipu_iu[self.conn.ipu_nm.map(self.ip)] + ipr = self.conn.hsrv.ipr + if ipr and self.uname in ipr: + if not ipr[self.uname].map(self.ip): + self.log("username [%s] rejected by --ipr" % (self.uname,), 3) + self.uname = "*" self.rvol = self.asrv.vfs.aread[self.uname] self.wvol = self.asrv.vfs.awrite[self.uname] diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index ddf5e7bc..77492d56 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -70,6 +70,7 @@ from .util import ( build_netmap, has_resource, ipnorm, + load_ipr, load_ipu, load_resource, min_ex, @@ -193,6 +194,11 @@ class HttpSrv(object): else: self.ipu_iu = self.ipu_nm = None + if self.args.ipr: + self.ipr = load_ipr(self.log, self.args.ipr) + else: + self.ipr = None + self.ipa_nm = build_netmap(self.args.ipa) self.xff_nm = build_netmap(self.args.xff_src) self.xff_lan = build_netmap("lan") diff --git a/copyparty/svchub.py b/copyparty/svchub.py index f2737b06..7b06d96c 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -66,6 +66,7 @@ from .util import ( build_netmap, expat_ver, gzip, + load_ipr, load_ipu, lock_file, min_ex, @@ -259,6 +260,10 @@ class SvcHub(object): setattr(args, "ipu_iu", iu) setattr(args, "ipu_nm", nm) + if args.ipr: + ipr = load_ipr(self.log, args.ipr, True) + setattr(args, "ipr_u", ipr) + for zs in "ah_salt fk_salt dk_salt".split(): if getattr(args, "show_%s" % (zs,)): self.log("root", "effective %s is %s" % (zs, getattr(args, zs))) @@ -432,6 +437,9 @@ class SvcHub(object): getattr(args, zs).mutex = threading.Lock() except: pass + if args.ipr: + for nm in args.ipr_u.values(): + nm.mutex = threading.Lock() def _db_onfail_ses(self) -> None: self.args.no_ses = True diff --git a/copyparty/util.py b/copyparty/util.py index 1578d291..03517785 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -2954,6 +2954,27 @@ def load_ipu( return ip_u, nm +def load_ipr( + log: "RootLogger", iprs: list[str], defer_mutex: bool = False +) -> dict[str, NetMap]: + ret = {} + for ipr in iprs: + try: + zs, uname = ipr.split("=") + cidrs = zs.split(",") + except: + t = "\n invalid value %r for argument --ipr; must be CIDR[,CIDR[,...]]=UNAME (192.168.0.0/16=amelia)" + raise Exception(t % (ipr,)) + try: + nm = NetMap(["::"], cidrs, True, True, defer_mutex) + except Exception as ex: + t = "failed to translate --ipr into netmap, probably due to invalid config: %r" + log("root", t % (ex,), 1) + raise + ret[uname] = nm + return ret + + def yieldfile(fn: str, bufsz: int) -> Generator[bytes, None, None]: readsz = min(bufsz, 128 * 1024) with open(fsenc(fn), "rb", bufsz) as f: diff --git a/tests/util.py b/tests/util.py index 8f4cd0e9..16a0132a 100644 --- a/tests/util.py +++ b/tests/util.py @@ -170,7 +170,7 @@ class Cfg(Namespace): ex = "ban_403 ban_404 ban_422 ban_pw ban_pwc ban_url spinner" ka.update(**{k: "no" for k in ex.split()}) - ex = "ext_th grp idp_h_usr idp_hm_usr on403 on404 xac xad xar xau xban xbc xbd xbr xbu xiu xm" + ex = "ext_th grp idp_h_usr idp_hm_usr ipr on403 on404 xac xad xar xau xban xbc xbd xbr xbu xiu xm" ka.update(**{k: [] for k in ex.split()}) ex = "exp_lg exp_md"