VYPR
Medium severity5.5NVD Advisory· Published Apr 2, 2026· Updated Apr 3, 2026

CVE-2026-34730

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.

PackageAffected versionsPatched versions
copierPyPI
< 9.14.19.14.1

Affected products

2
  • Copier Org/Copier2 versions
    cpe: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

1
5413062eb17b

fix: require `--trust` for `_external_data` paths outside subproject root

https://github.com/copier-org/copierSigurd SpieckermannMar 30, 2026via ghsa
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

News mentions

1