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).
Copier: trust-prefix bypass via path traversal runs tasks unprompted
Summary
In copier
>= 9.5.0, <= 9.15.1, thetrustsetting's prefix match(
copier/_settings.py) compares the template URL against a trusted prefix witha raw
str.startswithand no path normalization, while the URL isnormalized when the template is actually fetched (
Path.resolve()for localpaths; libcurl dot-segment removal for
https). A template reference thattextually starts with a trusted prefix but contains
..(e.g.
https://github.com/trusted-org/../attacker-org/repo.git) is thereforegranted trust yet resolves to a different, attacker-controlled template, whose
tasks/migrations/jinja_extensionsthen run without the--trustprompt — arbitrary command execution. Likely CWE-22 (Improper Limitation of
a Pathname) in the trust check leading to CWE-94 (code execution).
Details
trustlets users mark template locations as trusted so copier skips theunsafe-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(tagv9.15.1):_normalizeonly expands~; it does not touch..or collapse segments —copier/_settings.py:149-152(tagv9.15.1):This decision gates code execution —
copier/_main.py:293(tagv9.15.1):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 togit/pathlibis normalized, sothe template actually loaded is
evilorg/t(a different, attacker-owned org).Trust is granted to a location the user never trusted, and
_check_unsafereturns early, so the malicious template's tasks execute with no prompt.
This is most acute on
copier update, which reads_src_pathfrom theproject's
.copier-answers.yml(copier/_subproject.py) — i.e. an attacker whohands 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()plusis_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
copierCLI only. Static by default (copier copy --pretendreaches the trust decision but does not execute tasks);
--prove-execis anopt-in supplementary run that fires an inert marker (
echo+touch). The fullpoc.pyaccompanies this report.Build and run:
Observed output (
copier 9.15.1):The exploit is the same template as the control plus the minimal delta
<trusted_prefix>/... Deterministic: same input → same result. The PoC uses alocal trusted prefix for a self-contained, network-free run; the
httpscase isidentical because git normalizes
..before the request — e.g.git ls-remote "https://github.com/copier-org/../pallets/flask.git"emitswarning: redirecting to https://github.com/pallets/flask.git/and returnspallets/flask's refs, a different org than the trustedcopier-org/.Impact
A user who has configured a trusted prefix (a trailing-
/entry intrust, a documented feature) no longer gets the unsafe-feature prompt for atemplate 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 updateon, since_src_pathcomes from that project's.copier-answers.yml— can host the real template under a differentorg/location reached via
..and have itstasks/migrations/jinja_extensionsexecute arbitrary commands with no prompt. It fires on adefault, 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:Preflects 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. Conservativevariant if you scope impact to the user account only (no host escape claim):
drop
SC/SI/SAtoN.Recommended fix
Normalize both sides before comparing, instead of raw
startswith. Forlocal entries, compare resolved absolute paths (
Path(t).resolve()vsPath(repository).resolve()) using segment containment /is_relative_to, thepattern already used in
_render_templateandtemplate_copy_root. For URLentries, parse the URL and reject or collapse
.././empty path segmentsbefore the prefix test. As defense-in-depth, reject any
_src_pathread from ananswers file that contains
..segments after the scheme/host, sincelegitimate template URLs never need them.
References
v9.15.1):copier/_settings.py:141-146(prefix match),copier/_settings.py:149-152(_normalize),copier/_main.py:293(trust gate).docs/settings.md("Locations ending with/will be matched as prefixes").
https..normalization: libcurl removes dot segments by default(
CURLOPT_PATH_AS_ISdefaults to off) — https://curl.se/libcurl/c/CURLOPT_PATH_AS_IS.htmlread/write traversal in rendered output; this is an authorization bypass in
the
trustsetting's URL matching. The flawed match is identical betweenreleased
v9.15.1and currentmasterHEAD, and unchanged since thetrust-prefix feature was introduced in
v9.5.0(originallycopier/settings.py,commit
71358ed; renamed tocopier/_settings.pyin the v9.12.0 refactor).