CVE-2025-55214
Description
Copier library and CLI app for rendering project templates. From 7.1.0 to before 9.9.1, Copier suggests that it's safe to generate a project from a safe template, i.e. one that doesn't use unsafe features like custom Jinja extensions which would require passing the --UNSAFE,--trust flag. As it turns out, a safe template can currently write files outside the destination path where a project shall be generated or updated. This is possible when rendering a generated directory structure whose rendered path is either a relative parent path or an absolute path. Constructing such paths is possible using Copier's builtin pathjoin Jinja filter and its builtin _copier_conf.sep variable, which is the platform-native path separator. This way, a malicious template author can create a template that overwrites arbitrary files (according to the user's write permissions), e.g., to cause havoc. This vulnerability is fixed in 9.9.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
copierPyPI | >= 7.1.0, < 9.9.1 | 9.9.1 |
Affected products
1- Range: v7.1.0, v7.2.0, v8.0.0, …
Patches
2165c85a1536cbump: version 9.9.0 → 9.9.1
2 files changed · +8 −1
CHANGELOG.md+7 −0 modified@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. This projec adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) versioning schema, and the changelog itself conforms to [Keep A Changelog](https://keepachangelog.com/). +## v9.9.1 (2025-08-18) + +### Security + +- disallow render paths outside destination directory +- cast Jinja context path variables to `pathlib.PurePath` + ## v9.9.0 (2025-08-01) ### Feat
pyproject.toml+1 −1 modified@@ -179,7 +179,7 @@ annotated_tag = true changelog_incremental = true tag_format = "v$version" update_changelog_on_bump = true -version = "9.9.0" +version = "9.9.1" [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file
fdbc0167cc22fix: disallow render paths outside destination directory
3 files changed · +99 −6
copier/errors.py+7 −0 modified@@ -85,6 +85,13 @@ def __init__(self, *, path: Path) -> None: super().__init__(f'"{path}" is not a relative path') +class ForbiddenPathError(PathError): + """The path is forbidden in the given context.""" + + def __init__(self, *, path: Path) -> None: + super().__init__(f'"{path}" is forbidden') + + class ExtensionNotFoundError(UserMessageError): """Extensions listed in the configuration could not be loaded."""
copier/_main.py+10 −6 modified@@ -13,7 +13,7 @@ from filecmp import dircmp from functools import cached_property, partial, wraps from itertools import chain -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from shutil import rmtree from tempfile import TemporaryDirectory from types import TracebackType @@ -67,6 +67,7 @@ from .errors import ( CopierAnswersInterrupt, ExtensionNotFoundError, + ForbiddenPathError, InteractiveSessionError, TaskError, UnsafeTemplateError, @@ -674,16 +675,16 @@ def to_json_fallback(value: Any) -> Any: ) # Add a global function to join filesystem paths. - separators = { - "posix": "/", - "windows": "\\", - "native": os.path.sep, + path_type = { + "posix": PurePosixPath, + "windows": PureWindowsPath, + "native": PurePath, } def _pathjoin( *path: str, mode: Literal["posix", "windows", "native"] = "posix" ) -> str: - return separators[mode].join(path) + return str(path_type[mode](*path)) env.globals["pathjoin"] = _pathjoin return env @@ -713,13 +714,16 @@ def match_skip(self) -> Callable[[Path], bool]: def _render_template(self) -> None: """Render the template in the subproject root.""" follow_symlinks = not self.template.preserve_symlinks + cwd = Path.cwd() for src in scantree(str(self.template_copy_root), follow_symlinks): src_abspath = Path(src.path) src_relpath = Path(src_abspath).relative_to(self.template.local_abspath) dst_relpaths_ctxs = self._render_path( Path(src_abspath).relative_to(self.template_copy_root) ) for dst_relpath, ctx in dst_relpaths_ctxs: + if not cwd.joinpath(dst_relpath).resolve().is_relative_to(cwd): + raise ForbiddenPathError(path=dst_relpath) if self.match_exclude(dst_relpath): continue if src.is_symlink() and self.template.preserve_symlinks:
tests/test_copy.py+82 −0 modified@@ -1,7 +1,9 @@ from __future__ import annotations import filecmp +import os import platform +import re import stat import sys from contextlib import AbstractContextManager, nullcontext as does_not_raise @@ -19,6 +21,7 @@ import copier from copier import run_copy from copier._types import AnyByStrDict +from copier.errors import ForbiddenPathError from .helpers import ( BRACKET_ENVOPS, @@ -1274,3 +1277,82 @@ def test_copier_phase_variable(tmp_path_factory: pytest.TempPathFactory) -> None copier.run_copy(str(src), dst) assert (dst / "render").exists() assert (dst / "render").read_text() == "render" + + +def test_relative_render_path_outside_destination_raises_error( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = tmp_path_factory.mktemp("root") + (src := root / "src").mkdir() + (dst := root / "dst").mkdir() + build_file_tree( + { + root / "forbidden.txt": "foo", + src / "{{ pathjoin('..', 'forbidden.txt') }}": "bar", + } + ) + with pytest.raises( + ForbiddenPathError, + match=re.escape(rf'"{Path("..", "forbidden.txt")}" is forbidden'), + ): + copier.run_copy(str(src), dst, overwrite=True) + assert not (dst / "forbidden.txt").exists() + assert (root / "forbidden.txt").exists() + assert (root / "forbidden.txt").read_text("utf-8") == "foo" + + +@pytest.mark.skipif(os.name != "posix", reason="Applies only to POSIX") +def test_absolute_render_path_outside_destination_raises_error_on_posix( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other")) + path_parts = ", ".join(map(repr, other.parts[1:])) + build_file_tree( + { + other / "forbidden.txt": "foo", + src / "copier.yml": f"_envops: {BRACKET_ENVOPS_JSON}", + src / f"[[ pathjoin(_copier_conf.sep, {path_parts}, 'forbidden.txt') ]]": ( + "bar" + ), + } + ) + with pytest.raises( + ForbiddenPathError, + match=re.escape(rf'"{other / "forbidden.txt"}" is forbidden'), + ): + copier.run_copy(str(src), dst, overwrite=True) + assert not (dst / "forbidden.txt").exists() + assert (other / "forbidden.txt").exists() + assert (other / "forbidden.txt").read_text("utf-8") == "foo" + + +@pytest.mark.skipif(os.name != "nt", reason="Applies only to Windows") +def test_absolute_render_path_outside_destination_raises_error_on_windows( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other")) + path_parts = ", ".join(map(repr, other.parts[1:])) + build_file_tree( + { + other / "forbidden.txt": "foo", + src / "copier.yml": ( + f"""\ + _envops: {BRACKET_ENVOPS_JSON} + + drive: + type: str + default: "{other.drive}" + when: false + """ + ), + src / f"[[ pathjoin(drive, {path_parts}, 'forbidden.txt') ]]": "bar", + } + ) + with pytest.raises( + ForbiddenPathError, + match=re.escape(rf'"{other / "forbidden.txt"}" is forbidden'), + ): + copier.run_copy(str(src), dst, overwrite=True) + assert not (dst / "forbidden.txt").exists() + assert (other / "forbidden.txt").exists() + assert (other / "forbidden.txt").read_text("utf-8") == "foo"
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.