CVE-2026-34730
Description
Copier is a library and CLI app for rendering project templates. Prior to version 9.14.1, Copier's _external_data feature allows a template to load YAML files using template-controlled paths. If untrusted templates are in scope, a malicious template can read attacker-chosen YAML-parseable local files that are accessible to the user running Copier and expose their contents in rendered output. This issue has been patched in version 9.14.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Copier's `_external_data` feature before 9.14.1 allows template-controlled path traversal to read arbitrary YAML files and expose them via rendered output, patched in 9.14.1.
Vulnerability
Description
Copier is a library and CLI tool for rendering project templates. Prior to version 9.14.1, its _external_data feature allowed templates to load YAML files from paths controlled by the template. The intended design specifies that these paths should be relative to the subproject destination, but the implementation failed to restrict parent-directory traversal (e.g., ../secret.yml) or absolute paths (e.g., /tmp/secret.yml), enabling unintended access to arbitrary YAML-parseable files on the user's filesystem [1][2].
Exploitation
Method
When an untrusted template is used, the _external_data mapping in the template configuration renders a user-controlled path string. Copier then directly opens Path(dst_path, rendered_path) without validating that the resolved path stays within the destination directory [1]. This path traversal occurs without requiring --UNSAFE mode, lowering the bar for exploitation. An attacker with control over the template (e.g., from a malicious Git repository) can craft a path that reads sensitive YAML files, such as configuration secrets or SSH keys, as long as they are valid YAML and accessible to the user running Copier [2].
Impact
By successfully exploiting this vulnerability, an attacker can read the contents of attacker-chosen YAML-parseable local files. The parsed YAML is then made available as _external_data.<name> during template rendering, allowing its contents to be injected into generated output files [1]. This could lead to exposure of sensitive information like API tokens, encryption keys, or other confidential data stored in YAML format.
Mitigation
The issue has been patched in Copier version 9.14.1. The fix requires --trust (or --UNSAFE) for _external_data paths that resolve outside the subproject destination, ensuring that path traversal is no longer permitted by default [4]. Users are advised to upgrade to 9.14.1 or later, and to only run templates from trusted sources.
AI Insight generated on May 18, 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.14.1 | 9.14.1 |
Affected products
2cpe:2.3:a:copier-org:copier:*:*:*:*:*:python:*:*+ 1 more
- cpe:2.3:a:copier-org:copier:*:*:*:*:*:python:*:*range: <9.14.1
- (no CPE)range: <9.14.1
Patches
15413062eb17bfix: require `--trust` for `_external_data` paths outside subproject root
4 files changed · +125 −5
copier/errors.py+2 −2 modified@@ -117,8 +117,8 @@ def __init__(self, *, path: Path) -> None: class ForbiddenPathError(PathError): """The path is forbidden in the given context.""" - def __init__(self, *, path: Path) -> None: - super().__init__(f'"{path}" is forbidden') + def __init__(self, *, path: Path, hint: str = "") -> None: + super().__init__(f'"{path}" is forbidden' + (f"\n\n{hint}" if hint else "")) class ExtensionNotFoundError(UserMessageError):
copier/_main.py+20 −3 modified@@ -321,14 +321,31 @@ def _render(path: str) -> str: with Phase.use(Phase.UNDEFINED): return self._render_string(path) + def _load_external_data(path: str) -> Any: + if ( + not ( + (self.dst_path / (path := _render(path))) + .resolve() + .is_relative_to(self.subproject.local_abspath) + ) + and not self.unsafe + ): + raise ForbiddenPathError( + path=Path(path), + hint=( + "If you trust this path, you can override the check:\n" + " - CLI: `--trust`/`--UNSAFE`\n" + " - API: `trust=True`" + ), + ) + return load_answersfile_data(self.dst_path, path, warn_on_missing=True) + # Given those values are lazily rendered on 1st access then cached # the phase value is irrelevant and could be misleading. # As a consequence it is explicitly set to "undefined". return LazyDict( { - name: lambda path=path: load_answersfile_data( # type: ignore[misc] - self.dst_path, _render(path), warn_on_missing=True - ) + name: lambda path=path: _load_external_data(path) # type: ignore[misc] for name, path in self.template.external_data.items() } )
docs/configuring.md+6 −0 modified@@ -1005,6 +1005,12 @@ strings, where: password: "{{ password }}" ``` +!!! attention "Reading external data from outside subproject root" + + Reading data from outside the subproject root is supported, but only when explicitly + enabled with [`--trust`/`--UNSAFE`](#unsafe) (or `unsafe=True` via API) to prevent + unauthorized filesystem access. + ### `envops` - Format: `dict`
tests/test_answersfile.py+97 −0 modified@@ -1,6 +1,7 @@ from __future__ import annotations import json +from contextlib import AbstractContextManager, nullcontext as does_not_raise from pathlib import Path from textwrap import dedent @@ -9,6 +10,7 @@ import copier from copier._user_data import load_answersfile_data +from copier.errors import ForbiddenPathError from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree, git_save @@ -328,3 +330,98 @@ def test_undefined_phase_in_external_data( copier.run_copy(str(src), dst, defaults=True, overwrite=True) answers = load_answersfile_data(dst, ".copier-answers.yml") assert answers["key"] == "value" + + +@pytest.mark.parametrize( + ("unsafe", "expected"), + [(False, pytest.raises(ForbiddenPathError)), (True, does_not_raise())], +) +@pytest.mark.parametrize("is_absolute", [False, True], ids=["relative", "absolute"]) +def test_external_data_path_outside_destination_root_is_unsafe_on_copy( + tmp_path_factory: pytest.TempPathFactory, + is_absolute: bool, + unsafe: bool, + expected: AbstractContextManager[None], +) -> None: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + project = dst / "project" + project.mkdir() + + secret_file = dst / "secret.yml" + secret_file.write_text("secret_key: s3cr3t") + + external_data_path = secret_file if is_absolute else Path("..", "secret.yml") + + build_file_tree( + { + (src / "copier.yml"): ( + f"""\ + _external_data: + secret: {external_data_path} + + secret: "{{{{ _external_data.secret.secret_key }}}}" + """ + ), + (src / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers | to_nice_yaml }}" + ), + } + ) + git_save(src, tag="v1") + + with expected: + copier.run_copy(str(src), project, defaults=True, unsafe=unsafe) + + +@pytest.mark.parametrize( + ("unsafe", "expected"), + [(False, pytest.raises(ForbiddenPathError)), (True, does_not_raise())], +) +@pytest.mark.parametrize("is_absolute", [False, True], ids=["relative", "absolute"]) +def test_external_data_path_outside_destination_root_is_unsafe_on_update( + tmp_path_factory: pytest.TempPathFactory, + is_absolute: bool, + unsafe: bool, + expected: AbstractContextManager[None], +) -> None: + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + project = dst / "project" + project.mkdir() + + build_file_tree( + { + (src / "{{ _copier_conf.answers_file }}.jinja"): ( + "{{ _copier_answers | to_nice_yaml }}" + ), + } + ) + git_save(src, tag="v1") + + copier.run_copy(str(src), project) + assert load_answersfile_data(project).get("_commit") == "v1" + + git_save(project) + + secret_file = dst / "secret.yml" + secret_file.write_text("secret_key: s3cr3t") + + external_data_path = secret_file if is_absolute else Path("..", "secret.yml") + + build_file_tree( + { + (src / "copier.yml"): ( + f"""\ + _external_data: + secret: {external_data_path} + + secret: "{{{{ _external_data.secret.secret_key }}}}" + """ + ), + } + ) + git_save(src, tag="v2") + + with expected: + copier.run_update(project, defaults=True, overwrite=True, unsafe=unsafe)
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/copier-org/copier/commit/5413062eb17b73dc885f5e645cdc161e69ef641bnvdPatchWEB
- github.com/copier-org/copier/security/advisories/GHSA-hgjq-p8cr-gg4hnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-hgjq-p8cr-gg4hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34730ghsaADVISORY
- github.com/copier-org/copier/releases/tag/v9.14.1nvdRelease NotesWEB
News mentions
1- How the Story of a USB Penetration Test Went ViralDark Reading · May 5, 2026