diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst index ad1cd3f4f066814..d21b72b386cfa71 100644 --- a/Doc/c-api/init_config.rst +++ b/Doc/c-api/init_config.rst @@ -561,8 +561,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] ` 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 28c07f6c676c0eb..a34ebb9873ab80e 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1279,6 +1279,24 @@ always available. * 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 a1a7073cf5b1c79..e59dad3e8d0aa65 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -300,10 +300,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 @@ -331,14 +333,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. @@ -635,7 +631,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 5b20e5ba698ee99..dfc67688d79eccc 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -618,6 +618,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 54fc136d4074f20..e88b9fd1ded1908 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -314,6 +314,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 a35773017e5cb7b..11eb2c75950e605 100755 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2975,6 +2975,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) diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 9270f3f8d622b50..413450488dd7d4d 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -2289,3 +2289,13 @@ def control_characters_c0() -> list[str]: C0 control characters defined as the byte range 0x00-0x1F, and 0x7F. """ return [chr(c) for c in range(0x00, 0x20)] + ["\x7F"] + + +def make_clean_env() -> dict[str, str]: + clean_env = os.environ.copy() + for k in clean_env.copy(): + if k.startswith("PYTHON"): + clean_env.pop(k) + clean_env.pop("FORCE_COLOR", None) + clean_env.pop("NO_COLOR", None) + return clean_env diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index 628c8cae38a7519..105861f2a13c3d3 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -13,6 +13,8 @@ import unittest import warnings from test.support import no_tracing, verbose, requires_subprocess, requires_resource +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 from test.support.script_helper import make_script, make_zip_script @@ -835,5 +837,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 3f65d5e62fc5d4d..2e013275a7312b1 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1186,6 +1186,15 @@ def test_stdlib_dir(self): self.assertEqual(os.path.normpath(sys._stdlib_dir), os.path.normpath(expected)) + 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.py b/Lib/test/test_zipfile.py index 5726feb44fe7acc..63cb05c17a6a395 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -51,6 +51,16 @@ def get_files(test): yield f test.assertFalse(f.closed) + +@contextlib.contextmanager +def save_restore_sys_path(): + old_path = sys.path.copy() + try: + yield + finally: + sys.path[:] = old_path + + class AbstractTestsWithSourceFile: @classmethod def setUpClass(cls): @@ -4017,17 +4027,18 @@ def test_write_with_metadata_encoding(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: @@ -4046,7 +4057,9 @@ def test_cli_with_metadata_encoding_extract(self): except UnicodeEncodeError: self.skipTest(f'cannot encode file name {fn!r}') - 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.py b/Lib/zipfile.py index f06dd2e3a4065cb..395a0234b3b0013 100644 --- a/Lib/zipfile.py +++ b/Lib/zipfile.py @@ -2570,6 +2570,10 @@ def parent(self): 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) 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 f6dade1e409512c..2fcef520c82e84c 100644 --- a/Modules/main.c +++ b/Modules/main.c @@ -174,6 +174,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 be0f97c4b204a91..cd725b029228d6f 100644 --- a/Python/pathconfig.c +++ b/Python/pathconfig.c @@ -380,7 +380,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 560a6805fe323d0..0a8061c128e7ffc 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -3018,6 +3018,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_NewRef(Py_None)); + if (_PyErr_Occurred(tstate)) { goto err_occurred; }