Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍 PyPI
Not in CISA KEV

GHSA-9gmc-jqmh-3rvm

GHSA-9gmc-jqmh-3rvm is a Path Traversal vulnerability in copier. O3 Security confirms whether GHSA-9gmc-jqmh-3rvm is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

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

Also known asCVE-2026-53951
Published
Aug 19, 2026
Updated
Aug 19, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Aug 19, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Real-World Exposure

1 pkg affected
🐍copier

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

Description

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):

    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):

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):

        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:

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

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPIcopier9.5.0&&< 9.15.29.15.2

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for copier. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update copier to 9.15.2 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-9gmc-jqmh-3rvm is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-9gmc-jqmh-3rvm is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-9gmc-jqmh-3rvm. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

# 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
O3 Security · Impact-Aware SCA

Is GHSA-9gmc-jqmh-3rvm in your dependencies?

O3 detects GHSA-9gmc-jqmh-3rvm across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.

GHSA-9gmc-jqmh-3rvm: copier Remote Code… | O3 Security