diff --git a/README.md b/README.md index fc406f4d..35b17015 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [security](#security) - there is a [discord server](https://discord.gg/25J8CdTT6G) * [gotchas](#gotchas) - behavior that might be unexpected * [cors](#cors) - cross-site request config + * [filekeys](#filekeys) - prevent filename bruteforcing * [password hashing](#password-hashing) - you can hash passwords * [https](#https) - both HTTP and HTTPS are accepted * [recovering from crashes](#recovering-from-crashes) @@ -356,7 +357,7 @@ permissions: * `m` (move): move files/folders *from* this folder * `d` (delete): delete files/folders * `g` (get): only download files, cannot see folder contents or zip/tar -* `G` (upget): same as `g` except uploaders get to see their own filekeys (see `fk` in examples below) +* `G` (upget): same as `g` except uploaders get to see their own [filekeys](#filekeys) (see `fk` in examples below) * `h` (html): same as `g` except folders return their index.html, and filekeys are not necessary for index.html * `a` (admin): can see uploader IPs, config-reload @@ -370,7 +371,7 @@ examples: * `u1` can open the `inc` folder, but cannot see the contents, only upload new files to it * `u2` can browse it and move files *from* `/inc` into any folder where `u2` has write-access * make folder `/mnt/ss` available at `/i`, read-write for u1, get-only for everyone else, and enable filekeys: `-v /mnt/ss:i:rw,u1:g:c,fk=4` - * `c,fk=4` sets the `fk` (filekey) volflag to 4, meaning each file gets a 4-character accesskey + * `c,fk=4` sets the `fk` ([filekey](#filekeys)) volflag to 4, meaning each file gets a 4-character accesskey * `u1` can upload files, browse the folder, and see the generated filekeys * other users cannot browse the folder, but can access the files if they have the full file URL with the filekey * replacing the `g` permission with `wg` would let anonymous users upload files, but not see the required filekey to access it @@ -1647,9 +1648,7 @@ safety profiles: other misc notes: * you can disable directory listings by giving permission `g` instead of `r`, only accepting direct URLs to files - * combine this with volflag `c,fk` to generate filekeys (per-file accesskeys); users which have full read-access will then see URLs with `?k=...` appended to the end, and `g` users must provide that URL including the correct key to avoid a 404 - * the default filekey entropy is fairly small so give `--fk-salt` around 30 characters if you want filekeys longer than 16 chars - * permissions `wG` lets users upload files and receive their own filekeys, still without being able to see other uploads + * you may want [filekeys](#filekeys) to prevent filename bruteforcing * permission `h` instead of `r` makes copyparty behave like a traditional webserver with directory listing/index disabled, returning index.html instead * compatibility with filekeys: index.html itself can be retrieved without the correct filekey, but all other files are protected @@ -1679,6 +1678,17 @@ by default, except for `GET` and `HEAD` operations, all requests must either: cors can be configured with `--acao` and `--acam`, or the protections entirely disabled with `--allow-csrf` +## filekeys + +prevent filename bruteforcing + +volflag `c,fk` generates filekeys (per-file accesskeys) for all files; users which have full read-access (permission `r`) will then see URLs with the correct filekey `?k=...` appended to the end, and `g` users must provide that URL including the correct key to avoid a 404 + +by default, filekeys are generated based on salt (`--fk-salt`) + filesystem-path + file-size + inode (if not windows); add volflag `fka` to generate slightly weaker filekeys which will not be invalidated if the file is edited (only salt + path) + +permissions `wG` (write + upget) lets users upload files and receive their own filekeys, still without being able to see other uploads + + ## password hashing you can hash passwords before putting them into config files / providing them as arguments; see `--help-pwhash` for all the details diff --git a/copyparty/__main__.py b/copyparty/__main__.py index f9d0fd09..691c0009 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -246,12 +246,7 @@ def get_fk_salt(cert_path) -> str: with open(fp, "rb") as f: ret = f.read().strip() except: - if os.path.exists(cert_path): - zi = os.path.getmtime(cert_path) - ret = "{}".format(zi).encode("utf-8") - else: - ret = base64.b64encode(os.urandom(18)) - + ret = base64.b64encode(os.urandom(18)) with open(fp, "wb") as f: f.write(ret + b"\n") diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 4fff6a4a..a332e3f9 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -12,7 +12,7 @@ import threading import time from datetime import datetime -from .__init__ import ANYWIN, TYPE_CHECKING, WINDOWS +from .__init__ import ANYWIN, E, TYPE_CHECKING, WINDOWS from .bos import bos from .cfg import flagdescs, permdescs, vf_bmap, vf_cmap, vf_vmap from .pwhash import PWHash @@ -1390,6 +1390,9 @@ class AuthSrv(object): have_fk = False for vol in vfs.all_vols.values(): fk = vol.flags.get("fk") + fka = vol.flags.get("fka") + if fka and not fk: + fk = fka if fk: vol.flags["fk"] = int(fk) if fk is not True else 8 have_fk = True @@ -1397,6 +1400,12 @@ class AuthSrv(object): if have_fk and re.match(r"^[0-9\.]+$", self.args.fk_salt): self.log("filekey salt: {}".format(self.args.fk_salt)) + fk_len = len(self.args.fk_salt) + if have_fk and fk_len < 14: + t = "WARNING: filekeys are enabled, but the salt is only %d chars long; %d or longer is recommended. Either specify a stronger salt using --fk-salt or delete this file and restart copyparty: %s" + zs = os.path.join(E.cfg, "fk-salt.txt") + self.log(t % (fk_len, 16, zs), 3) + for vol in vfs.all_vols.values(): if "pk" in vol.flags and "gz" not in vol.flags and "xz" not in vol.flags: vol.flags["gz"] = False # def.pk diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index c247f50f..9b1dcc06 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -202,8 +202,10 @@ class HttpCli(object): if rem.startswith("/") or rem.startswith("../") or "/../" in rem: raise Exception("that was close") - def _gen_fk(self, salt: str, fspath: str, fsize: int, inode: int) -> str: - return gen_filekey_dbg(salt, fspath, fsize, inode, self.log, self.args.log_fk) + def _gen_fk(self, alg: int, salt: str, fspath: str, fsize: int, inode: int) -> str: + return gen_filekey_dbg( + alg, salt, fspath, fsize, inode, self.log, self.args.log_fk + ) def j2s(self, name: str, **ka: Any) -> str: tpl = self.conn.hsrv.j2[name] @@ -1712,7 +1714,9 @@ class HttpCli(object): vsuf = "" if (self.can_read or self.can_upget) and "fk" in vfs.flags: + alg = 2 if "fka" in vfs.flags else 1 vsuf = "?k=" + self.gen_fk( + alg, self.args.fk_salt, path, post_sz, @@ -2445,7 +2449,9 @@ class HttpCli(object): for sz, sha_hex, sha_b64, ofn, lfn, ap in files: vsuf = "" if (self.can_read or self.can_upget) and "fk" in vfs.flags: + alg = 2 if "fka" in vfs.flags else 1 vsuf = "?k=" + self.gen_fk( + alg, self.args.fk_salt, ap, sz, @@ -3412,7 +3418,7 @@ class HttpCli(object): t0 = time.time() lim = time.time() - self.args.unpost fk_vols = { - vol: vol.flags["fk"] + vol: (vol.flags["fk"], 2 if "fka" in vol.flags else 1) for vp, vol in self.asrv.vfs.all_vols.items() if "fk" in vol.flags and (vp in self.rvol or vp in self.upvol) } @@ -3421,7 +3427,7 @@ class HttpCli(object): if not cur: continue - nfk = fk_vols.get(vol, 0) + nfk, fk_alg = fk_vols.get(vol) or (0, 0) q = "select sz, rd, fn, at from up where ip=? and at>?" for sz, rd, fn, at in cur.execute(q, (self.ip, lim)): @@ -3432,6 +3438,7 @@ class HttpCli(object): rv = {"vp": quotep(vp), "sz": sz, "at": at, "nfk": nfk} if nfk: rv["ap"] = vol.canonical(vjoin(rd, fn)) + rv["fk_alg"] = fk_alg ret.append(rv) if len(ret) > 3000: @@ -3445,6 +3452,7 @@ class HttpCli(object): if not nfk: continue + alg = rv.pop("fk_alg") ap = rv.pop("ap") try: st = bos.stat(ap) @@ -3452,7 +3460,7 @@ class HttpCli(object): continue fk = self.gen_fk( - self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino + alg, self.args.fk_salt, ap, st.st_size, 0 if ANYWIN else st.st_ino ) rv["vp"] += "?k=" + fk[:nfk] @@ -3713,8 +3721,13 @@ class HttpCli(object): if not is_dir and (self.can_read or self.can_get): if not self.can_read and not fk_pass and "fk" in vn.flags: + alg = 2 if "fka" in vn.flags else 1 correct = self.gen_fk( - self.args.fk_salt, abspath, st.st_size, 0 if ANYWIN else st.st_ino + alg, + self.args.fk_salt, + abspath, + st.st_size, + 0 if ANYWIN else st.st_ino, )[: vn.flags["fk"]] got = self.uparam.get("k") if got != correct: @@ -3922,6 +3935,7 @@ class HttpCli(object): ls_names = exclude_dotfiles(ls_names) add_fk = vn.flags.get("fk") + fk_alg = 2 if "fka" in vn.flags else 1 dirs = [] files = [] @@ -3978,11 +3992,15 @@ class HttpCli(object): except: ext = "%" - if add_fk: + if add_fk and not is_dir: href = "%s?k=%s" % ( quotep(href), self.gen_fk( - self.args.fk_salt, fspath, sz, 0 if ANYWIN else inf.st_ino + fk_alg, + self.args.fk_salt, + fspath, + sz, + 0 if ANYWIN else inf.st_ino, )[:add_fk], ) else: diff --git a/copyparty/u2idx.py b/copyparty/u2idx.py index 10aa402e..be96b7d7 100644 --- a/copyparty/u2idx.py +++ b/copyparty/u2idx.py @@ -314,6 +314,7 @@ class U2idx(object): sret = [] fk = flags.get("fk") dots = flags.get("dotsrch") + fk_alg = 2 if "fka" in flags else 1 c = cur.execute(uq, tuple(vuv)) for hit in c: w, ts, sz, rd, fn, ip, at = hit[:7] @@ -333,16 +334,17 @@ class U2idx(object): else: try: ap = absreal(os.path.join(ptop, rd, fn)) - inf = bos.stat(ap) + ino = 0 if ANYWIN or fk_alg == 2 else bos.stat(ap).st_ino except: continue - suf = ( - "?k=" - + gen_filekey( - self.args.fk_salt, ap, sz, 0 if ANYWIN else inf.st_ino - )[:fk] - ) + suf = "?k=" + gen_filekey( + fk_alg, + self.args.fk_salt, + ap, + sz, + ino, + )[:fk] lim -= 1 if lim < 0: diff --git a/copyparty/up2k.py b/copyparty/up2k.py index 5cceceda..42577ca5 100644 --- a/copyparty/up2k.py +++ b/copyparty/up2k.py @@ -228,8 +228,10 @@ class Up2k(object): self.log_func("up2k", msg, c) - def _gen_fk(self, salt: str, fspath: str, fsize: int, inode: int) -> str: - return gen_filekey_dbg(salt, fspath, fsize, inode, self.log, self.args.log_fk) + def _gen_fk(self, alg: int, salt: str, fspath: str, fsize: int, inode: int) -> str: + return gen_filekey_dbg( + alg, salt, fspath, fsize, inode, self.log, self.args.log_fk + ) def _block(self, why: str) -> None: self.blocked = why @@ -2623,9 +2625,10 @@ class Up2k(object): and not self.args.nw and (cj["user"] in vfs.axs.uread or cj["user"] in vfs.axs.upget) ): + alg = 2 if "fka" in vfs.flags else 1 ap = absreal(djoin(job["ptop"], job["prel"], job["name"])) ino = 0 if ANYWIN else bos.stat(ap).st_ino - fk = self.gen_fk(self.args.fk_salt, ap, job["size"], ino) + fk = self.gen_fk(alg, self.args.fk_salt, ap, job["size"], ino) ret["fk"] = fk[: vfs.flags["fk"]] return ret diff --git a/copyparty/util.py b/copyparty/util.py index ba7fdddc..f1606682 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -1567,15 +1567,18 @@ def rand_name(fdir: str, fn: str, rnd: int) -> str: return fn -def gen_filekey(salt: str, fspath: str, fsize: int, inode: int) -> str: - return base64.urlsafe_b64encode( - hashlib.sha512( - ("%s %s %s %s" % (salt, fspath, fsize, inode)).encode("utf-8", "replace") - ).digest() - ).decode("ascii") +def gen_filekey(alg: int, salt: str, fspath: str, fsize: int, inode: int) -> str: + if alg == 1: + zs = "%s %s %s %s" % (salt, fspath, fsize, inode) + else: + zs = "%s %s" % (salt, fspath) + + zb = zs.encode("utf-8", "replace") + return base64.urlsafe_b64encode(hashlib.sha512(zb).digest()).decode("ascii") def gen_filekey_dbg( + alg: int, salt: str, fspath: str, fsize: int, @@ -1583,7 +1586,7 @@ def gen_filekey_dbg( log: "NamedLogger", log_ptn: Optional[Pattern[str]], ) -> str: - ret = gen_filekey(salt, fspath, fsize, inode) + ret = gen_filekey(alg, salt, fspath, fsize, inode) assert log_ptn if log_ptn.search(fspath): diff --git a/tests/util.py b/tests/util.py index d58b0a48..77e4c4e3 100644 --- a/tests/util.py +++ b/tests/util.py @@ -138,6 +138,7 @@ class Cfg(Namespace): dbd="wal", s_wr_sz=512 * 1024, th_size="320x256", + fk_salt="a" * 16, unpost=600, u2sort="s", mtp=[],