From efa43f891b18f9e0b354ce676d3050b7690f985e Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 13 Jun 2026 21:26:09 +0000 Subject: [PATCH] ffmpeg bwrap sandbox --- copyparty/__main__.py | 27 ++++++++ copyparty/mtag.py | 18 ++++- copyparty/svchub.py | 43 +++++++++--- copyparty/th_srv.py | 126 ++++++++++++++++++++--------------- copyparty/util.py | 8 +++ scripts/docker/Dockerfile.ac | 2 +- scripts/docker/Dockerfile.dj | 2 +- scripts/docker/Dockerfile.iv | 2 +- 8 files changed, 158 insertions(+), 70 deletions(-) diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 60bd185e..7fdb81ec 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -28,6 +28,7 @@ from .__init__ import ( MACOS, PY2, PY36, + UNIX, VT100, WINDOWS, E, @@ -45,6 +46,7 @@ from .util import ( DEF_EXP, DEF_MTE, DEF_MTH, + HAVE_BWRAP, HAVE_IPV6, IMPLICATIONS, JINJA_VER, @@ -1643,6 +1645,29 @@ def add_optouts(ap): def add_safety(ap): + th_bwrap = "" + if HAVE_BWRAP: + zsl = [ + "bwrap", + "--proc /proc", + "--tmpfs /tmp", + "--tmpfs /var", + "--tmpfs /run", + "--dev-bind /dev/null /dev/null", + "--dev-bind /dev/random /dev/random", + "--dev-bind /dev/urandom /dev/urandom", + "--chdir /tmp", + "--clearenv", + "--unshare-all", + "--cap-drop ALL", + "--die-with-parent", + "--new-session", + ] + for d in ("/lib", "/lib64", "/usr/lib", "/usr/lib64"): + if os.path.isdir(d): + zsl.append(" --ro-bind %s %s" % (d, d)) + th_bwrap = " ".join(zsl) + 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, webdav requires login, 404 on 403, ban on excessive 404s.\n └─Alias of\033[32m -s --no-html --no-readme --no-logues --unpost=0 --no-del --no-mv --reflink --dav-auth --vague-403 -nih") @@ -1679,6 +1704,8 @@ def add_safety(ap): ap2.add_argument("--loris", metavar="B", type=int, default=60, help="if a client maxes out the server connection limit without sending headers, ban it for \033[33mB\033[0m minutes; disable with [\033[32m0\033[0m]") ap2.add_argument("--acao", metavar="V[,V]", type=u, default="*", help="Access-Control-Allow-Origin; list of origins (domains/IPs without port) to accept requests from; [\033[32mhttps://1.2.3.4\033[0m]. Default [\033[32m*\033[0m] allows requests from all sites but removes cookies and http-auth; only ?pw=hunter2 survives") ap2.add_argument("--acam", metavar="V[,V]", type=u, default="GET,HEAD", help="Access-Control-Allow-Methods; list of methods to accept from offsite ('*' behaves like \033[33m--acao\033[0m's description)") + if not ANYWIN and not UNIX: + ap2.add_argument("--th-bwrap", metavar="CMD", type=u, default=th_bwrap, help="optional bwrap sandbox command for FFmpeg and dcraw (Linux-only)") def add_salt(ap, fk_salt, dk_salt, ah_salt): diff --git a/copyparty/mtag.py b/copyparty/mtag.py index d9290783..dd86caa9 100644 --- a/copyparty/mtag.py +++ b/copyparty/mtag.py @@ -74,6 +74,7 @@ def have_ff(name: str) -> bytes: HAVE_FFMPEG = have_ff("ffmpeg") HAVE_FFPROBE = have_ff("ffprobe") +TH_BWRAP = [] CBZ_PICS = set("png jpg jpeg gif bmp tga tif tiff webp avif jxl".split()) CBZ_01 = re.compile(r"(^|[^0-9v])0+[01]\b") @@ -224,17 +225,28 @@ def au_unpk( return abspath +def bwrap(prog: bytes, ap_in: bytes, ap_out: bytes) -> list[bytes]: + if not TH_BWRAP: + return [prog] + ret = TH_BWRAP + [b"--ro-bind", prog, prog, b"--ro-bind", ap_in, ap_in] + if ap_out: + zs = ap_out.rsplit(b"/", 1)[0] + ret += [b"--bind", zs, zs] + ret.append(prog) + return ret + + def ffprobe( abspath: str, timeout: int = 60 ) -> tuple[dict[str, tuple[int, Any]], dict[str, list[Any]], list[Any], dict[str, Any]]: # ffprobe -hide_banner -show_streams -show_format -- - cmd = [ - HAVE_FFPROBE, + bap = fsenc(abspath) + cmd = bwrap(HAVE_FFPROBE, bap, b"") + [ b"-hide_banner", b"-show_streams", b"-show_format", b"--", - fsenc(abspath), + bap, ] rc, so, se = runcmd(cmd, timeout=timeout, nice=True, oom=200) retchk(rc, cmd, se) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index c35a0566..ad837253 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -27,13 +27,23 @@ if True: # pylint: disable=using-constant-test import typing from typing import Any, Optional, Union -from .__init__ import ANYWIN, EXE, MACOS, PY2, TYPE_CHECKING, E, EnvParams, unicode +from .__init__ import ( + ANYWIN, + EXE, + MACOS, + PY2, + TYPE_CHECKING, + UNIX, + E, + EnvParams, + unicode, +) from .__version__ import S_VERSION, VERSION from .authsrv import BAD_CFG, AuthSrv, derive_args, n_du_who, n_ver_who from .bos import bos from .cert import ensure_cert from .fsutil import ramdisk_chk -from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, HAVE_MUTAGEN +from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, HAVE_MUTAGEN, TH_BWRAP from .pwhash import HAVE_ARGON2 from .sutil import close_pools as sutil_close_pools from .tcpsrv import TcpSrv @@ -42,8 +52,6 @@ from .th_srv import ( H_PIL_HEIF, H_PIL_WEBP, HAVE_DCRAW, - HAVE_FFMPEG, - HAVE_FFPROBE, HAVE_PIL, HAVE_RAWPY, HAVE_VIPS, @@ -56,6 +64,7 @@ from .util import ( DEF_MTE, DEF_MTH, FFMPEG_URL, + HAVE_BWRAP, HAVE_PSUTIL, HAVE_SQLITE3, HAVE_ZMQ, @@ -1013,6 +1022,8 @@ class SvcHub(object): fok = [] fng = [] t_ff = "transcode audio, create spectrograms, video thumbnails" + + # fmt: off to_check = [ (HAVE_SQLITE3, "sqlite", "sessions and file/media indexing"), (HAVE_PIL, "pillow", "image thumbnails (plenty fast)"), @@ -1020,6 +1031,7 @@ class SvcHub(object): (H_PIL_WEBP, "pillow-webp", "create thumbnails as webp files"), (HAVE_FFMPEG, "ffmpeg", t_ff + ", good-but-slow image thumbnails"), (HAVE_FFPROBE, "ffprobe", t_ff + ", read audio/media tags"), + (HAVE_BWRAP, "bwrap", "sandbox to make ffmpeg less dangerous", not ANYWIN and not UNIX), (HAVE_MUTAGEN, "mutagen", "read audio tags (ffprobe is better but slower)"), (HAVE_ARGON2, "argon2", "secure password hashing (advanced users only)"), (HAVE_ZMQ, "pyzmq", "send zeromq messages from event-hooks"), @@ -1027,17 +1039,21 @@ class SvcHub(object): (H_PIL_AVIF, "pillow-avif", "read .avif pics with pillow (rarely useful)"), (HAVE_RAWPY, "rawpy", "read RAW images"), (HAVE_DCRAW, "libraw", "read RAW images"), + (HAVE_PSUTIL, "psutil", "improved plugin cleanup (rarely useful)", ANYWIN), ] - if ANYWIN: - to_check += [ - (HAVE_PSUTIL, "psutil", "improved plugin cleanup (rarely useful)") - ] + # fmt: on verbose = self.args.deps if verbose: self.log("dependencies", "") - for have, feat, what in to_check: + for zc in to_check: + try: + have, feat, what = zc + except: + have, feat, what, zb = zc + if not zb: + continue lst = fok if have else fng lst.append((feat, what)) if verbose: @@ -1204,6 +1220,15 @@ class SvcHub(object): vsa = [x.upper() for x in vsa if x] setattr(al, k + "_set", set(vsa)) + zs = "th_bwrap" + for k in zs.split(" "): + zsl = [x for x in str(getattr(al, k)).split(" ") if x] + zbl = [x.encode("ascii", "replace") for x in zsl] + setattr(al, k + "_s", zsl) + setattr(al, k + "_b", zbl) + + TH_BWRAP[:] = al.th_bwrap_b + zs = "dav_ua1 lf_url sus_urls nonsus_urls ua_nodav ua_nodoc ua_nozip" for k in zs.split(" "): vs = getattr(al, k) diff --git a/copyparty/th_srv.py b/copyparty/th_srv.py index f1393a2e..9174c633 100644 --- a/copyparty/th_srv.py +++ b/copyparty/th_srv.py @@ -18,7 +18,7 @@ from queue import Queue from .__init__ import ANYWIN, PY2, TYPE_CHECKING, unicode from .authsrv import VFS from .bos import bos -from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, au_unpk, ffprobe, have_ff +from .mtag import HAVE_FFMPEG, HAVE_FFPROBE, au_unpk, bwrap, ffprobe, have_ff from .util import BytesIO # type: ignore from .util import ( FFMPEG_URL, @@ -763,14 +763,14 @@ class ThumbSrv(object): def _conv_dcraw(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: self.wait4ram(0.6, tpath) + bap = fsenc(abspath) # fmt: off - cmd = [ - b"dcraw_emu", + cmd = bwrap(HAVE_DCRAW, bap, b"") + [ b"-h", # halfsize b"-o", b"1", # srgb b"-s", b"0", # first frame b"-Z", b"-", # to stdout - fsenc(abspath), + bap, ] # fmt: on p = sp.Popen(cmd, stdout=sp.PIPE) @@ -858,16 +858,15 @@ class ThumbSrv(object): res = self.getres(vn, fmt) bscale = scale.format(*list(res)).encode("utf-8") + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner" - ] - cmd += seek - cmd += [ - b"-i", fsenc(abspath), + ] + seek + [ + b"-i", bap_in, b"-map", imap, b"-vf", bscale, b"-frames:v", b"1", @@ -875,15 +874,15 @@ class ThumbSrv(object): ] # fmt: on - self._ffmpeg_im_o(tpath, vn, cmd) + self._ffmpeg_im_o(bap_out, vn, cmd) - def _ffmpeg_im_o(self, tpath: str, vn: VFS, cmd: list[bytes]) -> None: - if tpath.endswith(".jpg"): + def _ffmpeg_im_o(self, tpath: bytes, vn: VFS, cmd: list[bytes]) -> None: + if tpath.endswith(b".jpg"): cmd += [ b"-q:v", FF_JPG_Q[vn.flags["th_qv"] // 5], # default=?? ] - elif tpath.endswith(".jxl"): + elif tpath.endswith(b".jxl"): cmd += [ b"-q:v", unicode(vn.flags["th_qvx"]).encode("ascii"), # default=?? @@ -898,7 +897,7 @@ class ThumbSrv(object): b"6", # default=4, 0=fast, 6=max ] - cmd += [fsenc(tpath)] + cmd.append(tpath) self._run_ff(cmd, vn, "convt") def _run_ff(self, cmd: list[bytes], vn: VFS, kto: str, oom: int = 400) -> None: @@ -998,20 +997,21 @@ class ThumbSrv(object): b",showwavespic=s=2048x64:colors=white" b",convolution=1 1 1 1 1 1 1 1 1:1 1 1 1 1 1 1 1 1:1 1 1 1 1 1 1 1 1:1 -1 1 -1 5 -1 1 -1 1" # idk what im doing but it looks ok ) + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-filter_complex", flt, b"-frames:v", b"1", ] # fmt: on - cmd += [fsenc(tpath)] + cmd.append(bap_out) self._run_ff(cmd, vn, "convt") if "pngquant" in vn.flags: @@ -1078,19 +1078,21 @@ class ThumbSrv(object): except: self.untemp[tpath] = [infile] + bap_in = fsenc(abspath) + bap_out = fsenc(infile) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-map", b"0:a:0", b"-ac", b"1", b"-ar", b"48000", b"-sample_fmt", b"s16", b"-t", b"900", - b"-y", fsenc(infile), + b"-y", bap_out, ] # fmt: on self._run_ff(cmd, vn, "convt") @@ -1110,20 +1112,22 @@ class ThumbSrv(object): fc = fc.format(fco) + bap_in = fsenc(infile) + bap_out = fsenc(tpath) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(infile), + b"-i", bap_in, b"-filter_complex", fc.encode("utf-8"), b"-map", b"[o]", b"-frames:v", b"1", ] # fmt: on - self._ffmpeg_im_o(tpath, vn, cmd) + self._ffmpeg_im_o(bap_out, vn, cmd) def conv_mp3(self, abspath: str, tpath: str, fmt: str, vn: VFS) -> None: quality = self.args.q_mp3.lower() @@ -1142,24 +1146,26 @@ class ThumbSrv(object): qk = b"-q:a" qv = quality[1:].encode("ascii") + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) + # extremely conservative choices for output format # (always 2ch 44k1) because if a device is old enough # to not support opus then it's probably also super picky # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, ] + self.big_tags(rawtags) + [ b"-map", b"0:a:0", b"-ar", b"44100", b"-ac", b"2", b"-c:a", b"libmp3lame", qk, qv, - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1175,16 +1181,18 @@ class ThumbSrv(object): self.log("conv2 flac", 6) + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-map", b"0:a:0", b"-c:a", b"flac", - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1210,16 +1218,18 @@ class ThumbSrv(object): self.log("conv2 wav", 6) + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-map", b"0:a:0", b"-c:a", codec, - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1271,19 +1281,21 @@ class ThumbSrv(object): except: pass + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, ] + tagset + [ b"-map", b"0:a:0", b"-ac", ac, ] + benc + [ b"-f", container, - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1312,19 +1324,21 @@ class ThumbSrv(object): self.log("conv2 caf-tmp [%s]" % (enc,), 6) benc = enc.encode("ascii").split(b" ") + bap_in = fsenc(abspath) + bap_out = fsenc(tmp_opus) + # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-map_metadata", b"-1", b"-map", b"0:a:0", b"-ac", b"2", ] + benc + [ b"-f", b"opus", - fsenc(tmp_opus) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1338,20 +1352,21 @@ class ThumbSrv(object): if dur < 20 or sz < 256 * 1024: zs = bq.decode("ascii") self.log("conv2 caf-transcode; dur=%d sz=%d q=%s" % (dur, sz, zs), 6) + bap_in = fsenc(abspath) + bap_out = fsenc(tpath) # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(abspath), + b"-i", bap_in, b"-filter_complex", b"anoisesrc=a=0.001:d=7:c=pink,asplit[l][r]; [l][r]amerge[s]; [0:a:0][s]amix", b"-map_metadata", b"-1", b"-ac", b"2", b"-c:a", b"libopus", b"-b:a", bq, b"-f", b"caf", - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) @@ -1359,18 +1374,19 @@ class ThumbSrv(object): else: # simple remux should be safe self.log("conv2 caf-remux; dur=%d sz=%d" % (dur, sz), 6) + bap_in = fsenc(tmp_opus) + bap_out = fsenc(tpath) # fmt: off - cmd = [ - HAVE_FFMPEG, + cmd = bwrap(HAVE_FFMPEG, bap_in, bap_out) + [ b"-nostdin", b"-v", b"error", b"-hide_banner", - b"-i", fsenc(tmp_opus), + b"-i", bap_in, b"-map_metadata", b"-1", b"-map", b"0:a:0", b"-c:a", b"copy", b"-f", b"caf", - fsenc(tpath) + bap_out, ] # fmt: on self._run_ff(cmd, vn, "aconvt", oom=300) diff --git a/copyparty/util.py b/copyparty/util.py index bc661644..62f3b253 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -657,6 +657,14 @@ if EXE: pass +try: + if PY2 or ANYWIN: + raise Exception() + HAVE_BWRAP = shutil.which("bwrap") +except: + HAVE_BWRAP = "" + + def py_desc() -> str: interp = platform.python_implementation() py_ver = ".".join([str(x) for x in sys.version_info]) diff --git a/scripts/docker/Dockerfile.ac b/scripts/docker/Dockerfile.ac index d549627f..7ad7dca9 100644 --- a/scripts/docker/Dockerfile.ac +++ b/scripts/docker/Dockerfile.ac @@ -9,7 +9,7 @@ ENV XDG_CONFIG_HOME=/cfg ARG ADD_PKG="" RUN apk --no-cache add !pyc ${ADD_PKG} \ - tzdata wget mimalloc2 mimalloc2-insecure \ + tzdata wget mimalloc2 mimalloc2-insecure bubblewrap \ py3-jinja2 py3-argon2-cffi py3-pyzmq \ py3-openssl py3-paramiko py3-pillow diff --git a/scripts/docker/Dockerfile.dj b/scripts/docker/Dockerfile.dj index 3796722c..75d4d6c6 100644 --- a/scripts/docker/Dockerfile.dj +++ b/scripts/docker/Dockerfile.dj @@ -12,7 +12,7 @@ COPY i/bin/mtag/install-deps.sh ./ COPY i/bin/mtag/audio-bpm.py /mtag/ COPY i/bin/mtag/audio-key.py /mtag/ RUN apk add -U !pyc ${ADD_PKG} \ - tzdata wget mimalloc2 mimalloc2-insecure \ + tzdata wget mimalloc2 mimalloc2-insecure bubblewrap \ py3-jinja2 py3-argon2-cffi py3-pyzmq \ py3-openssl py3-paramiko py3-pillow \ py3-pip \ diff --git a/scripts/docker/Dockerfile.iv b/scripts/docker/Dockerfile.iv index 1b9e7c2a..1a8b8aa4 100644 --- a/scripts/docker/Dockerfile.iv +++ b/scripts/docker/Dockerfile.iv @@ -9,7 +9,7 @@ ENV XDG_CONFIG_HOME=/cfg ARG ADD_PKG="" RUN apk add -U !pyc ${ADD_PKG} \ - tzdata wget mimalloc2 mimalloc2-insecure \ + tzdata wget mimalloc2 mimalloc2-insecure bubblewrap \ py3-jinja2 py3-argon2-cffi py3-pyzmq \ py3-openssl py3-paramiko py3-pillow \ py3-pip \