Version in base suite: 4.5.0+dfsg-1+deb13u1 Base version: sabnzbdplus_4.5.0+dfsg-1+deb13u1 Target version: sabnzbdplus_4.5.0+dfsg-1+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/contrib/s/sabnzbdplus/sabnzbdplus_4.5.0+dfsg-1+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/contrib/s/sabnzbdplus/sabnzbdplus_4.5.0+dfsg-1+deb13u2.dsc changelog | 7 patches/12_security_fix_rce.diff | 193 ++++++ patches/13_security_fix_path_traversal.diff | 366 +++++++++++++ patches/14_security_fixes_from_upstream_release_5-1-3.diff | 305 ++++++++++ patches/series | 3 5 files changed, 874 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpjzz595zv/sabnzbdplus_4.5.0+dfsg-1+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpjzz595zv/sabnzbdplus_4.5.0+dfsg-1+deb13u2.dsc: no acceptable signature found diff -Nru sabnzbdplus-4.5.0+dfsg/debian/changelog sabnzbdplus-4.5.0+dfsg/debian/changelog --- sabnzbdplus-4.5.0+dfsg/debian/changelog 2026-08-13 07:53:01.000000000 +0000 +++ sabnzbdplus-4.5.0+dfsg/debian/changelog 2026-09-11 10:44:58.000000000 +0000 @@ -1,3 +1,10 @@ +sabnzbdplus (4.5.0+dfsg-1+deb13u2) trixie-security; urgency=medium + + * Patches: add 12 through 14, backports of security fixes in upstream + releases 5.1.2 and 5.1.3. + + -- Jeroen Ploemen Fri, 11 Sep 2026 10:44:58 +0000 + sabnzbdplus (4.5.0+dfsg-1+deb13u1) trixie-security; urgency=high * Patches: add 11, backport of an upstream security fix for an diff -Nru sabnzbdplus-4.5.0+dfsg/debian/patches/12_security_fix_rce.diff sabnzbdplus-4.5.0+dfsg/debian/patches/12_security_fix_rce.diff --- sabnzbdplus-4.5.0+dfsg/debian/patches/12_security_fix_rce.diff 1970-01-01 00:00:00.000000000 +0000 +++ sabnzbdplus-4.5.0+dfsg/debian/patches/12_security_fix_rce.diff 2026-09-11 10:44:58.000000000 +0000 @@ -0,0 +1,193 @@ +# https://github.com/sabnzbd/sabnzbd/security/advisories/GHSA-rgqj-28c2-gxwp +# backport of the upstream fix in commits: +# https://github.com/sabnzbd/sabnzbd/commit/a0e24089338e4a9b214a439e6dba2ee489195847 +# https://github.com/sabnzbd/sabnzbd/commit/6525703a94cfdb6c8add5c6f91bc073a7e928ed5 +--- a/sabnzbd/misc.py ++++ b/sabnzbd/misc.py +@@ -51,6 +51,7 @@ + DEF_ARTICLE_CACHE_MAX, + REPAIR_REQUEST, + GUESSIT_SORT_TYPES, ++ PP_LOOKUP, + ) + import sabnzbd.config as config + import sabnzbd.cfg as cfg +@@ -216,7 +217,7 @@ + cat: Optional[str] = None, + pp: Optional[Union[int, str]] = None, + script: Optional[str] = None, +-) -> Tuple[Optional[Union[int, str]], Optional[str], Optional[str]]: ++) -> Tuple[Optional[str], Optional[int], Optional[str]]: + """Basic sanitizer from outside input to a bit more predictable values""" + # * and Default are valid values + if safe_lower(cat) in ("", "none"): +@@ -225,6 +226,11 @@ + # Cannot use "not pp" because pp can also be 0 + if safe_lower(pp) in ("", "-1", "none"): + pp = None ++ else: ++ # Only accept a valid pp value (key of PP_LOOKUP) ++ pp = int_conv(pp) ++ if pp not in PP_LOOKUP: ++ pp = None + + # Check for valid script is performed in NzbObject init + if not script or safe_lower(script) == "default": +--- a/tests/test_misc.py ++++ b/tests/test_misc.py +@@ -94,16 +94,26 @@ + (None, None, None, (None, None, None)), + ("", "", "", (None, None, None)), + ("none", "-1", "default", (None, None, None)), +- ("SomeCategory", "5", "SomeScript", ("SomeCategory", "5", "SomeScript")), ++ ("SomeCategory", "1", "SomeScript", ("SomeCategory", 1, "SomeScript")), + ("none", 0, "default", (None, 0, None)), + ("Movies", "", "default", ("Movies", None, None)), +- ("", "10", "default", (None, "10", None)), +- ("none", "15", "", (None, "15", None)), + ("none", 0, "Default", (None, 0, None)), + ("other", "-1", "Default", ("other", None, None)), + ("none", "None", "default", (None, None, None)), + ("some", "none", "script", ("some", None, "script")), + ("none", "NONE", "Default", (None, None, None)), ++ # pp must be a PP_LOOKUP key or None ++ ("none", "2", "default", (None, 2, None)), ++ ("none", 3, "default", (None, 3, None)), ++ # Out-of-range ints are invalid ++ ("", "10", "default", (None, None, None)), ++ ("none", "15", "", (None, None, None)), ++ ("none", 4, "default", (None, None, None)), ++ # Non-numeric never passes as a string ++ ("none", "-c", "default", (None, 0, None)), ++ ("none", "echo pwned", "default", (None, 0, None)), ++ ("none", "2; rm -rf /", "default", (None, 0, None)), ++ ("none", "1.5", "default", (None, 0, None)), + ], + ) + def test_cat_pp_script_sanitizer(self, cat, pp, script, expected): +--- a/sabnzbd/interface.py ++++ b/sabnzbd/interface.py +@@ -109,6 +109,7 @@ + check_configlock: bool = False, + check_for_login: bool = True, + check_api_key: bool = False, ++ api_route: bool = False, + access_type: int = 4, + ) -> Union[Callable, str]: + """Wrapper for both cherrypy.expose and login/access check""" +@@ -118,6 +119,7 @@ + check_configlock=check_configlock, + check_for_login=check_for_login, + check_api_key=check_api_key, ++ api_route=api_route, + access_type=access_type, + ) + +@@ -181,7 +183,7 @@ + + # Some pages need correct API key + if check_api_key: +- if msg := check_apikey(kwargs): ++ if msg := check_apikey(kwargs, api_route=api_route): + cherrypy.response.status = 403 + if cfg.api_warnings(): + return msg +@@ -373,31 +375,34 @@ + conf.update({"tools.auth_basic.on": False}) + + +-def check_apikey(kwargs): +- """Check API-key or NZB-key +- Return None when OK, otherwise an error message +- """ +- mode = kwargs.get("mode", "") +- name = kwargs.get("name", "") +- +- # Lookup required access level for the specific api-call +- req_access = sabnzbd.api.api_level(mode, name) +- if not check_access(req_access, warn_user=True): +- return _MSG_ACCESS_DENIED ++def check_apikey(kwargs, api_route: bool = False): ++ """Check API-key or NZB-key, return None when OK, else an error message. ++ Only the real /api route trusts "mode"; elsewhere it's attacker-controlled, ++ so a valid API-key is always required.""" ++ key = kwargs.get("apikey") + +- # Skip for auth and version calls +- if mode in ("version", "auth"): +- return None ++ if api_route: ++ mode = kwargs.get("mode", "") ++ name = kwargs.get("name", "") ++ ++ req_access = sabnzbd.api.api_level(mode, name) ++ if not check_access(req_access, warn_user=True): ++ return _MSG_ACCESS_DENIED ++ ++ # Skip for auth and version calls ++ if mode in ("version", "auth"): ++ return None ++ ++ # NZB-key suffices for nzb-level calls ++ if req_access == 1 and key and key == cfg.nzb_key(): ++ return None + +- # First check API-key, if OK that's sufficient +- key = kwargs.get("apikey") ++ # A valid API-key is required for everything else + if not key: + log_warning_and_ip( + T("API Key missing, please enter the api key from Config->General into your 3rd party program:") + ) + return _MSG_APIKEY_REQUIRED +- elif req_access == 1 and key == cfg.nzb_key(): +- return None + elif key == cfg.api_key(): + return None + else: +@@ -496,7 +501,7 @@ + sabnzbd.shutdown_program() + return T("SABnzbd shutdown finished") + +- @secured_expose(check_api_key=True, access_type=1) ++ @secured_expose(check_api_key=True, api_route=True, access_type=1) + def api(self, **kwargs): + """Redirect to API-handler, we check the access_type in the API-handler""" + return api_handler(kwargs) +--- a/tests/test_interface.py ++++ b/tests/test_interface.py +@@ -225,6 +225,34 @@ + + _func() + ++ @set_config({"api_key": "the_real_api_key", "nzb_key": "the_real_nzb_key"}) ++ @pytest.mark.parametrize( ++ "api_route, kwargs, expected", ++ [ ++ # /api route: version/auth public, NZB-key valid for nzb-level calls ++ (True, {"mode": "version"}, None), ++ (True, {"mode": "auth"}, None), ++ (True, {"mode": "addfile", "apikey": "the_real_nzb_key"}, None), ++ (True, {"mode": "queue", "apikey": "the_real_api_key"}, None), ++ (True, {"mode": "queue"}, interface._MSG_APIKEY_REQUIRED), ++ (True, {"mode": "queue", "apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT), ++ # Web-ui routes must ignore 'mode': no version/auth or NZB-key bypass ++ (False, {"mode": "version"}, interface._MSG_APIKEY_REQUIRED), ++ (False, {"mode": "auth"}, interface._MSG_APIKEY_REQUIRED), ++ (False, {"mode": "addfile", "apikey": "the_real_nzb_key"}, interface._MSG_APIKEY_INCORRECT), ++ (False, {"apikey": "the_real_api_key"}, None), ++ (False, {"mode": "version", "apikey": "the_real_api_key"}, None), ++ (False, {"apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT), ++ (False, {}, interface._MSG_APIKEY_REQUIRED), ++ ], ++ ) ++ def test_check_apikey_ignores_mode_off_api_route(self, api_route, kwargs, expected): ++ """'mode' is only trusted on the real /api route, not on web-ui handlers.""" ++ cherrypy.request.remote.ip = "127.0.0.1" ++ cherrypy.request.headers.update({"X-Forwarded-For": None}) ++ cherrypy.request.remote_label = "127.0.0.1 [test]" ++ assert interface.check_apikey(kwargs, api_route=api_route) == expected ++ + @set_config({"verify_xff_header": False}) + def test_logout_does_not_leak_valid_cookie(self): + """A logout must never emit a cookie/salt pair that passes check_login_cookie. diff -Nru sabnzbdplus-4.5.0+dfsg/debian/patches/13_security_fix_path_traversal.diff sabnzbdplus-4.5.0+dfsg/debian/patches/13_security_fix_path_traversal.diff --- sabnzbdplus-4.5.0+dfsg/debian/patches/13_security_fix_path_traversal.diff 1970-01-01 00:00:00.000000000 +0000 +++ sabnzbdplus-4.5.0+dfsg/debian/patches/13_security_fix_path_traversal.diff 2026-09-11 10:44:58.000000000 +0000 @@ -0,0 +1,366 @@ +# https://github.com/sabnzbd/sabnzbd/security/advisories/GHSA-75g3-96fr-7p2r +# backport of the upstream fix in commits: +# https://github.com/sabnzbd/sabnzbd/commit/6ddabb5a4599731b00f44162e62f31e291d2b2d0 +# https://github.com/sabnzbd/sabnzbd/commit/c57af32131216de3e1df3097a0cce06c8c99ceb8 +--- a/sabnzbd/deobfuscate_filenames.py ++++ b/sabnzbd/deobfuscate_filenames.py +@@ -72,7 +72,12 @@ + new_path = os.path.join(dirname, md5of16k[file_md5of16k]) + # Make sure it's a unique name + unique_filename = get_unique_filename(new_path) +- renamer(filepath, unique_filename) ++ # Untrusted par2 name: contain it, skip on traversal ++ try: ++ renamer(filepath, unique_filename, create_local_directories=True) ++ except OSError: ++ logging.info("Skipping par2 rename of %s to %s", filepath, unique_filename) ++ continue + new_files.append(unique_filename) + return new_files + +--- a/sabnzbd/filesystem.py ++++ b/sabnzbd/filesystem.py +@@ -207,13 +207,37 @@ + CH_ILLEGAL_WIN += chr(i) + + +-def sanitize_filename(filename: str) -> str: ++def sanitize_filename(filename: str, allow_subdirs: bool = False) -> str: + """Return filename with illegal chars converted to legal ones +- and with the par2 extension always in lowercase ++ and with the par2 extension always in lowercase. ++ With allow_subdirs the forward slashes that par2 uses to separate sub-directories are kept ++ and every part is sanitized on its own. The result is always local to the current folder: ++ empty parts, "." and ".." are dropped, so a leading slash or any amount of traversal can ++ never produce a name that points outside of it. The admin folder is dropped as well, so ++ it can never point into it either. + """ + if not filename: + return filename + ++ if allow_subdirs: ++ # Par2 always uses a forward slash, no matter which platform created the set ++ parts = [] ++ for part in filename.split("/"): ++ if part in ("", os.curdir): ++ continue ++ if part == os.pardir: ++ logging.info("Dropping directory traversal from name %s", filename) ++ continue ++ if part.lower() == JOB_ADMIN.lower(): ++ # Never let a name point into the admin folder, its files are pickle-loaded ++ logging.info("Dropping admin folder from name %s", filename) ++ continue ++ parts.append(sanitize_filename(part)) ++ # Nothing usable left, or no sub-directories after all ++ if not parts: ++ return "unknown" ++ return os.path.join(*parts) ++ + illegal = CH_ILLEGAL + if sabnzbd.WINDOWS or sabnzbd.cfg.sanitize_safe(): + # Remove all bad Windows chars too +@@ -442,6 +466,23 @@ + return is_subfolder + + ++def points_into_admin_dir(path: str, base: str) -> bool: ++ """Return True if path is, or is inside, an admin folder somewhere below base. ++ Both sides are resolved first, because the name alone cannot be trusted: on Windows ++ an NTFS 8.3 alias ("__ADMI~1") and on any platform a link point at a directory that ++ is named differently than the path says. Resolving also settles any case difference. ++ """ ++ # realpath() only keeps the \\?\ prefix if it was there to begin with, so clip both ++ # sides: comparing a prefixed path to a plain one would put the admin folder out of ++ # sight and let it pass ++ try: ++ relative = os.path.relpath(clip_path(os.path.realpath(path)), clip_path(os.path.realpath(base))) ++ except ValueError: ++ # Windows only: resolving ended up on another drive, so it left base altogether ++ return True ++ return JOB_ADMIN.lower() in relative.lower().split(os.sep) ++ ++ + def is_network_path(path: str) -> bool: + """Check weither a path is a network path. + On Windows, use win32 functions to detect users that try to avoid this detection by using a mapped drive letter. +@@ -885,12 +926,21 @@ + oldpath, _ = os.path.split(old) + # Check not outside directory + # In case of "same_file() == 1": same directory, so nothing to do +- if same_directory(oldpath, path) == 0: ++ location = same_directory(oldpath, path) ++ if location == 0: + # Outside current directory, this is most likely malicious + logging.error(T("Blocked attempt to create directory %s"), path) + raise OSError("Refusing to go outside directory") +- elif same_directory(oldpath, path) == 2: +- # Sub-directory, so create if does not yet exist: ++ ++ # Refuse the admin folder, it is pickle-loaded. Check the whole new path: if the ++ # last element is the admin folder itself, the move below would put the file ++ # inside it, since shutil.move accepts a directory as its target. ++ if points_into_admin_dir(new, oldpath): ++ logging.error(T("Blocked attempt to create directory %s"), path) ++ raise OSError("Refusing to go into admin directory") ++ ++ if location == 2: ++ # Sub-directory, create if does not yet exist: + create_all_dirs(path) + + logging.debug('Renaming "%s" to "%s"', old, new) +--- a/tests/test_filesystem.py ++++ b/tests/test_filesystem.py +@@ -31,7 +31,7 @@ + + import sabnzbd.cfg + import sabnzbd.filesystem as filesystem +-from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX ++from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX, JOB_ADMIN + from tests.testhelper import * + + # Set the global uid for fake filesystems to a non-root user; +@@ -118,6 +118,72 @@ + assert filesystem.sanitize_filename("../test") == ".._test" + + @set_platform("linux") ++ def test_file_allow_subdirs(self): ++ """Par2 uses "/" to separate sub-directories, no matter which platform created the set""" ++ assert filesystem.sanitize_filename("sub/test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar") ++ assert filesystem.sanitize_filename("sub/deeper/test.rar", allow_subdirs=True) == os.path.join( ++ "sub", "deeper", "test.rar" ++ ) ++ # No sub-directory at all, or nothing but separators ++ assert filesystem.sanitize_filename("test.rar", allow_subdirs=True) == "test.rar" ++ assert filesystem.sanitize_filename("a//b.rar", allow_subdirs=True) == os.path.join("a", "b.rar") ++ assert filesystem.sanitize_filename("sub/./test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar") ++ # Every part is sanitized on its own, chr(0) is illegal on all platforms ++ assert filesystem.sanitize_filename("sub" + chr(0) + "1/test" + chr(0) + "2.rar", allow_subdirs=True) == ( ++ os.path.join("sub_1", "test_2.rar") ++ ) ++ ++ @pytest.mark.parametrize( ++ "hostile_name", ++ [ ++ "/test.rar", ++ "//test.rar", ++ "../test.rar", ++ "../../../../../../etc/shadow", ++ "sub/../../test.rar", ++ "sub/../../../sub/test.rar", ++ "./../test.rar", ++ "../..", ++ "../", ++ "/", ++ "//", ++ "/../", ++ "...", ++ "....", ++ ], ++ ) ++ def test_file_allow_subdirs_cannot_escape(self, hostile_name): ++ """Whatever the par2 claims, the result has to stay inside the folder it is used in. ++ Joining it onto any base directory must never point above that base.""" ++ result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True) ++ ++ assert result, "an empty result would resolve to the base directory itself" ++ assert not os.path.isabs(result) ++ assert os.pardir not in result.split(os.sep) ++ ++ # The real test: it cannot climb out of whatever it gets joined to ++ base = os.path.join(os.sep + "downloads", "incomplete", "job") ++ resolved = os.path.normpath(os.path.join(base, result)) ++ assert resolved.startswith(base + os.sep), "%s escaped to %s" % (hostile_name, resolved) ++ ++ @pytest.mark.parametrize( ++ "hostile_name", ++ [ ++ JOB_ADMIN, ++ JOB_ADMIN + "/__verified__", ++ JOB_ADMIN.lower() + "/__verified__", ++ "sub/" + JOB_ADMIN + "/__verified__", ++ JOB_ADMIN + "/deeper/__verified__", ++ ], ++ ) ++ def test_file_allow_subdirs_cannot_enter_admin(self, hostile_name): ++ """The admin folder is pickle-loaded, so a par2 name must never point into it""" ++ result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True) ++ ++ assert result, "an empty result would resolve to the base directory itself" ++ assert JOB_ADMIN.lower() not in result.lower().split(os.sep) ++ ++ @set_platform("linux") + def test_folder_illegal_chars_linux(self): + assert filesystem.sanitize_foldername('test"aftertest') == "test_aftertest" + assert filesystem.sanitize_foldername("test:") == "test_" +@@ -401,6 +467,33 @@ + assert 0 == filesystem.same_directory("/test/../Home", "/home") + + ++class TestPointsIntoAdminDir: ++ def test_by_name(self, tmp_path): ++ base = str(tmp_path) ++ assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN), base) ++ assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN, "__verified__"), base) ++ assert filesystem.points_into_admin_dir(os.path.join(base, "sub", JOB_ADMIN, "__verified__"), base) ++ ++ def test_regular_names_are_left_alone(self, tmp_path): ++ base = str(tmp_path) ++ assert not filesystem.points_into_admin_dir(os.path.join(base, "testfile.rar"), base) ++ assert not filesystem.points_into_admin_dir(os.path.join(base, "sub", "testfile.rar"), base) ++ # Only a full part counts, not a name that merely starts with it ++ assert not filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN + "-data", "testfile.rar"), base) ++ ++ def test_link_cannot_hide_it(self, tmp_path): ++ """On Windows an NTFS 8.3 alias ("__ADMI~1") points at the admin folder under a ++ different name, exactly like a link does here, so the name cannot be trusted""" ++ base = str(tmp_path) ++ admin_dir = os.path.join(base, JOB_ADMIN) ++ os.mkdir(admin_dir) ++ linkname = os.path.join(base, "notadmin") ++ os.symlink(admin_dir, linkname) ++ ++ assert filesystem.points_into_admin_dir(linkname, base) ++ assert filesystem.points_into_admin_dir(os.path.join(linkname, "__verified__"), base) ++ ++ + class TestClipLongPath: + def test_empty(self): + assert filesystem.clip_path(None) is None +@@ -1164,6 +1257,41 @@ + assert os.path.isfile(filename) + assert not os.path.isfile(newfilename) + ++ # ... renaming into the admin folder is not allowed either ++ admin_dir = os.path.join(dirname, JOB_ADMIN) ++ os.mkdir(admin_dir) ++ Path(filename).touch() ++ newfilename = os.path.join(admin_dir, "__verified__") ++ try: ++ filesystem.renamer(filename, newfilename, create_local_directories=True) ++ except Exception: ++ pass ++ assert os.path.isfile(filename) ++ assert not os.path.isfile(newfilename) ++ ++ # ... nor is naming the admin folder itself: a move into an existing directory ++ # keeps the old basename, so this would end up inside the admin folder as well ++ Path(filename).touch() ++ try: ++ filesystem.renamer(filename, admin_dir, create_local_directories=True) ++ except Exception: ++ pass ++ assert os.path.isfile(filename) ++ assert not os.listdir(admin_dir) ++ ++ # ... and not under another name that resolves to it, such as a link. On Windows ++ # an NTFS 8.3 alias ("__ADMI~1") reaches the admin folder the very same way. ++ linkname = os.path.join(dirname, "notadmin") ++ os.symlink(admin_dir, linkname) ++ Path(filename).touch() ++ try: ++ filesystem.renamer(filename, os.path.join(linkname, "__verified__"), create_local_directories=True) ++ except Exception: ++ pass ++ assert os.path.isfile(filename) ++ assert not os.listdir(admin_dir) ++ os.remove(linkname) ++ + # Cleanup working directory + shutil.rmtree(dirname) + +--- a/sabnzbd/newsunpack.py ++++ b/sabnzbd/newsunpack.py +@@ -1742,7 +1742,13 @@ + if calculated_crc32.get(nzf.filename, "") == sfv_parse_results[file]: + try: + logging.debug("SFV-check will rename %s to %s", nzf.filename, file) +- renamer(os.path.join(nzo.download_path, nzf.filename), os.path.join(nzo.download_path, file)) ++ # Untrusted sfv name: normalize separators and contain ++ file = os.path.normpath(file) ++ renamer( ++ os.path.join(nzo.download_path, nzf.filename), ++ os.path.join(nzo.download_path, file), ++ create_local_directories=True, ++ ) + renames[file] = nzf.filename + nzf.filename = file + result &= True +--- a/tests/test_newsunpack.py ++++ b/tests/test_newsunpack.py +@@ -40,8 +40,31 @@ + assert not newsunpack.is_sfv_file("tests/data/only_comments.sfv") + assert not newsunpack.is_sfv_file("tests/data/random.bin") + +- def test_is_sevenfile(self): +- # False, because the command is not set ++ def test_sfv_check_blocks_path_traversal(self, tmp_path): ++ """A traversing SFV filename must not move a file out of the job directory""" ++ download_path = str(tmp_path) ++ obfuscated_name = "6f1ed002ab5595859014ebf0951522d9" ++ obfuscated_path = os.path.join(download_path, obfuscated_name) ++ with open(obfuscated_path, "wb") as test_file: ++ test_file.write(b"payload") ++ ++ # SFV entry with matching crc32 but a traversing target name ++ sfv_path = os.path.join(download_path, "check.sfv") ++ with open(sfv_path, "w") as sfv_file: ++ sfv_file.write("../escaped.bin deadbeef\n") ++ ++ nzf = mock.Mock(filename=obfuscated_name, filepath=obfuscated_path, crc32=0xDEADBEEF) ++ nzo = mock.Mock(download_path=download_path, finished_files=[nzf]) ++ ++ assert newsunpack.sfv_check([sfv_path], nzo) is False ++ assert not os.path.exists(os.path.join(download_path, os.pardir, "escaped.bin")) ++ assert os.path.exists(obfuscated_path) ++ ++ def test_is_sevenfile(self, monkeypatch): ++ # False, because the command is not set. Force it explicitly: SEVENZIP_COMMAND ++ # is a module global that another test in this class may have populated via ++ # find_programs(), and under pytest-xdist tests share no ordering guarantee. ++ monkeypatch.setattr(newsunpack, "SEVENZIP_COMMAND", None) + assert not newsunpack.SEVENZIP_COMMAND + assert not newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z") + +@@ -54,6 +77,7 @@ + assert newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z") + + def test_sevenzip(self): ++ newsunpack.find_programs(".") + testzip = newsunpack.SevenZip("tests/data/test_7zip/testfile.7z") + assert testzip.namelist() == ["My_Test_Download.bin"] + # Basic check that we can get data from the 7zip +--- a/sabnzbd/nzbstuff.py ++++ b/sabnzbd/nzbstuff.py +@@ -89,6 +89,8 @@ + remove_data, + strip_extensions, + get_ext, ++ same_directory, ++ points_into_admin_dir, + ) + from sabnzbd.par2file import FilePar2Info, has_par2_in_filename, analyse_par2, parse_par2_file, is_par2_file + from sabnzbd.decorators import synchronized +@@ -451,8 +453,21 @@ + if not self.filepath: + self.nzo.verify_nzf_filename(self) + filename = sanitize_filename(self.filename) +- self.filepath = get_unique_filename(os.path.join(self.nzo.download_path, filename)) ++ directory = self.nzo.download_path ++ candidate = get_unique_filename(os.path.join(directory, filename)) ++ path = os.path.join(directory, candidate) ++ ++ if subdir := os.path.dirname(candidate): ++ # sanitize_filename() keeps the name local, so this should never trigger ++ if same_directory(directory, os.path.dirname(path)) == 0: ++ raise ValueError("Refusing to write %s outside of %s" % (candidate, directory)) ++ if points_into_admin_dir(path, directory): ++ raise ValueError("Refusing to write %s into the admin folder" % candidate) ++ create_all_dirs(os.path.join(directory, subdir)) ++ ++ self.filepath = candidate + self.filename = get_filename(self.filepath) ++ + return self.filepath + + @property diff -Nru sabnzbdplus-4.5.0+dfsg/debian/patches/14_security_fixes_from_upstream_release_5-1-3.diff sabnzbdplus-4.5.0+dfsg/debian/patches/14_security_fixes_from_upstream_release_5-1-3.diff --- sabnzbdplus-4.5.0+dfsg/debian/patches/14_security_fixes_from_upstream_release_5-1-3.diff 1970-01-01 00:00:00.000000000 +0000 +++ sabnzbdplus-4.5.0+dfsg/debian/patches/14_security_fixes_from_upstream_release_5-1-3.diff 2026-09-11 10:44:58.000000000 +0000 @@ -0,0 +1,305 @@ +# https://github.com/sabnzbd/sabnzbd/security/advisories/GHSA-q326-jpxx-jmjc +# https://github.com/sabnzbd/sabnzbd/security/advisories/GHSA-mjwj-v5mr-cmcg +# backport of the security fixes in upstream release 5.1.3: +# https://github.com/sabnzbd/sabnzbd/compare/f39f99c827f8965aa8ccd9e1a1507ae0183a8e7f...c62391e9833037f8bf9229568ab16a7aad743d7f +--- a/SABnzbd.py ++++ b/SABnzbd.py +@@ -1324,6 +1324,7 @@ + } + + appconfig = { ++ "/": {"request.dispatch": sabnzbd.interface.SecureDispatcher()}, + "/api": { + "tools.auth_basic.on": False, + "tools.response_headers.on": True, +--- a/sabnzbd/filesystem.py ++++ b/sabnzbd/filesystem.py +@@ -223,6 +223,8 @@ + # Par2 always uses a forward slash, no matter which platform created the set + parts = [] + for part in filename.split("/"): ++ # Sanitize first, the checks below run on the stripped name ++ part = sanitize_filename(part) + if part in ("", os.curdir): + continue + if part == os.pardir: +@@ -232,7 +234,7 @@ + # Never let a name point into the admin folder, its files are pickle-loaded + logging.info("Dropping admin folder from name %s", filename) + continue +- parts.append(sanitize_filename(part)) ++ parts.append(part) + # Nothing usable left, or no sub-directories after all + if not parts: + return "unknown" +@@ -483,6 +485,13 @@ + return JOB_ADMIN.lower() in relative.lower().split(os.sep) + + ++def points_outside(root: str, path: str) -> bool: ++ """Return True if the file at path does not end up inside root. ++ Both sides are resolved, so a root that is itself a link is fine, a link inside it is not. ++ """ ++ return same_directory(os.path.realpath(root), os.path.dirname(os.path.realpath(path))) == 0 ++ ++ + def is_network_path(path: str) -> bool: + """Check weither a path is a network path. + On Windows, use win32 functions to detect users that try to avoid this detection by using a mapped drive letter. +@@ -800,7 +809,7 @@ + if n: + new_path = "%s.%s" % (path, n) + +- if not os.path.exists(new_path): ++ if not os.path.lexists(new_path): + if create_dir: + return create_all_dirs(new_path, apply_permissions=True) + else: +@@ -817,7 +826,7 @@ + num = 1 + new_path, filename = os.path.split(path) + name, ext = os.path.splitext(filename) +- while os.path.exists(path): ++ while os.path.lexists(path): + filename = "%s.%d%s" % (name, num, ext) + num += 1 + path = os.path.join(new_path, filename) +@@ -839,8 +848,9 @@ + + + @synchronized(DIR_LOCK) +-def move_to_path(path: str, new_path: str) -> Tuple[bool, Optional[str]]: ++def move_to_path(path: str, new_path: str, root: Optional[str] = None) -> tuple[bool, Optional[str]]: + """Move a file to a new path, optionally give unique filename ++ With root the destination has to resolve to a location inside it + Return (ok, new_path) + """ + ok = True +@@ -856,6 +866,11 @@ + new_path = get_unique_filename(new_path) + + if new_path: ++ if root and points_outside(root, new_path): ++ logging.error(T("Failed moving %s to %s"), clip_path(path), clip_path(new_path)) ++ logging.info("Refusing to move %s, it points outside %s", new_path, root) ++ return False, None ++ + logging.debug("Moving (overwrite: %s) %s => %s", overwrite, path, new_path) + if not os.path.exists(new_path_dir): + create_all_dirs(os.path.dirname(new_path), apply_permissions=True) +@@ -925,9 +940,7 @@ + if create_local_directories: + oldpath, _ = os.path.split(old) + # Check not outside directory +- # In case of "same_file() == 1": same directory, so nothing to do +- location = same_directory(oldpath, path) +- if location == 0: ++ if points_outside(oldpath, new): + # Outside current directory, this is most likely malicious + logging.error(T("Blocked attempt to create directory %s"), path) + raise OSError("Refusing to go outside directory") +@@ -939,7 +952,7 @@ + logging.error(T("Blocked attempt to create directory %s"), path) + raise OSError("Refusing to go into admin directory") + +- if location == 2: ++ if not os.path.isdir(path): + # Sub-directory, create if does not yet exist: + create_all_dirs(path) + +@@ -1154,6 +1167,44 @@ + raise IOError + + ++# Allowlist of every global our pickles may reference: safe data types and our persisted classes. ++# Explicit, not a "sabnzbd.*" wildcard, which would also admit gadget classes (e.g. a __del__ ++# that runs os.kill). sabnzbd.nzbstuff is the pre-refactor module path (compat shim). ++_SAFE_GLOBALS = { ++ ("datetime", "datetime"), ++ ("datetime", "date"), ++ ("datetime", "time"), ++ ("datetime", "timedelta"), ++ ("datetime", "timezone"), ++ ("time", "struct_time"), ++ ("os", "stat_result"), ++ ("collections", "OrderedDict"), ++ ("collections", "defaultdict"), ++ ("collections", "deque"), ++ ("builtins", "set"), ++ ("builtins", "frozenset"), ++ ("builtins", "bytearray"), ++ ("builtins", "complex"), ++ ("copyreg", "_reconstructor"), ++ ("sabnzbd.nzb.object", "NzbObject"), ++ ("sabnzbd.nzb.file", "NzbFile"), ++ ("sabnzbd.nzb.article", "Article"), ++ ("sabnzbd.par2file", "FilePar2Info"), ++ ("sabnzbd.nzbstuff", "NzbObject"), ++ ("sabnzbd.nzbstuff", "NzbFile"), ++ ("sabnzbd.nzbstuff", "Article"), ++} ++ ++ ++class RestrictedUnpickler(pickle.Unpickler): ++ """Unpickler restricted to an allowlist, so a hostile pickle cannot run code""" ++ ++ def find_class(self, module, name): ++ if (module, name) in _SAFE_GLOBALS: ++ return super().find_class(module, name) ++ raise pickle.UnpicklingError("Refusing to unpickle %s.%s" % (module, name)) ++ ++ + def save_data(data, _id, path, do_pickle=True, silent=False): + """Save data to a diskfile""" + if not silent: +@@ -1195,11 +1246,7 @@ + try: + with open(path, "rb") as data_file: + if do_pickle: +- try: +- data = pickle.load(data_file, encoding=sabnzbd.encoding.CODEPAGE) +- except UnicodeDecodeError: +- # Could be Python 2 data that we can load using old encoding +- data = pickle.load(data_file, encoding="latin1") ++ data = RestrictedUnpickler(data_file, encoding=sabnzbd.encoding.CODEPAGE).load() + else: + data = data_file.read() + +--- a/sabnzbd/interface.py ++++ b/sabnzbd/interface.py +@@ -104,6 +104,17 @@ + _MSG_APIKEY_INCORRECT = "API Key Incorrect" + + ++class SecureDispatcher(cherrypy.dispatch.Dispatcher): ++ """Dispatcher that refuses paths traversing private attributes""" ++ ++ def find_handler(self, path): ++ handler, vpath = super().find_handler(path) ++ # Punctuation is translated to underscores before the attribute lookup ++ if any(segment.translate(self.translate).startswith("_") for segment in path.split("/")): ++ return None, [] ++ return handler, vpath ++ ++ + def secured_expose( + wrap_func: Optional[Callable] = None, + check_configlock: bool = False, +@@ -123,11 +134,11 @@ + access_type=access_type, + ) + +- # Expose to cherrypy +- wrap_func.exposed = True +- + @functools.wraps(wrap_func) + def internal_wrap(*args, **kwargs): ++ if len(args) > 1: ++ raise cherrypy.NotFound() ++ + # Label for logging in this and other functions, handling X-Forwarded-For + # The cherrypy.request object allows adding custom attributes + if cherrypy.request.headers.get("X-Forwarded-For"): +@@ -192,6 +203,9 @@ + # All good, cool! + return wrap_func(*args, **kwargs) + ++ # Expose only the wrapper to cherrypy ++ del internal_wrap.__wrapped__ ++ internal_wrap.exposed = True + return internal_wrap + + +@@ -338,10 +352,14 @@ + + + def check_login(): +- # Not when no authentication required or basic-auth is on +- if not cfg.html_login() or not cfg.username() or not cfg.password(): ++ # Not when no authentication required ++ if not cfg.username() or not cfg.password(): + return True + ++ # Basic-auth is checked by cherrypy, only on the routes where the tool is enabled ++ if not cfg.html_login(): ++ return bool(cherrypy.request.login) ++ + # If we show login for external IP, by using access_type=6 we can check if IP match + if cfg.inet_exposure() == 5 and check_access(access_type=6): + return True +--- a/sabnzbd/newsunpack.py ++++ b/sabnzbd/newsunpack.py +@@ -63,6 +63,7 @@ + SEVENMULTI_RE, + is_size, + get_basename, ++ points_outside, + ) + from sabnzbd.nzbstuff import NzbObject + import sabnzbd.cfg as cfg +@@ -428,7 +429,7 @@ + joinable_sets[joinable_set].sort() + + # If par2 already did the work, just remove the files +- if os.path.exists(joinable_set): ++ if os.path.lexists(joinable_set): + logging.debug("file_join(): Skipping %s, (probably) joined by par2", joinable_set) + if nzo.delete: + clean_up_joinables(current) +@@ -446,6 +447,12 @@ + filename = filename.replace(nzo.download_path, workdir_complete) + logging.debug("file_join(): Assembling %s", filename) + ++ join_root = workdir_complete or nzo.download_path ++ if points_outside(join_root, filename): ++ raise OSError("Refusing to join into %s, it points outside %s" % (filename, join_root)) ++ if os.path.islink(filename): ++ raise OSError("Refusing to join into %s, it is a link" % filename) ++ + # Join the segments + with open(filename, "ab") as joined_file: + n = get_seq_number(current[0]) +--- a/sabnzbd/postproc.py ++++ b/sabnzbd/postproc.py +@@ -464,19 +464,17 @@ + if all_ok: + # Move any (left-over) files to destination + nzo.status = Status.MOVING +- for root, _, files in os.walk(nzo.download_path): +- if not root.endswith(JOB_ADMIN): +- for file in files: +- path = os.path.join(root, file) +- new_path = path.replace(nzo.download_path, tmp_workdir_complete) +- nzo.set_action_line(T("Moving"), file) +- ok, new_path = move_to_path(path, new_path) +- if new_path: +- newfiles.append(new_path) +- if not ok: +- nzo.set_unpack_info("Unpack", T("Failed moving %s to %s") % (path, new_path)) +- all_ok = False +- break ++ for path in listdir_full(nzo.download_path): ++ if JOB_ADMIN not in path: ++ new_path = path.replace(nzo.download_path, tmp_workdir_complete) ++ nzo.set_action_line(T("Moving"), get_filename(path)) ++ ok, new_path = move_to_path(path, new_path, root=tmp_workdir_complete) ++ if new_path: ++ newfiles.append(new_path) ++ if not ok: ++ nzo.set_unpack_info("Unpack", T("Failed moving %s to %s") % (path, new_path)) ++ all_ok = False ++ break + + # Set permissions right + set_permissions(tmp_workdir_complete) +--- a/sabnzbd/sorting.py ++++ b/sabnzbd/sorting.py +@@ -624,7 +624,7 @@ + for _file in files: + path = os.path.join(root, _file) + new_path = path.replace(workdir, dest) +- ok, new_path = move_to_path(path, new_path) ++ ok, new_path = move_to_path(path, new_path, root=dest) + if not ok: + return dest, False + diff -Nru sabnzbdplus-4.5.0+dfsg/debian/patches/series sabnzbdplus-4.5.0+dfsg/debian/patches/series --- sabnzbdplus-4.5.0+dfsg/debian/patches/series 2026-08-13 07:53:01.000000000 +0000 +++ sabnzbdplus-4.5.0+dfsg/debian/patches/series 2026-09-11 10:44:58.000000000 +0000 @@ -4,3 +4,6 @@ 09_remove_external_resources.diff 10_pytest_mods.diff 11_security_fix_authentication_bypass.diff +12_security_fix_rce.diff +13_security_fix_path_traversal.diff +14_security_fixes_from_upstream_release_5-1-3.diff