Skip to content

Commit 7408f0d

Browse files
committed
fix: prevent trust bypass via encoded URL traversal
A template URL with percent-encoded parent-directory segments (e.g. `%2e%2e`, `%2E%2E`, `%2e.`, `.%2e`) or backslashes (`%5c`, `%5C`) could match a trusted URL prefix while HTTP servers and Git transports decode the segments and ultimately fetch a repository outside the trusted prefix. This allowed unsafe features from an untrusted repository to run without explicit trust. Trust matching now decodes percent-encoded characters in URL paths before resolving dot segments, so the form used for the trust check matches what the server actually resolves. The fix also neutralizes encoded path separators (`%2f`) and folds backslashes (literal or `%5c`) to forward slashes, since some servers treat them as path separators. Local filesystem paths are unaffected: the OS does not decode `%2e%2e`, and Git treats it as a literal directory name.
1 parent b8e2218 commit 7408f0d

2 files changed

Lines changed: 86 additions & 3 deletions

File tree

copier/_settings.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from os.path import expanduser
1111
from pathlib import Path
1212
from typing import Any
13-
from urllib.parse import urlsplit, urlunsplit
13+
from urllib.parse import unquote, urlsplit, urlunsplit
1414

1515
import yaml
1616
from platformdirs import user_config_path
@@ -154,8 +154,16 @@ def _normalize(url: str) -> str:
154154
url = expanduser(url) # noqa: PTH111
155155
if "://" in url:
156156
parts = urlsplit(url)
157-
path = posixpath.normpath(parts.path) if parts.path else parts.path
158-
if parts.path.endswith("/") and not path.endswith("/"):
157+
# Percent-decode before normalizing so that encoded dot segments (e.g.
158+
# `%2e%2e`) and encoded separators (e.g. `%2f`) are collapsed by
159+
# `posixpath.normpath`. Backslashes (literal or `%5c`) are folded to
160+
# forward slashes because some servers and intermediaries treat them
161+
# as path separators while `posixpath.normpath` does not. Otherwise
162+
# the form used for the trust check could differ from what the
163+
# HTTP/Git layer ultimately resolves, allowing a trust-prefix bypass.
164+
decoded_path = unquote(parts.path).replace("\\", "/")
165+
path = posixpath.normpath(decoded_path) if decoded_path else decoded_path
166+
if decoded_path.endswith("/") and not path.endswith("/"):
159167
path += "/"
160168
return urlunsplit(
161169
(parts.scheme, parts.netloc, path, parts.query, parts.fragment)

tests/test_settings.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,81 @@ def test_load_settings_with_invalid_data(
169169
{"https://github.com/user/../evil/repo.git"},
170170
True,
171171
),
172+
(
173+
"https://github.com/user/%2e%2e/evil/repo.git",
174+
{"https://github.com/user/"},
175+
False,
176+
),
177+
(
178+
"https://github.com/user/%2E%2E/evil/repo.git",
179+
{"https://github.com/user/"},
180+
False,
181+
),
182+
(
183+
"https://github.com/user/%2e%2E/evil/repo.git",
184+
{"https://github.com/user/"},
185+
False,
186+
),
187+
(
188+
"https://github.com/user/%2e./evil/repo.git",
189+
{"https://github.com/user/"},
190+
False,
191+
),
192+
(
193+
"https://github.com/user/.%2e/evil/repo.git",
194+
{"https://github.com/user/"},
195+
False,
196+
),
197+
(
198+
"https://github.com/user%2f%2e%2e%2fevil/repo.git",
199+
{"https://github.com/user/"},
200+
False,
201+
),
202+
(
203+
"https://github.com/user/%2e%2e/evil/repo.git",
204+
{"https://github.com/user/%2e%2e/evil/repo.git"},
205+
True,
206+
),
207+
(
208+
"https://github.com/user/%2e%2e/evil/repo.git",
209+
{"https://github.com/user/../evil/repo.git"},
210+
True,
211+
),
212+
(
213+
"https://github.com/user/%2e%2e%5cevil/repo.git",
214+
{"https://github.com/user/"},
215+
False,
216+
),
217+
(
218+
"https://github.com/user/%2e%2e%5Cevil/repo.git",
219+
{"https://github.com/user/"},
220+
False,
221+
),
222+
(
223+
"https://github.com/user/..%5cevil/repo.git",
224+
{"https://github.com/user/"},
225+
False,
226+
),
227+
(
228+
"https://github.com/user/..\\evil/repo.git",
229+
{"https://github.com/user/"},
230+
False,
231+
),
232+
(
233+
"https://github.com/user/%2e%2e%5cevil/repo.git",
234+
{"https://github.com/user/%2e%2e%5cevil/repo.git"},
235+
True,
236+
),
237+
(
238+
"https://github.com/user/%2e%2e%5cevil/repo.git",
239+
{"https://github.com/user/..\\evil/repo.git"},
240+
True,
241+
),
242+
(
243+
"https://github.com/user/%2e%2e%5cevil/repo.git",
244+
{"https://github.com/user/../evil/repo.git"},
245+
True,
246+
),
172247
(f"{Path.home()}/template", [], False),
173248
(f"{Path.home()}/template", {f"{Path.home()}/template"}, True),
174249
(f"{Path.home()}/template", {"~/template"}, True),

0 commit comments

Comments
 (0)