Skip to content

Commit 138b19a

Browse files
authored
Merge pull request #8517 from bluetech/backport-mktmp
[6.2.x] Fix minor temporary directory security issue
2 parents 12e7db8 + 822686e commit 138b19a

File tree

5 files changed

+92
-23
lines changed

5 files changed

+92
-23
lines changed

changelog/8414.bugfix.rst

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
pytest used to create directories under ``/tmp`` with world-readable
2+
permissions. This means that any user in the system was able to read
3+
information written by tests in temporary directories (such as those created by
4+
the ``tmp_path``/``tmpdir`` fixture). Now the directories are created with
5+
private permissions.
6+
7+
pytest used silenty use a pre-existing ``/tmp/pytest-of-<username>`` directory,
8+
even if owned by another user. This means another user could pre-create such a
9+
directory and gain control of another user's temporary directory. Now such a
10+
condition results in an error.

src/_pytest/pathlib.py

+4-11
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,6 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
6464
return path.joinpath(".lock")
6565

6666

67-
def ensure_reset_dir(path: Path) -> None:
68-
"""Ensure the given path is an empty directory."""
69-
if path.exists():
70-
rm_rf(path)
71-
path.mkdir()
72-
73-
7467
def on_rm_rf_error(func, path: str, exc, *, start_path: Path) -> bool:
7568
"""Handle known read-only errors during rmtree.
7669
@@ -214,15 +207,15 @@ def _force_symlink(
214207
pass
215208

216209

217-
def make_numbered_dir(root: Path, prefix: str) -> Path:
210+
def make_numbered_dir(root: Path, prefix: str, mode: int = 0o700) -> Path:
218211
"""Create a directory with an increased number as suffix for the given prefix."""
219212
for i in range(10):
220213
# try up to 10 times to create the folder
221214
max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1)
222215
new_number = max_existing + 1
223216
new_path = root.joinpath(f"{prefix}{new_number}")
224217
try:
225-
new_path.mkdir()
218+
new_path.mkdir(mode=mode)
226219
except Exception:
227220
pass
228221
else:
@@ -354,13 +347,13 @@ def cleanup_numbered_dir(
354347

355348

356349
def make_numbered_dir_with_cleanup(
357-
root: Path, prefix: str, keep: int, lock_timeout: float
350+
root: Path, prefix: str, keep: int, lock_timeout: float, mode: int,
358351
) -> Path:
359352
"""Create a numbered dir with a cleanup lock and remove old ones."""
360353
e = None
361354
for i in range(10):
362355
try:
363-
p = make_numbered_dir(root, prefix)
356+
p = make_numbered_dir(root, prefix, mode)
364357
lock_path = create_cleanup_lock(p)
365358
register_cleanup_lock_removal(lock_path)
366359
except Exception as exc:

src/_pytest/pytester.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1426,7 +1426,7 @@ def runpytest_subprocess(self, *args, timeout: Optional[float] = None) -> RunRes
14261426
:rtype: RunResult
14271427
"""
14281428
__tracebackhide__ = True
1429-
p = make_numbered_dir(root=self.path, prefix="runpytest-")
1429+
p = make_numbered_dir(root=self.path, prefix="runpytest-", mode=0o700)
14301430
args = ("--basetemp=%s" % p,) + args
14311431
plugins = [x for x in self.plugins if isinstance(x, str)]
14321432
if plugins:
@@ -1445,7 +1445,7 @@ def spawn_pytest(
14451445
The pexpect child is returned.
14461446
"""
14471447
basetemp = self.path / "temp-pexpect"
1448-
basetemp.mkdir()
1448+
basetemp.mkdir(mode=0o700)
14491449
invoke = " ".join(map(str, self._getpytestargs()))
14501450
cmd = f"{invoke} --basetemp={basetemp} {string}"
14511451
return self.spawn(cmd, expect_timeout=expect_timeout)

src/_pytest/tmpdir.py

+35-10
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import attr
99
import py
1010

11-
from .pathlib import ensure_reset_dir
1211
from .pathlib import LOCK_TIMEOUT
1312
from .pathlib import make_numbered_dir
1413
from .pathlib import make_numbered_dir_with_cleanup
14+
from .pathlib import rm_rf
1515
from _pytest.compat import final
1616
from _pytest.config import Config
1717
from _pytest.deprecated import check_ispytest
@@ -90,20 +90,22 @@ def mktemp(self, basename: str, numbered: bool = True) -> Path:
9090
basename = self._ensure_relative_to_basetemp(basename)
9191
if not numbered:
9292
p = self.getbasetemp().joinpath(basename)
93-
p.mkdir()
93+
p.mkdir(mode=0o700)
9494
else:
95-
p = make_numbered_dir(root=self.getbasetemp(), prefix=basename)
95+
p = make_numbered_dir(root=self.getbasetemp(), prefix=basename, mode=0o700)
9696
self._trace("mktemp", p)
9797
return p
9898

9999
def getbasetemp(self) -> Path:
100-
"""Return base temporary directory."""
100+
"""Return the base temporary directory, creating it if needed."""
101101
if self._basetemp is not None:
102102
return self._basetemp
103103

104104
if self._given_basetemp is not None:
105105
basetemp = self._given_basetemp
106-
ensure_reset_dir(basetemp)
106+
if basetemp.exists():
107+
rm_rf(basetemp)
108+
basetemp.mkdir(mode=0o700)
107109
basetemp = basetemp.resolve()
108110
else:
109111
from_env = os.environ.get("PYTEST_DEBUG_TEMPROOT")
@@ -112,14 +114,37 @@ def getbasetemp(self) -> Path:
112114
# use a sub-directory in the temproot to speed-up
113115
# make_numbered_dir() call
114116
rootdir = temproot.joinpath(f"pytest-of-{user}")
115-
rootdir.mkdir(exist_ok=True)
117+
rootdir.mkdir(mode=0o700, exist_ok=True)
118+
# Because we use exist_ok=True with a predictable name, make sure
119+
# we are the owners, to prevent any funny business (on unix, where
120+
# temproot is usually shared).
121+
# Also, to keep things private, fixup any world-readable temp
122+
# rootdir's permissions. Historically 0o755 was used, so we can't
123+
# just error out on this, at least for a while.
124+
if hasattr(os, "getuid"):
125+
rootdir_stat = rootdir.stat()
126+
uid = os.getuid()
127+
# getuid shouldn't fail, but cpython defines such a case.
128+
# Let's hope for the best.
129+
if uid != -1:
130+
if rootdir_stat.st_uid != uid:
131+
raise OSError(
132+
f"The temporary directory {rootdir} is not owned by the current user. "
133+
"Fix this and try again."
134+
)
135+
if (rootdir_stat.st_mode & 0o077) != 0:
136+
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
116137
basetemp = make_numbered_dir_with_cleanup(
117-
prefix="pytest-", root=rootdir, keep=3, lock_timeout=LOCK_TIMEOUT
138+
prefix="pytest-",
139+
root=rootdir,
140+
keep=3,
141+
lock_timeout=LOCK_TIMEOUT,
142+
mode=0o700,
118143
)
119144
assert basetemp is not None, basetemp
120-
self._basetemp = t = basetemp
121-
self._trace("new basetemp", t)
122-
return t
145+
self._basetemp = basetemp
146+
self._trace("new basetemp", basetemp)
147+
return basetemp
123148

124149

125150
@final

testing/test_tmpdir.py

+41
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,44 @@ def test(tmp_path):
445445
# running a second time and ensure we don't crash
446446
result = pytester.runpytest("--basetemp=tmp")
447447
assert result.ret == 0
448+
449+
450+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
451+
def test_tmp_path_factory_create_directory_with_safe_permissions(
452+
tmp_path: Path, monkeypatch,
453+
) -> None:
454+
"""Verify that pytest creates directories under /tmp with private permissions."""
455+
# Use the test's tmp_path as the system temproot (/tmp).
456+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
457+
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
458+
basetemp = tmp_factory.getbasetemp()
459+
460+
# No world-readable permissions.
461+
assert (basetemp.stat().st_mode & 0o077) == 0
462+
# Parent too (pytest-of-foo).
463+
assert (basetemp.parent.stat().st_mode & 0o077) == 0
464+
465+
466+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
467+
def test_tmp_path_factory_fixes_up_world_readable_permissions(
468+
tmp_path: Path, monkeypatch,
469+
) -> None:
470+
"""Verify that if a /tmp/pytest-of-foo directory already exists with
471+
world-readable permissions, it is fixed.
472+
473+
pytest used to mkdir with such permissions, that's why we fix it up.
474+
"""
475+
# Use the test's tmp_path as the system temproot (/tmp).
476+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
477+
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
478+
basetemp = tmp_factory.getbasetemp()
479+
480+
# Before - simulate bad perms.
481+
os.chmod(basetemp.parent, 0o777)
482+
assert (basetemp.parent.stat().st_mode & 0o077) != 0
483+
484+
tmp_factory = TempPathFactory(None, lambda *args: None, _ispytest=True)
485+
basetemp = tmp_factory.getbasetemp()
486+
487+
# After - fixed.
488+
assert (basetemp.parent.stat().st_mode & 0o077) == 0

0 commit comments

Comments
 (0)