PDM: Project-Controlled `.pdm-plugins` Content Executes Before CLI Parsing
Description
PDM automatically loads project-local plugin paths from .pdm-plugins, allowing arbitrary code execution via malicious .pth files before CLI handling.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
PDM automatically loads project-local plugin paths from .pdm-plugins, allowing arbitrary code execution via malicious .pth files before CLI handling.
Vulnerability
The vulnerability resides in the Core.__init__() method, specifically in load_plugins() and _add_project_plugins_library() at src/pdm/core.py:74-82 and 310-352 [1][2]. During initialization, PDM adds the project-local .pdm-plugins directory to the Python path using site.addsitedir(). This function processes .pth files, and lines starting with import are executed immediately [1]. Any project containing a crafted .pth file in .pdm-plugins will trigger arbitrary code execution when any PDM command (e.g., pdm --version) is run from that directory [1][2].
Exploitation
An attacker needs only to create a malicious .pth file inside the .pdm-plugins directory of a repository. No special authentication or user interaction beyond running a PDM command is required [1]. Even low-impact commands like pdm --version are sufficient to trigger the code execution [1]. The .pth file's import statement executes arbitrary Python code with the privileges of the user running PDM [2].
Impact
Successful exploitation results in arbitrary code execution as the invoking user [1][2]. This can lead to credential theft, persistence, workspace tampering, and potential privilege escalation if PDM is executed via sudo, in root-owned CI jobs, or by privileged service accounts [1][2].
Mitigation
The issue is fixed in PDM version 2.27.0, released on 2026-06-11 [3]. The fix moves project plugin installations from .pdm-plugins under the project root to an isolated cache directory, preventing untrusted .pth file processing [3]. Users should upgrade to 2.27.0 or later. As a workaround, avoid running PDM in untrusted directories until the fix is applied [1].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <= 2.26.9
Patches
216bab5cd8886fix: update plugin installation path to use project_plugins_dir (#3790)
8 files changed · +101 −13
news/3790.feature.md+1 −0 added@@ -0,0 +1 @@ +Move project plugin installations from `.pdm-plugins` under the project root to an isolated cache directory, and add a fixer to migrate existing plugin directories.
src/pdm/cli/commands/fix/fixers.py+32 −0 modified@@ -1,5 +1,7 @@ import abc import re +import shutil +from pathlib import Path from pdm.project import Config, Project from pdm.project.lockfile import FLAG_CROSS_PLATFORM @@ -70,6 +72,36 @@ def check(self) -> bool: return self.project.root.joinpath(".pdm.toml").exists() +class ProjectPluginFixer(BaseFixer): + """Move project plugins to the cache directory.""" + + identifier = "project-plugins" + + @property + def old_plugin_dir(self) -> Path: + return self.project.root / ".pdm-plugins" + + def get_message(self) -> str: + return "Project plugins need to be moved from [info].pdm-plugins[/] to the PDM cache directory." + + def check(self) -> bool: + return self.old_plugin_dir.is_dir() + + def fix(self) -> None: + plugin_root = self.project.project_plugins_dir + self.log( + f"Moving .pdm-plugins to {plugin_root.as_posix()}...", + verbosity=Verbosity.DETAIL, + ) + plugin_root.parent.mkdir(parents=True, exist_ok=True) + if plugin_root.exists(): + if plugin_root.is_dir(): + shutil.rmtree(plugin_root) + else: + plugin_root.unlink() + shutil.move(self.old_plugin_dir, plugin_root) + + class PackageTypeFixer(BaseFixer): # pragma: no cover identifier = "package-type"
src/pdm/cli/commands/fix/__init__.py+13 −2 modified@@ -3,7 +3,13 @@ import argparse from pdm.cli.commands.base import BaseCommand -from pdm.cli.commands.fix.fixers import BaseFixer, LockStrategyFixer, PackageTypeFixer, ProjectConfigFixer +from pdm.cli.commands.fix.fixers import ( + BaseFixer, + LockStrategyFixer, + PackageTypeFixer, + ProjectConfigFixer, + ProjectPluginFixer, +) from pdm.exceptions import PdmUsageError from pdm.project import Project from pdm.termui import Emoji @@ -49,7 +55,12 @@ def check_problems(project: Project, strict: bool = True) -> None: @staticmethod def get_fixers(project: Project) -> list[BaseFixer]: """Return a list of fixers to check, the order matters""" - return [ProjectConfigFixer(project), PackageTypeFixer(project), LockStrategyFixer(project)] + return [ + ProjectConfigFixer(project), + ProjectPluginFixer(project), + PackageTypeFixer(project), + LockStrategyFixer(project), + ] def handle(self, project: Project, options: argparse.Namespace) -> None: if options.dry_run:
src/pdm/cli/commands/install.py+2 −5 modified@@ -51,7 +51,7 @@ def install_plugins(self, project: Project) -> None: plugins = [parse_line(r) for r in project.pyproject.plugins] if not plugins: return - plugin_root = project.root / ".pdm-plugins" + plugin_root = project.project_plugins_dir extra_paths = list({sysconfig.get_path("purelib"), sysconfig.get_path("platlib")}) environment = PythonEnvironment( project, python=sys.executable, prefix=str(plugin_root), extra_paths=extra_paths @@ -61,10 +61,7 @@ def install_plugins(self, project: Project) -> None: install_requirements( plugins, environment, clean=True, use_install_cache=project.config["install.cache"], allow_uv=False ) - if not plugin_root.joinpath(".gitignore").exists(): - plugin_root.mkdir(exist_ok=True) - plugin_root.joinpath(".gitignore").write_text("*\n") - project.core.ui.echo("Plugins are installed successfully into [primary].pdm-plugins[/].") + project.core.ui.echo(f"Plugins are installed successfully into [primary]{plugin_root}[/].") def handle(self, project: Project, options: argparse.Namespace) -> None: if not project.pyproject.is_valid and termui.is_interactive():
src/pdm/core.py+4 −3 modified@@ -308,14 +308,15 @@ def add_config(name: str, config_item: ConfigItem) -> None: Config.add_config(name, config_item) def _add_project_plugins_library(self) -> None: - project = self.create_project(is_global=False) - if project.is_global or not project.root.joinpath(".pdm-plugins").exists(): + project = self.create_project(is_global=False, global_config=os.getenv("PDM_CONFIG_FILE")) + plugin_root = project.project_plugins_dir + if project.is_global or not plugin_root.exists(): return import site import sysconfig - base = str(project.root / ".pdm-plugins") + base = str(plugin_root) replace_vars = {"base": base, "platbase": base} scheme_names = sysconfig.get_scheme_names()
src/pdm/project/core.py+6 −0 modified@@ -119,6 +119,12 @@ def __repr__(self) -> str: def cache_dir(self) -> Path: return Path(self.config.get("cache_dir", "")).expanduser() + @cached_property + def project_plugins_dir(self) -> Path: + name = self.root.name or "project" + root_hash = hashlib.sha224(os.path.normcase(str(self.root)).encode("utf-8")).hexdigest() + return self.cache_dir / "plugins" / f"{name}-{root_hash[:12]}" + @cached_property def pyproject(self) -> PyProject: return PyProject(self.root / self.PYPROJECT_FILENAME, ui=self.core.ui)
tests/cli/test_fix.py+25 −0 modified@@ -46,3 +46,28 @@ def test_fix_project_config(project, pdm): assert not old_config.exists() assert project.root.joinpath("pdm.toml").read_text() == "[python]\nuse_pyenv = false\n" assert project.root.joinpath(".pdm-python").read_text().strip() == Path(sys.executable).as_posix() + + +def test_fix_project_plugins(project, pdm): + old_plugin_dir = project.root / ".pdm-plugins" + old_plugin_dir.mkdir() + old_plugin_dir.joinpath("plugin.py").write_text("value = 1\n") + + pdm(["fix", "project-plugins"], obj=project, strict=True) + + assert not old_plugin_dir.exists() + assert project.project_plugins_dir.joinpath("plugin.py").read_text() == "value = 1\n" + + +def test_fix_project_plugins_replaces_existing_cache_dir(project, pdm): + old_plugin_dir = project.root / ".pdm-plugins" + old_plugin_dir.mkdir() + old_plugin_dir.joinpath("plugin.py").write_text("value = 1\n") + project.project_plugins_dir.mkdir(parents=True) + project.project_plugins_dir.joinpath("stale.py").write_text("value = 0\n") + + pdm(["fix", "project-plugins"], obj=project, strict=True) + + assert not old_plugin_dir.exists() + assert not project.project_plugins_dir.joinpath("stale.py").exists() + assert project.project_plugins_dir.joinpath("plugin.py").read_text() == "value = 1\n"
tests/test_plugin.py+18 −3 modified@@ -110,9 +110,12 @@ def get_entry_points(group): @pytest.mark.usefixtures("local_finder") def test_project_plugin_library(pdm, project, core, monkeypatch): monkeypatch.setattr(sys, "path", sys.path[:]) + monkeypatch.setenv("PDM_CONFIG_FILE", project.global_config.config_file.as_posix()) project.pyproject.settings["plugins"] = ["pdm-hello"] pdm(["install", "--plugins"], obj=project, strict=True) - assert project.root.joinpath(".pdm-plugins").exists() + assert project.project_plugins_dir.exists() + assert not project.root.joinpath(".pdm-plugins").exists() + assert project.project_plugins_dir.parent == project.cache_dir / "plugins" assert "pdm-hello" not in project.environment.get_working_set() with cd(project.root): core.load_plugins() @@ -128,11 +131,12 @@ def test_project_plugin_library(pdm, project, core, monkeypatch): ], ) @pytest.mark.usefixtures("local_finder") -def test_install_local_plugin_without_name(pdm, project, core, req_str): +def test_install_local_plugin_without_name(pdm, project, core, monkeypatch, req_str): import shutil from . import FIXTURES + monkeypatch.setenv("PDM_CONFIG_FILE", project.global_config.config_file.as_posix()) test_plugin_path = FIXTURES / "projects" / "test-plugin-pdm" project.root.joinpath("plugins").mkdir(exist_ok=True) shutil.copytree(test_plugin_path, project.root / "plugins" / "test-plugin-pdm", dirs_exist_ok=True) @@ -142,7 +146,18 @@ def test_install_local_plugin_without_name(pdm, project, core, req_str): with cd(project.root): result = pdm(["install", "--plugins", "-vv"], obj=project, strict=True) - assert project.root.joinpath(".pdm-plugins").exists() + assert project.project_plugins_dir.exists() + assert not project.root.joinpath(".pdm-plugins").exists() core.load_plugins() result = pdm(["hello", "--name", "Frost"], strict=True) assert result.stdout.strip() == "Hello, Frost" + + +def test_project_plugin_directory_is_isolated_by_project_root(project, tmp_path): + other_project = project.core.create_project( + tmp_path / project.root.name, + global_config=project.global_config.config_file, + ) + + assert project.project_plugins_dir != other_project.project_plugins_dir + assert project.project_plugins_dir.parent == other_project.project_plugins_dir.parent
2cf992eccaf3Merge commit from fork
8 files changed · +157 −8
news/3788.bugfix.md+1 −0 added@@ -0,0 +1 @@ +Refuse to write project-local config and state files (`pdm.toml`, `.pdm-python`, `.python-version`) when the destination is a symlink, preventing an untrusted repository from clobbering files outside the project root.
src/pdm/cli/commands/use.py+2 −2 modified@@ -11,7 +11,7 @@ from pdm.models.python import PythonInfo from pdm.models.venv import get_venv_python from pdm.project import Project -from pdm.utils import is_conda_base_python +from pdm.utils import is_conda_base_python, open_for_write_no_symlink class Command(BaseCommand): @@ -185,7 +185,7 @@ def do_use( ) project.python = selected_python if version_file and project.config["python.use_python_version"]: - with project.root.joinpath(".python-version").open("w") as f: + with open_for_write_no_symlink(project.root.joinpath(".python-version")) as f: f.write(f"{selected_python.major}.{selected_python.minor}\n") if project.environment.is_local: assert isinstance(project.environment, PythonLocalEnvironment)
src/pdm/project/config.py+14 −3 modified@@ -14,6 +14,7 @@ from pdm._types import RepositoryConfig from pdm.compat import tomllib from pdm.exceptions import NoConfigError, PdmUsageError +from pdm.utils import open_for_write_no_symlink REPOSITORY = "repository" SOURCE = "pypi" @@ -302,7 +303,9 @@ def add_config(cls, name: str, item: ConfigItem) -> None: def __init__(self, config_file: Path, is_global: bool = False): self.is_global = is_global - self.config_file = config_file.resolve() + # Keep the path as given (only made absolute) instead of resolving symlinks, + # so a symlinked destination can still be detected and refused on write. + self.config_file = config_file.absolute() self.deprecated = {v.replace: k for k, v in self._config_map.items() if v.replace} self._file_data = load_config(self.config_file) self._data = collections.ChainMap( @@ -332,9 +335,9 @@ def iter_sources(self) -> Iterator[RepositoryConfig]: yield RepositoryConfig(**data, name=name[len(SOURCE) + 1 :], config_prefix=SOURCE) def _save_config(self) -> None: + """Save the changes to the config file.""" import tomlkit - """Save the changed to config file.""" self.config_file.parent.mkdir(parents=True, exist_ok=True) toml_data: dict[str, Any] = {} for key, value in self._file_data.items(): @@ -346,7 +349,15 @@ def _save_config(self) -> None: temp = temp[part] temp[last] = value - with self.config_file.open("w", encoding="utf-8") as fp: + # A project-local pdm.toml may be planted as a symlink by an untrusted + # repository to clobber a file outside the project (GHSA-ghq2-5c67-fprm). + # The global config lives in the user's own config directory and is not + # attacker-controlled, so a user-managed symlink there is honored. + if self.is_global: + writer: Any = self.config_file.open("w", encoding="utf-8") + else: + writer = open_for_write_no_symlink(self.config_file) + with writer as fp: tomlkit.dump(toml_data, fp) def __getitem__(self, key: str) -> Any:
src/pdm/project/core.py+3 −1 modified@@ -40,6 +40,7 @@ is_conda_base_python, is_path_relative_to, normalize_name, + open_for_write_no_symlink, ) if TYPE_CHECKING: @@ -214,7 +215,8 @@ def _saved_python(self, value: str | None) -> None: with contextlib.suppress(FileNotFoundError): python_file.unlink() return - python_file.write_text(value, "utf-8") + with open_for_write_no_symlink(python_file) as fp: + fp.write(value) def resolve_interpreter(self) -> PythonInfo: """Get the Python interpreter path."""
src/pdm/utils.py+30 −1 modified@@ -6,6 +6,7 @@ import atexit import contextlib +import errno import functools import inspect import json @@ -27,7 +28,7 @@ from packaging.version import Version from pdm.compat import importlib_metadata -from pdm.exceptions import PDMDeprecationWarning, PdmException +from pdm.exceptions import PDMDeprecationWarning, PdmException, PdmUsageError if TYPE_CHECKING: from re import Match @@ -170,6 +171,34 @@ def atomic_open_for_write(filename: str | Path, *, mode: str = "w", encoding: st os.unlink(name) +@contextlib.contextmanager +def open_for_write_no_symlink(filename: str | Path, *, encoding: str = "utf-8") -> Iterator[IO[str]]: + """Open *filename* for writing text, refusing to follow a symlink. + + Project-local state and config files (``pdm.toml``, ``.pdm-python``, + ``.python-version``) may be planted as symlinks by an untrusted repository to + redirect the write onto a file outside the project root. This helper rejects + such writes: on POSIX it passes ``O_NOFOLLOW`` so the kernel rejects the open + atomically (no TOCTOU window); on platforms lacking ``O_NOFOLLOW`` it falls + back to a best-effort ``lstat`` pre-check. + """ + path = Path(filename) + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow and path.is_symlink(): + raise PdmUsageError(f"Refusing to write to {path} because it is a symlink.") + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | nofollow, 0o666) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.EMLINK): + raise PdmUsageError(f"Refusing to write to {path} because it is a symlink.") from exc + raise + fp = open(fd, "w", encoding=encoding) + try: + yield fp + finally: + fp.close() + + @contextlib.contextmanager def cd(path: str | Path) -> Iterator: _old_cwd = os.getcwd()
tests/cli/test_config.py+54 −0 modified@@ -1,6 +1,7 @@ import pytest from pdm.exceptions import PdmUsageError +from pdm.project.config import Config from pdm.utils import cd @@ -231,3 +232,56 @@ def test_keyring_operation_error_disables_itself(project, keyring, mocker): assert not keyring.enabled assert keyring.get_auth_info("pdm-pypi-extra", "foo") is None assert keyring.get_auth_info("pdm-repository-pypi", None) is None + + +def _symlink_or_skip(link, target): + try: + link.symlink_to(target) + except OSError as e: + pytest.skip(f"symlink is not supported: {e}") + + +def test_config_keeps_symlink_path(tmp_path): + target = tmp_path / "target.toml" + target.write_text("python.use_pyenv = false\n") + link = tmp_path / "config.toml" + _symlink_or_skip(link, target) + + config = Config(link) + + # The symlink path is kept (only made absolute), not resolved to the target. + assert config.config_file == link.absolute() + assert config.config_file.is_symlink() + assert config["python.use_pyenv"] is False + + +def test_config_refuses_to_write_symlinked_file(tmp_path): + target = tmp_path / "target.toml" + target.write_text("python.use_pyenv = true\n") + link = tmp_path / "config.toml" + _symlink_or_skip(link, target) + + config = Config(link) + + with pytest.raises(PdmUsageError, match="symlink"): + config["python.use_pyenv"] = False + + # The symlink target outside the intended file is left untouched. + assert target.read_text() == "python.use_pyenv = true\n" + assert link.is_symlink() + + +def test_global_config_writes_through_symlink(tmp_path): + target = tmp_path / "target.toml" + target.write_text("python.use_pyenv = true\n") + link = tmp_path / "config.toml" + _symlink_or_skip(link, target) + + # The global config is the user's own file; a user-managed symlink (e.g. a + # dotfiles-managed config) is honored rather than refused. + config = Config(link, is_global=True) + config["python.use_pyenv"] = False + + assert link.is_symlink() + assert config["python.use_pyenv"] is False + assert "use_pyenv = false" in target.read_text()
tests/test_project.py+19 −1 modified@@ -11,7 +11,7 @@ from pytest_httpserver import HTTPServer from pdm.environments import PythonEnvironment -from pdm.exceptions import PdmException, ProjectError +from pdm.exceptions import PdmException, PdmUsageError, ProjectError from pdm.models.requirements import parse_requirement from pdm.models.specifiers import PySpecSet from pdm.models.venv import get_venv_python @@ -593,3 +593,21 @@ def test_select_lockfile_format(project, pdm, capsys): pdm(["export", "-f", "pylock", "-o", "pylock.toml"], strict=True) project._lockfile = None assert project.lockfile._path.name == "pylock.toml" + + +def test_saved_python_refuses_symlinked_pdm_python(project): + real = project.root / "real-state.txt" + real.write_text("untouched") + link = project.root / ".pdm-python" + link.unlink(missing_ok=True) + try: + link.symlink_to(real) + except OSError as e: + pytest.skip(f"symlink is not supported: {e}") + + with pytest.raises(PdmUsageError, match="symlink"): + project._saved_python = "/some/python" + + # The symlink target outside .pdm-python is left untouched. + assert real.read_text() == "untouched" + assert link.is_symlink()
tests/test_utils.py+34 −0 modified@@ -554,3 +554,37 @@ def now(cls, tz=None): monkeypatch.setattr(utils, "datetime", FrozenDateTime) assert utils.convert_to_datetime(value) == expected_datetime + + +def test_open_for_write_no_symlink_writes_regular_file(tmp_path): + target = tmp_path / "file.txt" + with utils.open_for_write_no_symlink(target) as fp: + fp.write("hello") + assert target.read_text() == "hello" + assert not target.is_symlink() + + +def test_open_for_write_no_symlink_truncates_existing_file(tmp_path): + target = tmp_path / "file.txt" + target.write_text("stale content") + with utils.open_for_write_no_symlink(target) as fp: + fp.write("new") + assert target.read_text() == "new" + + +def test_open_for_write_no_symlink_refuses_symlinked_target(tmp_path): + real = tmp_path / "real.txt" + real.write_text("untouched") + link = tmp_path / "link.txt" + try: + link.symlink_to(real) + except OSError as e: + pytest.skip(f"symlink is not supported: {e}") + + with pytest.raises(PdmUsageError, match="symlink"): + with utils.open_for_write_no_symlink(link): + pass + + # The symlink and its target are both left intact. + assert link.is_symlink() + assert real.read_text() == "untouched"
Vulnerability mechanics
Root cause
"Project-local plugin paths are implicitly trusted and loaded via `site.addsitedir()`, which executes attacker-controlled `.pth` files before CLI handling begins."
Attack vector
An attacker places a malicious `.pth` file inside the `.pdm-plugins` directory of a repository. When a victim runs any `pdm` command (e.g., `pdm --version`) from that checkout, `site.addsitedir()` processes the `.pth` file and executes its `import` lines immediately, before CLI parsing occurs [ref_id=1][ref_id=2]. This yields arbitrary code execution with the victim's privileges, requiring only that the victim runs `pdm` in the attacker-controlled repository.
Affected code
The vulnerability resides in `src/pdm/core.py` at lines 74–82, 310–333, and 335–352. `Core.__init__()` calls `load_plugins()`, which invokes `_add_project_plugins_library()` to add the `.pdm-plugins` directory via `site.addsitedir()`.
What the fix does
The patch [patch_id=5595011] removes the use of `site.addsitedir()` for project-local plugin paths, preventing `.pth` file processing. The advisory recommends either not auto-loading `.pdm-plugins` by default or requiring explicit opt-in (e.g., `--enable-project-plugins`) to close the trust-boundary break [ref_id=1][ref_id=2].
Preconditions
- inputVictim must run any `pdm` command inside a repository containing a `.pdm-plugins` directory with a malicious `.pth` file.
- authNo special privileges on the victim host are required; the attacker only needs to control the repository content.
- networkThe attack is local to the system where `pdm` is executed.
Reproduction
The bundle includes a full PoC: create a temporary project with a `pyproject.toml`, compute the `.pdm-plugins` purelib path via `sysconfig`, place an `evil.pth` file containing `import pathlib; pathlib.Path(...).write_text(...)`, and run `pdm --version`. The marker file is created, confirming code execution [ref_id=1][ref_id=2].
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.