VYPR
Medium severityOSV Advisory· Published Aug 18, 2025· Updated Apr 15, 2026

CVE-2025-55214

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.

PackageAffected versionsPatched versions
copierPyPI
>= 7.1.0, < 9.9.19.9.1

Affected products

1

Patches

2
165c85a1536c

bump: version 9.9.0 → 9.9.1

https://github.com/copier-org/copierSigurd SpieckermannAug 18, 2025via osv
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
    
fdbc0167cc22

fix: disallow render paths outside destination directory

https://github.com/copier-org/copierSigurd SpieckermannAug 14, 2025via ghsa
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

4

News mentions

0

No linked articles in our index yet.