diff --git a/.vscode/launch.py b/.vscode/launch.py index 7c3e443e..2e9e20e7 100755 --- a/.vscode/launch.py +++ b/.vscode/launch.py @@ -30,9 +30,17 @@ except: argv = [os.path.expanduser(x) if x.startswith("~") else x for x in argv] +sfx = "" +if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]): + sfx = sys.argv[1] + sys.argv = [sys.argv[0]] + sys.argv[2:] + argv += sys.argv[1:] -if re.search(" -j ?[0-9]", " ".join(argv)): +if sfx: + argv = [sys.executable, sfx] + argv + sp.check_call(argv) +elif re.search(" -j ?[0-9]", " ".join(argv)): argv = [sys.executable, "-m", "copyparty"] + argv sp.check_call(argv) else: diff --git a/README.md b/README.md index cc13c0f9..04fb8764 100644 --- a/README.md +++ b/README.md @@ -958,7 +958,11 @@ avoid traversing into other filesystems using `--xdev` / volflag `:c,xdev`, ski and/or you can `--xvol` / `:c,xvol` to ignore all symlinks leaving the volume's top directory, but still allow bind-mounts pointing elsewhere -**NB: only affects the indexer** -- users can still access anything inside a volume, unless shadowed by another volume +* symlinks are permitted with `xvol` if they point into another volume where the user has the same level of access + +these options will reduce performance; unlikely worst-case estimates are 14% reduction for directory listings, 35% for download-as-tar + +as of copyparty v1.7.0 these options also prevent file access at runtime -- in previous versions it was just hints for the indexer ### periodic rescan diff --git a/copyparty/__main__.py b/copyparty/__main__.py index e9d2679a..a8aefeec 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -838,6 +838,8 @@ def add_safety(ap, fk_salt): ap2.add_argument("-ss", action="store_true", help="further increase safety: Prevent js-injection, accidental move/delete, broken symlinks, webdav, 404 on 403, ban on excessive 404s.\n └─Alias of\033[32m -s --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 --no-dav --no-logues --no-readme -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 \033[33mUSER\033[0m,\033[33mVOL\033[0m,\033[33mFLAGS\033[0m; example [\033[32m**,*,ln,p,r\033[0m]") + ap2.add_argument("--xvol", action="store_true", help="never follow symlinks leaving the volume root, unless the link is into another volume where the user has similar access (volflag=xvol)") + ap2.add_argument("--xdev", action="store_true", help="stay within the filesystem of the volume root; do not descend into other devices (symlink or bind-mount to another HDD, ...) (volflag=xdev)") ap2.add_argument("--salt", type=u, default="hunter2", help="up2k file-hash salt; serves no purpose, no reason to change this (but delete all databases if you do)") ap2.add_argument("--fk-salt", metavar="SALT", type=u, default=fk_salt, help="per-file accesskey salt; used to generate unpredictable URLs for hidden files -- this one DOES matter") ap2.add_argument("--no-dot-mv", action="store_true", help="disallow moving dotfiles; makes it impossible to move folders containing dotfiles") @@ -931,8 +933,6 @@ def add_db_general(ap, hcores): ap2.add_argument("--no-forget", action="store_true", help="never forget indexed files, even when deleted from disk -- makes it impossible to ever upload the same file twice (volflag=noforget)") ap2.add_argument("--dbd", metavar="PROFILE", default="wal", help="database durability profile; sets the tradeoff between robustness and speed, see --help-dbd (volflag=dbd)") ap2.add_argument("--xlink", action="store_true", help="on upload: check all volumes for dupes, not just the target volume (volflag=xlink)") - ap2.add_argument("--xdev", action="store_true", help="do not descend into other filesystems (symlink or bind-mount to another HDD, ...) (volflag=xdev)") - ap2.add_argument("--xvol", action="store_true", help="skip symlinks leaving the volume root (volflag=xvol)") ap2.add_argument("--hash-mt", metavar="CORES", type=int, default=hcores, help="num cpu cores to use for file hashing; set 0 or 1 for single-core hashing") ap2.add_argument("--re-maxage", metavar="SEC", type=int, default=0, help="disk rescan volume interval, 0=off (volflag=scan)") ap2.add_argument("--db-act", metavar="SEC", type=float, default=10, help="defer any scheduled volume reindexing until SEC seconds after last db write (uploads, renames, ...)") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index beffd29c..4c92ef56 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -285,6 +285,8 @@ class VFS(object): self.vpath = vpath # absolute path in the virtual filesystem self.axs = axs self.flags = flags # config options + self.root = self + self.dev = 0 # st_dev self.nodes: dict[str, VFS] = {} # child nodes self.histtab: dict[str, str] = {} # all realpath->histpath self.dbv: Optional[VFS] = None # closest full/non-jump parent @@ -297,11 +299,17 @@ class VFS(object): self.apget: dict[str, list[str]] = {} if realpath: + rp = realpath + ("" if realpath.endswith(os.sep) else os.sep) + vp = vpath + ("/" if vpath else "") self.histpath = os.path.join(realpath, ".hist") # db / thumbcache self.all_vols = {vpath: self} # flattened recursive + self.all_aps = [(rp, self)] + self.all_vps = [(vp, self)] else: self.histpath = "" self.all_vols = {} + self.all_aps = [] + self.all_vps = [] def __repr__(self) -> str: return "VFS(%s)" % ( @@ -311,12 +319,22 @@ class VFS(object): ) ) - def get_all_vols(self, outdict: dict[str, "VFS"]) -> None: + def get_all_vols( + self, + vols: dict[str, "VFS"], + aps: list[tuple[str, "VFS"]], + vps: list[tuple[str, "VFS"]], + ) -> None: if self.realpath: - outdict[self.vpath] = self + vols[self.vpath] = self + rp = self.realpath + rp += "" if rp.endswith(os.sep) else os.sep + vp = self.vpath + ("/" if self.vpath else "") + aps.append((rp, self)) + vps.append((vp, self)) for v in self.nodes.values(): - v.get_all_vols(outdict) + v.get_all_vols(vols, aps, vps) def add(self, src: str, dst: str) -> "VFS": """get existing, or add new path to the vfs""" @@ -390,7 +408,11 @@ class VFS(object): self, vpath: str, uname: str ) -> tuple[bool, bool, bool, bool, bool, bool]: """can Read,Write,Move,Delete,Get,Upget""" - vn, _ = self._find(undot(vpath)) + if vpath: + vn, _ = self._find(undot(vpath)) + else: + vn = self + c = vn.axs return ( uname in c.uread or "*" in c.uread, @@ -545,6 +567,15 @@ class VFS(object): self.log("vfs.walk", t.format(seen[-1], fsroot, self.vpath, rem), 3) return + if "xdev" in self.flags or "xvol" in self.flags: + rm1 = [] + for le in vfs_ls: + ap = absreal(os.path.join(fsroot, le[0])) + vn2 = self.chk_ap(ap) + if not vn2 or not vn2.get("", uname, True, False): + rm1.append(le) + _ = [vfs_ls.remove(x) for x in rm1] # type: ignore + seen = seen[:] + [fsroot] rfiles = [x for x in vfs_ls if not stat.S_ISDIR(x[1].st_mode)] rdirs = [x for x in vfs_ls if stat.S_ISDIR(x[1].st_mode)] @@ -643,6 +674,44 @@ class VFS(object): for d in [{"vp": v, "ap": a, "st": n} for v, a, n in ret2]: yield d + def chk_ap(self, ap: str, st: Optional[os.stat_result] = None) -> Optional["VFS"]: + aps = ap + os.sep + if "xdev" in self.flags and not ANYWIN: + if not st: + ap2 = ap.replace("\\", "/") if ANYWIN else ap + while ap2: + try: + st = bos.stat(ap2) + break + except: + if "/" not in ap2: + raise + ap2 = ap2.rsplit("/", 1)[0] + assert st + + vdev = self.dev + if not vdev: + vdev = self.dev = bos.stat(self.realpath).st_dev + + if vdev != st.st_dev: + if self.log: + t = "xdev: {}[{}] => {}[{}]" + self.log("vfs", t.format(vdev, self.realpath, st.st_dev, ap), 3) + + return None + + if "xvol" in self.flags: + for vap, vn in self.root.all_aps: + if aps.startswith(vap): + return vn + + if self.log: + self.log("vfs", "xvol: [{}]".format(ap), 3) + + return None + + return self + if WINDOWS: re_vol = re.compile(r"^([a-zA-Z]:[\\/][^:]*|[^:]*):([^:]*):(.*)$") @@ -1069,7 +1138,13 @@ class AuthSrv(object): assert vfs vfs.all_vols = {} - vfs.get_all_vols(vfs.all_vols) + vfs.all_aps = [] + vfs.all_vps = [] + vfs.get_all_vols(vfs.all_vols, vfs.all_aps, vfs.all_vps) + for vol in vfs.all_vols.values(): + vol.all_aps.sort(key=lambda x: len(x[0]), reverse=True) + vol.all_vps.sort(key=lambda x: len(x[0]), reverse=True) + vol.root = vfs for perm in "read write move del get pget".split(): axs_key = "u" + perm diff --git a/copyparty/cfg.py b/copyparty/cfg.py index 85f4c596..cb65c14e 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -107,7 +107,7 @@ flagcats = { "dbd=[acid|swal|wal|yolo]": "database speed-durability tradeoff", "xlink": "cross-volume dupe detection / linking", "xdev": "do not descend into other filesystems", - "xvol": "skip symlinks leaving the volume root", + "xvol": "do not follow symlinks leaving the volume root", "dotsrch": "show dotfiles in search results", "nodotsrch": "hide dotfiles in search results (default)", }, diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index c957782e..024beed5 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -144,17 +144,30 @@ class FtpFs(AbstractedFS): d: bool = False, ) -> tuple[str, VFS, str]: try: - vpath = vpath.replace("\\", "/").lstrip("/") + vpath = vpath.replace("\\", "/").strip("/") rd, fn = os.path.split(vpath) if ANYWIN and relchk(rd): logging.warning("malicious vpath: %s", vpath) - raise FSE("Unsupported characters in filepath", 1) + t = "Unsupported characters in [{}]" + raise FSE(t.format(vpath), 1) fn = sanitize_fn(fn or "", "", [".prologue.html", ".epilogue.html"]) vpath = vjoin(rd, fn) vfs, rem = self.hub.asrv.vfs.get(vpath, self.uname, r, w, m, d) if not vfs.realpath: - raise FSE("No filesystem mounted at this path", 1) + t = "No filesystem mounted at [{}]" + raise FSE(t.format(vpath)) + + if "xdev" in vfs.flags or "xvol" in vfs.flags: + ap = vfs.canonical(rem) + avfs = vfs.chk_ap(ap) + t = "Permission denied in [{}]" + if not avfs: + raise FSE(t.format(vpath), 1) + + cr, cw, cm, cd, _, _ = avfs.can_access("", self.h.uname) + if r and not cr or w and not cw or m and not cm or d and not cd: + raise FSE(t.format(vpath), 1) return os.path.join(vfs.realpath, rem), vfs, rem except Pebkac as ex: @@ -207,10 +220,18 @@ class FtpFs(AbstractedFS): nwd = join(self.cwd, path) vfs, rem = self.hub.asrv.vfs.get(nwd, self.uname, False, False) ap = vfs.canonical(rem) - if not bos.path.isdir(ap): + try: + st = bos.stat(ap) + if not stat.S_ISDIR(st.st_mode): + raise Exception() + except: # returning 550 is library-default and suitable raise FSE("No such file or directory") + avfs = vfs.chk_ap(ap, st) + if not avfs: + raise FSE("Permission denied", 1) + self.cwd = nwd ( self.can_read, @@ -219,16 +240,18 @@ class FtpFs(AbstractedFS): self.can_delete, self.can_get, self.can_upget, - ) = self.hub.asrv.vfs.can_access(self.cwd.lstrip("/"), self.h.uname) + ) = avfs.can_access("", self.h.uname) def mkdir(self, path: str) -> None: ap = self.rv2a(path, w=True)[0] bos.makedirs(ap) # filezilla expects this def listdir(self, path: str) -> list[str]: - vpath = join(self.cwd, path).lstrip("/") + vpath = join(self.cwd, path) try: - vfs, rem = self.hub.asrv.vfs.get(vpath, self.uname, True, False) + ap, vfs, rem = self.v2a(vpath, True, False) + if not bos.path.isdir(ap): + raise FSE("No such file or directory", 1) fsroot, vfs_ls1, vfs_virt = vfs.ls( rem, @@ -249,7 +272,7 @@ class FtpFs(AbstractedFS): if getattr(ex, "severity", 0): raise - if vpath: + if vpath.strip("/"): # display write-only folders as empty return [] @@ -389,7 +412,7 @@ class FtpHandler(FTPHandler): def ftp_STOR(self, file: str, mode: str = "w") -> Any: # Optional[str] vp = join(self.fs.cwd, file).lstrip("/") - ap, vfs, rem = self.fs.v2a(vp) + ap, vfs, rem = self.fs.v2a(vp, w=True) self.vfs_map[ap] = vp xbu = vfs.flags.get("xbu") if xbu and not runhook( diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index a8a316aa..1efab5c0 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -135,6 +135,7 @@ class HttpCli(object): self.ouparam: dict[str, str] = {} self.uparam: dict[str, str] = {} self.cookies: dict[str, str] = {} + self.avn: Optional[VFS] = None self.vpath = " " self.uname = " " self.pw = " " @@ -411,6 +412,13 @@ class HttpCli(object): uparam["b"] = "" cookies["b"] = "" + vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False) + if "xdev" in vn.flags or "xvol" in vn.flags: + ap = vn.canonical(rem) + avn = vn.chk_ap(ap) + else: + avn = vn + ( self.can_read, self.can_write, @@ -418,7 +426,10 @@ class HttpCli(object): self.can_delete, self.can_get, self.can_upget, - ) = self.asrv.vfs.can_access(self.vpath, self.uname) + ) = ( + avn.can_access("", self.uname) if avn else [False] * 6 + ) + self.avn = avn self.s.settimeout(self.args.s_tbody or None) @@ -875,7 +886,7 @@ class HttpCli(object): try: topdir = {"vp": "", "st": bos.stat(tap)} except OSError as ex: - if ex.errno != errno.ENOENT: + if ex.errno not in (errno.ENOENT, errno.ENOTDIR): raise raise Pebkac(404)