Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Doc/c-api/init_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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] <PyConfig.argv>` is equal to ``L"-m"``
(``python -m module``), prepend the current working directory.
Expand Down
18 changes: 18 additions & 0 deletions Doc/library/sys.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 11 additions & 13 deletions Doc/using/cmdline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions Lib/gzip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
20 changes: 20 additions & 0 deletions Lib/runpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions Lib/tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
92 changes: 92 additions & 0 deletions Lib/test/test_runpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
9 changes: 9 additions & 0 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
23 changes: 18 additions & 5 deletions Lib/test/test_zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Lib/zipfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions Modules/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion Python/pathconfig.c
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Python/sysmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading