From fdc6d75b080fe746ad6fc121dd40db40dedf3289 Mon Sep 17 00:00:00 2001 From: cploujoux <11555461+cploujoux@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:54:27 -0700 Subject: [PATCH 1/3] chore(deps): bump pytest and requests in jupyter-server image pytest 8.3.5 -> 9.0.3 (GHSA-6w46-j5rx-g56g, CVE-2025-71176): the base temp directory /tmp/pytest-of-{user} was resolved without checking whether it was a symlink, letting a local attacker redirect another user's pytest temp artifacts via a TOCTOU symlink swap. Fixed in pytest-dev/pytest#14343. Major bump; reviewed the 8.4.0 -> 9.0.3 changelog for breaking changes (Python 3.9 support dropped -- this image is on 3.12; PytestRemovedIn9 deprecations now error by default; overlapping-path CLI arg handling changed) -- none touch how this image invokes pytest (`pytest tests/ -v`, one path argument, no deprecated APIs in tests/). Full existing test suite re-run clean against the new version. requests 2.32.4 -> 2.33.0 (GHSA-gc5v-m9x4-r6x2, CVE-2026-25645): predictable temp-file reuse in requests.utils.extract_zipped_paths(); upstream advisory states standard usage of the library is unaffected. Neither requirements.txt file's requests import is reached from our own server code (server/*.py uses httpx) or calls extract_zipped_paths -- shipped for user code inside the sandbox. urllib3==2.7.0 (pinned separately) already satisfies 2.33.0's `urllib3<3,>=1.26` requirement, so no companion bump was forced. Co-Authored-By: Claude Opus 5 (1M context) --- hub/jupyter-server/requirements.txt | 4 ++-- hub/jupyter-server/server/requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hub/jupyter-server/requirements.txt b/hub/jupyter-server/requirements.txt index d8a7b37..8aa66ee 100644 --- a/hub/jupyter-server/requirements.txt +++ b/hub/jupyter-server/requirements.txt @@ -24,10 +24,10 @@ opencv-python==4.11.0.86 openpyxl==3.1.5 plotly==6.9.0 kaleido==1.3.0 -pytest==8.3.5 +pytest==9.0.3 python-docx==1.2.0 pytz==2026.2 -requests==2.32.4 +requests==2.33.0 scikit-image==0.26.0 scikit-learn==1.9.0 scipy==1.13.1 # bump blocked by gensim diff --git a/hub/jupyter-server/server/requirements.txt b/hub/jupyter-server/server/requirements.txt index 550db2b..845a74a 100644 --- a/hub/jupyter-server/server/requirements.txt +++ b/hub/jupyter-server/server/requirements.txt @@ -2,6 +2,6 @@ fastapi==0.111.0 httpx==0.28.1 websockets==12.0 uvicorn[standard]==0.30.1 -requests==2.32.4 +requests==2.33.0 pydantic==2.13.4 From 7b55c314ad55a485d5ceea5ed64e907a347e99b1 Mon Sep 17 00:00:00 2001 From: cploujoux <11555461+cploujoux@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:54:27 -0700 Subject: [PATCH 2/3] test: smoke test pytest's basetemp symlink rejection (CVE-2025-71176) Reproduces pytest's own upstream regression test for the fix (testing/test_tmpdir.py::test_tmp_path_factory_doesnt_follow_symlinks) via the internal _pytest.tmpdir.TempPathFactory API and the PYTEST_DEBUG_TEMPROOT escape hatch: replaces the pytest-of-{user} base directory with a symlink to an attacker-controlled directory and asserts getbasetemp() now raises OSError instead of silently following it. Revert-checked against pytest==8.3.5: the same test fails with "DID NOT RAISE ", confirming it actually discriminates the fix rather than passing regardless of version. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_pytest_tmpdir_security.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 hub/jupyter-server/tests/test_pytest_tmpdir_security.py diff --git a/hub/jupyter-server/tests/test_pytest_tmpdir_security.py b/hub/jupyter-server/tests/test_pytest_tmpdir_security.py new file mode 100644 index 0000000..0744bb4 --- /dev/null +++ b/hub/jupyter-server/tests/test_pytest_tmpdir_security.py @@ -0,0 +1,81 @@ +"""Smoke test for the ``pytest`` package shipped in this image's Python +environment (see ``requirements.txt``). + +``pytest`` is not imported by our own server code (``server/*.py``) -- like +``aiohttp`` (see ``test_aiohttp_smoke.py``), it is installed here so that +user code running *inside* a jupyter-server sandbox can write and run its +own tests. That is the "production" surface this test exercises: pytest's +own ``basetemp`` resolution, exactly as it runs for any test session in this +image. + +Bumped for GHSA-6w46-j5rx-g56g / CVE-2025-71176 (pytest 8.3.5 -> 9.0.3): +pytest's base temp directory used the predictable name +``/tmp/pytest-of-{user}``. If that path was ever replaced by a symlink +(TOCTOU / symlink-swapping: a local attacker races to recreate the +directory as a symlink to somewhere they control after it's removed, or +plants it before pytest is ever run under that user), pytest would silently +follow the symlink and use whatever directory it points at, letting a local +attacker redirect another user's test artifacts. Fixed by +pytest-dev/pytest#14343: ``TempPathFactory.getbasetemp()`` now stats +``pytest-of-{user}`` with ``follow_symlinks=False`` and raises ``OSError`` +if it is a symlink at all. + +This test reproduces pytest's own upstream regression test for the fix +(testing/test_tmpdir.py::test_tmp_path_factory_doesnt_follow_symlinks) via +the internal ``_pytest.tmpdir.TempPathFactory`` API and the +``PYTEST_DEBUG_TEMPROOT`` escape hatch, which is the only way to point +pytest's temproot resolution at a throwaway directory instead of the real +``/tmp``. This is a deliberate coupling to an internal, undocumented pytest +API rather than a public one -- there is no public entry point for this +behavior -- so a future pytest release could rename or remove it with +nothing wrong in the dependency; if this test starts failing to *import* +(as opposed to failing its assertion), that is the first thing to check. + +Revert-check: run against pytest==8.3.5 (the version this replaces), the +same call does not raise -- it silently accepts the symlink and returns a +path underneath the attacker-controlled directory. See the remediation +report for the full transcript. +""" + +import os +import shutil +from pathlib import Path + +import pytest + + +@pytest.mark.skipif( + not hasattr(os, "getuid") or os.stat not in os.supports_follow_symlinks, + reason="checks unix permissions and symlinks", +) +def test_tmp_path_factory_rejects_symlinked_basetemp(tmp_path: Path, monkeypatch) -> None: + """A pytest-of-{user} base directory that is a symlink must be rejected, + not silently followed (GHSA-6w46-j5rx-g56g / CVE-2025-71176).""" + from _pytest.tmpdir import TempPathFactory + + attacker_controlled = tmp_path / "attacker_controlled" + attacker_controlled.mkdir() + + # Point pytest's temproot resolution at this test's own tmp_path instead + # of the real /tmp, so the test is hermetic (no writes outside tmp_path, + # no interaction with any real /tmp/pytest-of-* directory on the host). + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + + # First resolution creates the real pytest-of-{user} directory; capture + # its path, then remove it and replace it with a symlink to a directory + # an "attacker" controls. + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + pytest_of_user = tmp_factory.getbasetemp().parent + assert "pytest-of-" in str(pytest_of_user) + shutil.rmtree(pytest_of_user) + pytest_of_user.symlink_to(attacker_controlled) + + # A fresh factory must now refuse to use it. + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + with pytest.raises(OSError, match=r"temporary directory .* is a symbolic link"): + tmp_factory.getbasetemp() + + +def test_pytest_version_is_patched() -> None: + """Belt-and-suspenders version assertion alongside the lockfile check.""" + assert tuple(int(p) for p in pytest.__version__.split(".")[:2]) >= (9, 0) From 075183034ac586d762489dd5460b9af8b7d8adf2 Mon Sep 17 00:00:00 2001 From: cploujoux <11555461+cploujoux@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:02:05 -0700 Subject: [PATCH 3/3] test: skip (not fail) the pytest internals smoke test if the internal API moves Flagged by devin-ai-integration: _pytest.tmpdir.TempPathFactory is internal and undocumented, so a future pytest release reordering/renaming its constructor args (or removing it) would turn this test into a hard CI failure on an unrelated, otherwise-healthy future dependency PR. Guard the import and construction behind a helper that pytest.skip()s with an explanatory message on ImportError/TypeError instead, leaving the actual security assertion (OSError on a symlinked basetemp) as a hard failure -- that one failing would mean the property genuinely regressed. Verified both guard paths trigger a skip (not a failure) by monkeypatching the module's TempPathFactory to None and to a signature-mismatched stub; reran the full suite (11 passed) to confirm the happy path is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_pytest_tmpdir_security.py | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/hub/jupyter-server/tests/test_pytest_tmpdir_security.py b/hub/jupyter-server/tests/test_pytest_tmpdir_security.py index 0744bb4..c195a08 100644 --- a/hub/jupyter-server/tests/test_pytest_tmpdir_security.py +++ b/hub/jupyter-server/tests/test_pytest_tmpdir_security.py @@ -28,8 +28,12 @@ ``/tmp``. This is a deliberate coupling to an internal, undocumented pytest API rather than a public one -- there is no public entry point for this behavior -- so a future pytest release could rename or remove it with -nothing wrong in the dependency; if this test starts failing to *import* -(as opposed to failing its assertion), that is the first thing to check. +nothing wrong in the dependency. That specific failure mode (the internal +API moving or its constructor signature changing) is guarded below to +``skip`` with an explanatory message instead of failing the CI gate on an +unrelated future dependency PR; an assertion failure inside the test body +(the ``OSError``/message-match not raising) is left as a hard failure, +since that would mean the actual security property regressed. Revert-check: run against pytest==8.3.5 (the version this replaces), the same call does not raise -- it silently accepts the symlink and returns a @@ -43,6 +47,42 @@ import pytest +# _pytest.tmpdir.TempPathFactory is an internal, undocumented API (see module +# docstring) -- a future pytest release can rename/remove it or reorder its +# positional constructor arguments with nothing wrong in the dependency being +# bumped. Import it defensively so that happening turns this test into an +# informative skip instead of a hard failure that would block an unrelated +# future dependency PR's CI gate (flagged by devin-ai-integration on this PR). +try: + from _pytest.tmpdir import TempPathFactory + + _import_error: Exception | None = None +except ImportError as exc: # pragma: no cover - depends on pytest internals + TempPathFactory = None # type: ignore[assignment,misc] + _import_error = exc + + +def _new_temp_path_factory() -> "TempPathFactory": + """Construct a TempPathFactory, skipping (not failing) the test if + pytest's internal constructor shape has changed -- see module docstring + and the guard above.""" + if TempPathFactory is None: + pytest.skip( + "pytest moved or removed _pytest.tmpdir.TempPathFactory " + f"({_import_error}); this test targets an internal pytest API " + "and needs re-deriving against the new pytest internals -- " + "see this file's module docstring" + ) + try: + return TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + except TypeError as exc: + pytest.skip( + f"_pytest.tmpdir.TempPathFactory's constructor signature changed " + f"({exc}); this test targets an internal pytest API and needs " + "re-deriving against the new pytest internals -- see this " + "file's module docstring" + ) + @pytest.mark.skipif( not hasattr(os, "getuid") or os.stat not in os.supports_follow_symlinks, @@ -51,8 +91,6 @@ def test_tmp_path_factory_rejects_symlinked_basetemp(tmp_path: Path, monkeypatch) -> None: """A pytest-of-{user} base directory that is a symlink must be rejected, not silently followed (GHSA-6w46-j5rx-g56g / CVE-2025-71176).""" - from _pytest.tmpdir import TempPathFactory - attacker_controlled = tmp_path / "attacker_controlled" attacker_controlled.mkdir() @@ -64,14 +102,14 @@ def test_tmp_path_factory_rejects_symlinked_basetemp(tmp_path: Path, monkeypatch # First resolution creates the real pytest-of-{user} directory; capture # its path, then remove it and replace it with a symlink to a directory # an "attacker" controls. - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = _new_temp_path_factory() pytest_of_user = tmp_factory.getbasetemp().parent assert "pytest-of-" in str(pytest_of_user) shutil.rmtree(pytest_of_user) pytest_of_user.symlink_to(attacker_controlled) # A fresh factory must now refuse to use it. - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = _new_temp_path_factory() with pytest.raises(OSError, match=r"temporary directory .* is a symbolic link"): tmp_factory.getbasetemp()