https://github.com/python/cpython/commit/8aa5d657e4d3f1a4cca5ff6c976e2e868e0a9056 commit: 8aa5d657e4d3f1a4cca5ff6c976e2e868e0a9056 branch: 3.14 author: Victor Stinner <[email protected]> committer: vstinner <[email protected]> date: 2026-11-10T00:35:37Z summary:
[3.14] gh-161705: Prevent shadowing stdlib in gzip/tarfile/zipfile CLI (#161706) (#161719) gh-161705: Prevent shadowing stdlib in gzip/tarfile/zipfile CLI (#161706) Remove sys.path[0] in gzip, tarfile and zipfile command line interface to prevent shadowing standard library modules from extracted files. * Add sys._path0 variable. * pymain_sys_path_add_path0() sets sys._path0. * Add runpy._remove_sys_path0(). * gzip, tarfile and zipfile main functions now call _remove_sys_path0() to prevent shadowing stdlib modules from extracted files. (cherry picked from commit e891a9117985888df3e5d48145cf07ddda098335) Co-authored-by: Gregory P. Smith <[email protected]> Co-authored-by: Ethan Furman <[email protected]> Co-authored-by: Kirill Podoprigora <[email protected]> files: A Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst M Doc/c-api/init_config.rst M Doc/library/sys.rst M Doc/using/cmdline.rst M Lib/gzip.py M Lib/runpy.py M Lib/tarfile.py M Lib/test/test_runpy.py M Lib/test/test_sys.py M Lib/test/test_zipfile/test_core.py M Lib/zipfile/__init__.py M Modules/main.c M Python/pathconfig.c M Python/sysmodule.c diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst index 7f2beb365dc1f91..f43c88f4631f427 100644 --- a/Doc/c-api/init_config.rst +++ b/Doc/c-api/init_config.rst @@ -1216,8 +1216,8 @@ PyConfig .. c:member:: int safe_path - If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe path to - :data:`sys.path` at startup: + If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe + :data:`sys._path0` to :data:`sys.path` at startup: * If :c:member:`argv[0] <PyConfig.argv>` is equal to ``L"-m"`` (``python -m module``), prepend the current working directory. diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index 9fc274382ff845a..387edfbbc6b7628 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1516,6 +1516,24 @@ always available. Unless explicitly noted otherwise, all variables are read-only * Module :mod:`site` This describes how to use .pth files to extend :data:`sys.path`. +.. data:: _path0 + + Path prepended to :data:`sys.path` at Python startup: + + * Current directory if running a module + with ``python -m module`` command line. + * Script's directory if running a script + with ``python script.py``. + * Otherwise an empty string, which means the current working directory, + for ``python -c code`` or ``python`` (REPL) command line. + + If :option:`-P` or :option:`-I` command line options are used or if + :envvar:`PYTHONSAFEPATH` environment variable is used, :data:`sys._path0` is + ``None`` and :data:`sys.path` is left unchanged. + + .. versionadded:: next + + .. data:: path_hooks A list of callables that take a path argument to try to create a diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index fd9605c4fc28df8..6f975b40e226857 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -313,10 +313,12 @@ Miscellaneous options Run Python in isolated mode. This also implies :option:`-E`, :option:`-P` and :option:`-s` options. - In isolated mode :data:`sys.path` contains neither the script's directory nor - the user's site-packages directory. All ``PYTHON*`` environment - variables are ignored, too. Further restrictions may be imposed to prevent - the user from injecting malicious code. + In isolated mode, Python does not prepend the potentially unsafe path + :data:`sys._path0` to :data:`sys.path`, and ignores all ``PYTHON*`` + environment variables, e.g. :envvar:`PYTHONPATH` and :envvar:`PYTHONHOME`, + that might be set. + Further restrictions may be imposed to prevent the user from injecting + malicious code. .. versionadded:: 3.4 @@ -344,14 +346,8 @@ Miscellaneous options .. option:: -P - Don't prepend a potentially unsafe path to :data:`sys.path`: - - * ``python -m module`` command line: Don't prepend the current working - directory. - * ``python script.py`` command line: Don't prepend the script's directory. - If it's a symbolic link, resolve symbolic links. - * ``python -c code`` and ``python`` (REPL) command lines: Don't prepend an - empty string, which means the current working directory. + Don't prepend the potentially unsafe path :data:`sys._path0` to + :data:`sys.path`. See also the :envvar:`PYTHONSAFEPATH` environment variable, and :option:`-E` and :option:`-I` (isolated) options. @@ -784,7 +780,9 @@ conflict. .. envvar:: PYTHONSAFEPATH If this is set to a non-empty string, don't prepend a potentially unsafe - path to :data:`sys.path`: see the :option:`-P` option for details. + path :data:`sys._path0` to :data:`sys.path`. + + See also the :option:`-P` option. .. versionadded:: 3.11 diff --git a/Lib/gzip.py b/Lib/gzip.py index 721c6e2f49eab9d..c366c44999d5dab 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -695,6 +695,11 @@ def decompress(data): def main(): from argparse import ArgumentParser + import runpy + + # Prevent shadowing a stdlib module from the extracted file + runpy._remove_sys_path0() + parser = ArgumentParser(description= "A simple command line interface for the gzip module: act like gzip, " "but do not delete the input file.", diff --git a/Lib/runpy.py b/Lib/runpy.py index 1d5ecf0cf15bc0e..d4edf2ed096c030 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -317,6 +317,26 @@ def run_path(path_name, init_globals=None, run_name=None): pass +def _remove_sys_path0(): + """ + Remove sys._path0 from sys.path. Do nothing if sys._path0 is + sys._stdlib_dir or if it has already been removed. Similar to using -P + (safe path) command line option. + """ + path0 = sys._path0 + if path0 is None: + return + + if path0 == sys._stdlib_dir: + return + + # Remove all path0 copies, not only the first one. + # We are intentionally not trying to normalize & match alternate + # representations of path0, this is only intended to remove copies + # of the exact value the runtime itself may have added. + sys.path[:] = [path for path in sys.path if path != path0] + + if __name__ == "__main__": # Run the module specified as the next command line argument if len(sys.argv) < 2: diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 3a2673d7f112c79..18055b987b9ffcd 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -3136,6 +3136,10 @@ def is_tarfile(name): def main(): import argparse + import runpy + + # Prevent shadowing stdlib modules from extracted files + runpy._remove_sys_path0() description = 'A simple command-line interface for tarfile module.' parser = argparse.ArgumentParser(description=description, color=True) diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index 7eb4cee36107ab0..9eb3224a9b5fceb 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -20,6 +20,8 @@ requires_subprocess, verbose, ) +from test import support +from test.support import os_helper from test.support.import_helper import forget, make_legacy_pyc, unload from test.support.os_helper import create_empty_file, temp_dir, FakePath from test.support.script_helper import make_script, make_zip_script @@ -875,5 +877,95 @@ def test_pymain_run_module(self): self.assertSigInt(["-m", ham.stem], cwd=ham.parent) +class MiscTests(unittest.TestCase): + def test_remove_sys_path0(self): + # Check that all path0 copies are removed + PATH0 = '/path/zero' + STDLIB_PATH = '/to/stdlib' + OTHER_PATH = '/other/path' + sys_path = [PATH0, STDLIB_PATH, PATH0, '', OTHER_PATH, PATH0] + expected = [STDLIB_PATH, '', OTHER_PATH] + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), + support.swap_attr(sys, '_path0', PATH0), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + # Calling it twice does nothing + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + # Do nothing if sys._path0 is sys._stdlib_dir + sys_path = ['', STDLIB_PATH, OTHER_PATH] + expected = sys_path.copy() + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), + support.swap_attr(sys, '_path0', STDLIB_PATH), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + # Do nothing if sys._path0 is None + sys_path = ['', STDLIB_PATH, OTHER_PATH] + expected = sys_path.copy() + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), + support.swap_attr(sys, '_path0', None), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + def run_python(self, *args, PYTHONPATH=None): + cmd = [sys.executable, *args] + env = support.make_clean_env() + if PYTHONPATH: + env['PYTHONPATH'] = PYTHONPATH + proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + + @support.requires_subprocess() + def test_remove_sys_path0_run_python(self): + CODE = textwrap.dedent(""" + import runpy, sys + + # Remove sys.path[0] + orig_path = sys.path.copy() + path0 = sys._path0 + runpy._remove_sys_path0() + + if path0 is not None and path0 in sys.path: + raise Exception(f"path0 {path0!r} found in sys.path {sys.path!r}") + if not sys.flags.safe_path: + if sys.path == orig_path: + raise Exception("sys.path was not changed") + else: + if sys.path != orig_path: + raise Exception("sys.path was changed") + + # Calling it twice must do nothing + clean_path = sys.path.copy() + runpy._remove_sys_path0() + if sys.path != clean_path: + raise Exception("sys.path was changed") + """) + + # Test "python -c code" + self.run_python('-c', CODE) + self.run_python('-P', '-c', CODE) + + with tempfile.TemporaryDirectory() as tmpdir: + name = 'test_remove_sys_path0' + filename = os.path.join(tmpdir, f'{name}.py') + with open(filename, 'w') as fp: + fp.write(CODE) + + # Test "python script.py" + self.run_python(filename) + self.run_python('-P', filename) + + with os_helper.change_cwd(tmpdir): + # Test "python -m module" + self.run_python('-m', name) + self.run_python('-P', '-m', name, PYTHONPATH=tmpdir) + + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index e7f58586c348cee..4da9f3c3179cd1b 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1453,6 +1453,15 @@ class PythonType: self.assertFalse(is_immortal(b'abc'.decode())) self.assertFalse(is_immortal('abc'.encode())) + def test_path(self): + self.assertIsInstance(sys.path, list) + for path in sys.path: + self.assertIsInstance(path, str) + + def test_path0(self): + self.assertTrue(sys._path0 is None or isinstance(sys._path0, str), + sys._path0) + @test.support.cpython_only class UnraisableHookTest(unittest.TestCase): diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 8336d0f27a7cab7..e9710da275f99a2 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -53,6 +53,15 @@ def get_files(test): test.assertFalse(f.closed) [email protected] +def save_restore_sys_path(): + old_path = sys.path.copy() + try: + yield + finally: + sys.path[:] = old_path + + class LazyImportTest(unittest.TestCase): @cpython_only def test_lazy_import(self): @@ -3817,17 +3826,18 @@ def test_add_comment(self): def test_cli_with_metadata_encoding(self): errmsg = "Non-conforming encodings not supported with -c." args = ["--metadata-encoding=shift_jis", "-c", "nonesuch", "nonesuch"] - with captured_stdout() as stdout: - with captured_stderr() as stderr: + with (captured_stdout() as stdout, + captured_stderr() as stderr, + save_restore_sys_path()): self.assertRaises(SystemExit, zipfile.main, args) self.assertEqual(stdout.getvalue(), "") self.assertIn(errmsg, stderr.getvalue()) - with captured_stdout() as stdout: + with captured_stdout() as stdout, save_restore_sys_path(): zipfile.main(["--metadata-encoding=shift_jis", "-t", TESTFN]) listing = stdout.getvalue() - with captured_stdout() as stdout: + with captured_stdout() as stdout, save_restore_sys_path(): zipfile.main(["--metadata-encoding=shift_jis", "-l", TESTFN]) listing = stdout.getvalue() for name in self.file_names: @@ -3846,7 +3856,9 @@ def test_cli_with_metadata_encoding_extract(self): except UnicodeEncodeError: self.skipTest(f'cannot encode file name {fn!a}') - zipfile.main(["--metadata-encoding=shift_jis", "-e", TESTFN, TESTFN2]) + with save_restore_sys_path(): + zipfile.main(["--metadata-encoding=shift_jis", "-e", + TESTFN, TESTFN2]) listing = os.listdir(TESTFN2) for name in self.file_names: self.assertIn(name, listing) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index cf0c69687be94f7..d4b104056115f86 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -2413,6 +2413,10 @@ def _compile(file, optimize=-1): def main(args=None): import argparse + import runpy + + # Prevent shadowing stdlib modules from extracted files + runpy._remove_sys_path0() description = 'A simple command-line interface for zipfile module.' parser = argparse.ArgumentParser(description=description, color=True) diff --git a/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst new file mode 100644 index 000000000000000..d9a862ed858e012 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst @@ -0,0 +1,6 @@ +.. cve: CVE-2026-115791 + +Remove ``sys.path[0]`` in :mod:`gzip`, :mod:`tarfile` and :mod:`zipfile` +command line interface to prevent shadowing standard library modules from +extracted files. Issue reported by Jelle Zijlstra, patch by Gregory P. +Smith and Victor Stinner. diff --git a/Modules/main.c b/Modules/main.c index d2c3b84f3e12cc6..facfb9a3de8a808 100644 --- a/Modules/main.c +++ b/Modules/main.c @@ -181,6 +181,10 @@ pymain_sys_path_add_path0(PyInterpreterState *interp, PyObject *path0) return -1; } + if (PyDict_SetItemString(sysdict, "_path0", path0) < 0) { + return -1; + } + if (PyList_Insert(sys_path, 0, path0)) { return -1; } diff --git a/Python/pathconfig.c b/Python/pathconfig.c index 92360c1bb024204..b21bb9c0177b9de 100644 --- a/Python/pathconfig.c +++ b/Python/pathconfig.c @@ -332,7 +332,7 @@ Py_GetProgramName(void) /* Compute module search path from argv[0] or the current working - directory ("-m module" case) which will be prepended to sys.argv: + directory ("-m module" case) which will be prepended to sys.path: sys.path[0]. Return 1 if the path is correctly resolved and written into *path0_p. diff --git a/Python/sysmodule.c b/Python/sysmodule.c index ec1fc6141093f20..0194d2467b2e567 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3961,6 +3961,8 @@ _PySys_InitCore(PyThreadState *tstate, PyObject *sysdict) SET_SYS("path_importer_cache", PyDict_New()); SET_SYS("path_hooks", PyList_New(0)); + SET_SYS("_path0", Py_None); + if (_PyErr_Occurred(tstate)) { goto err_occurred; } _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
