PYSEC-2026-3818

    Dashboard / Vulnerabilities / PYSEC-2026-3818

    PYSEC-2026-3818

    Published: 10 Sept 2026Last Modified: 10 Sept 2026

    Summary: Copier has a trust-prefix bypass via path traversal that runs tasks unprompted

    Details: # Copier: trust-prefix bypass via path traversal runs tasks unprompted ### Summary In copier `>= 9.5.0, <= 9.15.1`, the `trust` setting's prefix match (`copier/_settings.py`) compares the template URL against a trusted prefix with a raw `str.startswith` and **no path normalization**, while the URL *is* normalized when the template is actually fetched (`Path.resolve()` for local paths; libcurl dot-segment removal for `https`). A template reference that textually starts with a trusted prefix but contains `..` (e.g. `https://github.com/trusted-org/../attacker-org/repo.git`) is therefore granted trust yet resolves to a different, attacker-controlled template, whose `tasks` / `migrations` / `jinja_extensions` then run **without the `--trust` prompt** — arbitrary command execution. Likely **CWE-22 (Improper Limitation of a Pathname)** in the trust check leading to **CWE-94 (code execution)**. ### Details `trust` lets users mark template locations as trusted so copier skips the unsafe-feature gate. A trailing `/` makes an entry a **prefix** match (`docs/settings.md`: *"Locations ending with `/` will be matched as prefixes, trusting all templates from that location"*). `copier/_settings.py:141-146` (tag `v9.15.1`): ```python return any( repository.startswith(_normalize(t)) if t.endswith("/") else repository == _normalize(t) for t in trust ) ``` `_normalize` only expands `~`; it does **not** touch `..` or collapse segments — `copier/_settings.py:149-152` (tag `v9.15.1`): ```python def _normalize(url: str) -> str: if url.startswith("~"): # Only expand on str to avoid messing with URLs url = expanduser(url) # noqa: PTH111 return url ``` This decision gates code execution — `copier/_main.py:293` (tag `v9.15.1`): ```python if self.unsafe or is_trusted_repository(self.settings.trust, self.template.url): return # skip the unsafe-feature check entirely ``` The chain: the trust comparison sees the **raw** URL, so `"https://github.com/safeorg/../evilorg/t.git".startswith("https://github.com/safeorg/")` is `True`; but the value copier hands to `git`/`pathlib` is **normalized**, so the template actually loaded is `evilorg/t` (a different, attacker-owned org). Trust is granted to a location the user never trusted, and `_check_unsafe` returns early, so the malicious template's tasks execute with no prompt. This is most acute on `copier update`, which reads `_src_path` from the project's `.copier-answers.yml` (`copier/_subproject.py`) — i.e. an attacker who hands you a project controls the URL that the trust check is applied to. In-repo asymmetry that confirms the omission: copier consistently resolves paths *everywhere else* it makes a security decision — `Path.resolve()` plus `is_relative_to(...)` guards in `_render_template`, `template_copy_root`, and `_external_data` — but not in the trust comparison. ### PoC Self-contained standalone script; runs against a clean, pinned PyPI install via the real `copier` CLI only. **Static by default** (`copier copy --pretend` reaches the trust decision but does not execute tasks); `--prove-exec` is an opt-in supplementary run that fires an inert marker (`echo` + `touch`). The full `poc.py` accompanies this report. Build and run: ```bash python -m venv venv && . venv/bin/activate pip install "copier==9.15.1" python poc.py # static proof (default) python poc.py --prove-exec # also fire the inert marker ``` Observed output (`copier 9.15.1`): ``` == version proof == copier == 9.15.1 module : .../site-packages/copier/__init__.py == inputs == trusted prefix (settings.yml): /tmp/copier_trust_poc_XXXX/trusted_templates/ control src (canonical) : /tmp/copier_trust_poc_XXXX/attacker/evil_template exploit src (traversal) : /tmp/copier_trust_poc_XXXX/trusted_templates/../attacker/evil_template both resolve to the SAME dir : True exploit startswith trusted/ : True minimal delta : exploit = '/tmp/copier_trust_poc_XXXX/trusted_templates/..' + '/attacker/evil_template' == static proof (copier copy --pretend; payload NOT executed) == control (canonical, untrusted): exit=4 -> BLOCKED (UnsafeTemplateError) exploit (trusted-prefix /..) : exit=0 -> TRUSTED, task reached marker on disk after --pretend: False (expected False: --pretend does not run tasks) --- copier's own output for the exploit (note the task it WOULD run) --- | Copying from template version None | create hello.txt | > Running task 1 of 1: echo COPIER-TRUST-BYPASS-RCE-MARKER && touch COPIER_RCE_PROOF == VERDICT == BYPASS CONFIRMED: identical template is refused by canonical path (exit 4) yet granted trust via '<trusted>/..' traversal (exit 0), so its tasks run with no --trust prompt. == --prove-exec: running the exploit for real (inert marker) == | COPIER-TRUST-BYPASS-RCE-MARKER | > Running task 1 of 1: echo COPIER-TRUST-BYPASS-RCE-MARKER && touch COPIER_RCE_PROOF exit=0 marker file 'COPIER_RCE_PROOF' created: True -> ARBITRARY COMMAND EXECUTED via a 'trusted' template, no --trust ``` The exploit is the same template as the control plus the minimal delta `<trusted_prefix>/..`. Deterministic: same input → same result. The PoC uses a local trusted prefix for a self-contained, network-free run; the `https` case is identical because git normalizes `..` before the request — e.g. `git ls-remote "https://github.com/copier-org/../pallets/flask.git"` emits `warning: redirecting to https://github.com/pallets/flask.git/` and returns `pallets/flask`'s refs, a different org than the trusted `copier-org/`. ### Impact A user who has configured a trusted **prefix** (a trailing-`/` entry in `trust`, a documented feature) no longer gets the unsafe-feature prompt for a template that merely *appears* to live under that prefix. Any party who can influence the template URL — most realistically the author of a project the victim runs `copier update` on, since `_src_path` comes from that project's `.copier-answers.yml` — can host the real template under a different org/location reached via `..` and have its `tasks`/`migrations`/ `jinja_extensions` execute arbitrary commands with no prompt. It fires on a default, modern git for both local paths and `https`. Proposed severity: **High**, comparable to the project's prior unsafe-template advisory (GHSA-3xw7-v6cj-5q8h). Proposed CVSS v4 vector (maintainer to finalize; `AT:P` reflects the required trusted-prefix configuration): `CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H`. Conservative variant if you scope impact to the user account only (no host escape claim): drop `SC/SI/SA` to `N`. ### Recommended fix Normalize **both** sides before comparing, instead of raw `startswith`. For local entries, compare resolved absolute paths (`Path(t).resolve()` vs `Path(repository).resolve()`) using segment containment / `is_relative_to`, the pattern already used in `_render_template` and `template_copy_root`. For URL entries, parse the URL and reject or collapse `..`/`.`/empty path segments before the prefix test. As defense-in-depth, reject any `_src_path` read from an answers file that contains `..` segments after the scheme/host, since legitimate template URLs never need them. ### References - CWE-22 — https://cwe.mitre.org/data/definitions/22.html - CWE-94 — https://cwe.mitre.org/data/definitions/94.html - Affected source (tag `v9.15.1`): `copier/_settings.py:141-146` (prefix match), `copier/_settings.py:149-152` (`_normalize`), `copier/_main.py:293` (trust gate). - Documented prefix behavior: `docs/settings.md` ("Locations ending with `/` will be matched as prefixes"). - `https` `..` normalization: libcurl removes dot segments by default (`CURLOPT_PATH_AS_IS` defaults to off) — https://curl.se/libcurl/c/CURLOPT_PATH_AS_IS.html - Novelty: distinct from copier's published advisories, which concern filesystem read/write traversal in rendered output; this is an authorization bypass in the `trust` setting's URL matching. The flawed match is identical between released `v9.15.1` and current `master` HEAD, and unchanged since the trust-prefix feature was introduced in `v9.5.0` (originally `copier/settings.py`, commit `71358ed`; renamed to `copier/_settings.py` in the v9.12.0 refactor).

    Affected packages

    Package

    Name: copier

    Purl: pkg:pypi/copier

    Affected ranges

    Type: ECOSYSTEM

    Events:

    Introduced- 9.5.0
    Fixed -9.15.2

    Affected versions

    9.10.0
    9.10.1
    9.10.2
    9.10.3

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High
    PYSEC-2026-3818 | CVE-DB