Copier safe template has arbitrary filesystem read access via symlinks when _preserve_symlinks: false
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 include arbitrary files/directories outside the local template clone location by using symlinks along with _preserve_symlinks: false (which is Copier's default setting). Version 9.11.2 patches the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Copier before 9.11.2 allows safe templates to include arbitrary files outside the template root via symlinks when `_preserve_symlinks` is false.
Vulnerability
Overview
Copier is a library and CLI tool for rendering project templates. Prior to version 9.11.2, Copier's documentation suggests that generating a project from a 'safe' template (one that does not use custom Jinja extensions requiring the --UNSAFE,--trust flag) is secure. However, a safe template can include arbitrary files or directories from outside the local template clone location by using symlinks combined with the default setting _preserve_symlinks: false [1][4]. This allows a malicious template author to exfiltrate sensitive data from the user's filesystem.
Exploitation
An attacker can craft a template that contains a symlink pointing to a sensitive file or directory outside the template's root. When Copier copies the template with _preserve_symlinks: false (the default), it resolves the symlink and includes the target content in the generated project. For example, a symlink named stolen-secret.txt pointing to ../secret.txt will copy the contents of secret.txt into the output [4]. No special flags or authentication are needed; the user only needs to run copier copy on the malicious template.
Impact
An attacker who can convince a user to generate a project from a malicious template can read arbitrary files from the user's filesystem, such as SSH keys, credentials, or other secrets. If the user then pushes the generated project to a public repository, the attacker can extract the stolen data [4]. This breaks the trust model of safe templates.
Mitigation
Version 9.11.2 patches the issue by disallowing symlink-based includes that resolve outside the template root [3]. Users should update to Copier 9.11.2 or later. There are no known workarounds for earlier versions [4].
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
4- github.com/advisories/GHSA-xjhm-gp88-8pfxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23968ghsaADVISORY
- github.com/copier-org/copier/commit/b3a7b3772d17cf0e7a4481978188c9f536c8d8f6ghsax_refsource_MISCWEB
- github.com/copier-org/copier/security/advisories/GHSA-xjhm-gp88-8pfxghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.