Copier safe template has arbitrary filesystem write access via directory symlinks when _preserve_symlinks: true
Description
Copier is a library and CLI app for rendering project templates. Prior to version 9.11.2, 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 to arbitrary directories outside the destination path by using directory a symlink along with _preserve_symlinks: true and a generated directory structure whose rendered path is inside the symlinked directory. 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. Version 9.11.2 patches the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2026-23986: Copier before 9.11.2 allows arbitrary file overwrite via symlink traversal in safe templates.
Vulnerability
Overview
CVE-2026-23986 is a path traversal vulnerability in Copier, a library and CLI tool for rendering project templates. Prior to version 9.11.2, Copier claimed that templates not using unsafe features (like custom Jinja extensions requiring the --UNSAFE flag) were safe to use. However, a malicious template author could bypass this safety guarantee by including a directory symlink with _preserve_symlinks: true and crafting a generated directory structure whose rendered path falls inside the symlinked directory. This allows writing files to arbitrary file writes outside the intended destination path [1][2].
Exploitation
Details
The attack requires the user to generate a project from a template that contains a symlink pointing outside the template root. The template must also set _preserve_symlinks: true in its configuration. When Copier processes the template, it follows the symlink and writes generated files to the external location, effectively overwriting arbitrary files on the user's system, subject to the user's write permissions. No authentication or special privileges are needed beyond the user's own permissions [1][3].
Impact
An attacker who can convince a user to generate a project from a malicious template can overwrite any file the user has write access to. This could lead to arbitrary code execution (e.g., overwriting shell configuration files, scripts, or application binaries), data files), data corruption, or system compromise. The vulnerability undermines Copier's trust model for safe templates [1][4].
Mitigation
The issue is patched in Copier version 9.11.2, released on 2026-01-20. The fix disallows symlink-based includes that resolve outside the template root, raising a ForbiddenPathError [3][3][4]. Users should update to the latest version immediately. No workarounds are documented; avoiding templates from untrusted sources is recommended until the update is applied.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
copierPyPI | < 9.11.2 | 9.11.2 |
Affected products
1- Range: 1.1.1, 3.0.0-alpha5, 3.0.0-alpha6, …
Patches
1b3a7b3772d17fix: disallow symlink-based includes outside template root
2 files changed · +92 −1
copier/_main.py+14 −0 modified@@ -718,6 +718,20 @@ def _render_template(self) -> None: dst_root = self.dst_path.resolve() for src in scantree(str(self.template_copy_root), follow_symlinks): src_abspath = Path(src.path) + # If the source is a symlink, we are not preserving symlinks, and the + # symlink target is outside the template root, this means that we are + # copying a file/directory from outside the template, which is + # forbidden, so raise an error. + if ( + src_abspath.is_symlink() + and not self.template.preserve_symlinks + and not (src_abspath.resolve()).is_relative_to( + self.template.local_abspath + ) + ): + raise ForbiddenPathError( + path=src_abspath.relative_to(self.template_copy_root) + ) 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)
tests/test_symlinks.py+78 −1 modified@@ -1,11 +1,13 @@ import os +import re from pathlib import Path +from tempfile import gettempdir import pytest from plumbum import local from copier import run_copy, run_update -from copier.errors import DirtyLocalWarning +from copier.errors import DirtyLocalWarning, ForbiddenPathError from .helpers import build_file_tree, git @@ -569,3 +571,78 @@ def test_symlinked_to_outside_destination_relative( assert (dst / "symlink_dir" / "outside.txt").read_text() == "outside" assert (dst / "a_symlink.txt").is_symlink() assert (dst / "a_symlink.txt").read_text() == "outside" + + +def test_resolve_relative_symlink_outside_template_root_raises_error( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other")) + + build_file_tree( + { + src / ".copier-answers.yml.jinja": """\ + # Changes here will be overwritten by Copier + {{ _copier_answers|to_nice_yaml }} + """, + src / "copier.yaml": """\ + _preserve_symlinks: false + """, + src / "symlink.txt": Path( + # HACK: This is the path to `outside.txt` relative to the location the + # symlink file in the template's local clone location. To construct + # this path dynamically, we rely on internal knowledge where Copier + # clones the template. + os.path.relpath(other, start=Path(gettempdir(), "template-clone")), + "outside.txt", + ), + other / "outside.txt": "outside", + } + ) + + with local.cwd(src): + git("init") + git("add", "-A") + git("commit", "-m", "init") + + with pytest.raises( + ForbiddenPathError, + match=re.escape('"symlink.txt" is forbidden'), + ): + run_copy(str(src), dst, defaults=True, overwrite=True, cleanup_on_error=False) + + assert not (dst / "symlink.txt").exists() + + +@pytest.mark.skipif( + os.name == "nt", reason="Absolute paths not created as symlinks on Windows" +) +def test_resolve_absolute_symlink_outside_template_root_raises_error( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other")) + build_file_tree( + { + src / ".copier-answers.yml.jinja": """\ + # Changes here will be overwritten by Copier + {{ _copier_answers|to_nice_yaml }} + """, + src / "copier.yaml": """\ + _preserve_symlinks: false + """, + src / "symlink.txt": other / "outside.txt", + other / "outside.txt": "outside", + } + ) + + with local.cwd(src): + git("init") + git("add", "-A") + git("commit", "-m", "init") + + with pytest.raises( + ForbiddenPathError, + match=re.escape('"symlink.txt" is forbidden'), + ): + run_copy(str(src), dst, defaults=True, overwrite=True) + + assert not (dst / "symlink.txt").exists()
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
5- github.com/advisories/GHSA-4fqp-r85r-hxqhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23986ghsaADVISORY
- github.com/copier-org/copier/commit/b3a7b3772d17cf0e7a4481978188c9f536c8d8f6ghsax_refsource_MISCWEB
- github.com/copier-org/copier/releases/tag/v9.11.2ghsax_refsource_MISCWEB
- github.com/copier-org/copier/security/advisories/GHSA-4fqp-r85r-hxqhghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.