Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES/1631.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Add :py:meth:`~yarl.URL.joinpath_safe` which appends each argument as a
single path segment, percent-encoding ``/`` characters and the standalone
relative-path indicators ``.`` and ``..`` so untrusted input cannot
introduce path traversal.
22 changes: 22 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,28 @@ The path is encoded if needed.

.. versionadded:: 1.9

.. method:: URL.joinpath_safe(*other)

Like :meth:`URL.joinpath`, but treat each argument as a single opaque
path segment. ``/`` characters inside an argument are percent-encoded so
they cannot introduce additional segments, and segments that consist
entirely of ``.`` or ``..`` are percent-encoded so they cannot be
interpreted as relative-path indicators.

Use this method to compose URLs from untrusted input — it prevents path
traversal attacks like ``api / "object" / user_supplied`` where
``user_supplied`` could otherwise be ``"../evil"``.

.. doctest::

>>> api = URL('https://api.example/api/v1/')
>>> api.joinpath_safe('object', '../evil')
URL('https://api.example/api/v1/object/..%2Fevil')
>>> api.joinpath_safe('..', 'etc/passwd')
URL('https://api.example/api/v1/%2E%2E/etc%2Fpasswd')

.. versionadded:: 1.24.0

.. method:: URL.__truediv__(url)

Shortcut for :meth:`URL.joinpath` with a single element and ``encoded=False``.
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ svetlov
uncompiled
unencoded
unquoter
untrusted
v1
yarl
92 changes: 92 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,98 @@ def test_joinpath_backtrack_to_base() -> None:
assert new_url.raw_path == "/"


@pytest.mark.parametrize(
"base,to_join,expected",
[
pytest.param(
"/api/v1/",
("object/id", "../evil_function"),
"http://example.com/api/v1/object%2Fid/..%2Fevil_function",
id="issue-1631-prevent-traversal",
),
pytest.param(
"/api/v1/",
("..",),
"http://example.com/api/v1/%2E%2E",
id="standalone-double-dot",
),
pytest.param(
"/api/v1/",
(".",),
"http://example.com/api/v1/%2E",
id="standalone-single-dot",
),
pytest.param(
"/api/v1/",
("..", "..", "etc/passwd"),
"http://example.com/api/v1/%2E%2E/%2E%2E/etc%2Fpasswd",
id="multi-traversal-blocked",
),
pytest.param(
"/path",
("file.txt",),
"http://example.com/path/file.txt",
id="dot-in-segment-preserved",
),
pytest.param(
"/path",
("a/b/c",),
"http://example.com/path/a%2Fb%2Fc",
id="slashes-encoded",
),
pytest.param(
"/path",
("a b",),
"http://example.com/path/a%20b",
id="space-encoded",
),
pytest.param(
"/path",
("%2E%2E",),
"http://example.com/path/%252E%252E",
id="percent-literal-is-encoded",
),
pytest.param(
"",
("foo", "bar"),
"http://example.com/foo/bar",
id="simple-segments",
),
pytest.param(
"/p",
(),
"http://example.com/p",
id="no-segments",
),
],
)
def test_joinpath_safe(base: str, to_join: tuple[str, ...], expected: str) -> None:
url = URL(f"http://example.com{base}")
assert str(url.joinpath_safe(*to_join)) == expected


def test_joinpath_safe_prevents_traversal_to_parent() -> None:
"""joinpath_safe must never reduce the path below the base."""
base = URL("https://api.example/api/v1/")
user_input = "../evil_function"
result = base.joinpath_safe("object/id", user_input)
# No traversal occurred: still under /api/v1/
assert result.path.startswith("/api/v1/")
assert "evil_function" not in result.path.split("/")[1:4]


def test_joinpath_safe_rejects_non_str() -> None:
with pytest.raises(TypeError):
URL("http://example.com").joinpath_safe(123) # type: ignore[arg-type]


def test_joinpath_safe_round_trip_via_parts() -> None:
"""Path segments survive as opaque values."""
url = URL("http://example.com/").joinpath_safe("a/b", "..", "c.d")
# parts decodes percent-encoding, so we get the original segment back
assert url.parts[-3:] == ("a/b", "..", "c.d")


def test_joinpath_single_empty_segments() -> None:
"""joining standalone empty segments does not create empty segments"""
a = URL("/1//2///3")
Expand Down
1 change: 1 addition & 0 deletions yarl/_quoters.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
REQUOTER = _Quoter()
PATH_QUOTER = _Quoter(safe="@:", protected="/+", requote=False)
PATH_REQUOTER = _Quoter(safe="@:", protected="/+")
PATH_SAFE_QUOTER = _Quoter(safe="@:", protected="+", requote=False)
QUERY_QUOTER = _Quoter(safe="?/:@", protected="=+&;", qs=True, requote=False)
QUERY_REQUOTER = _Quoter(safe="?/:@", protected="=+&;", qs=True)
QUERY_PART_QUOTER = _Quoter(safe="?/:@", qs=True, requote=False)
Expand Down
22 changes: 22 additions & 0 deletions yarl/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
FRAGMENT_REQUOTER,
PATH_QUOTER,
PATH_REQUOTER,
PATH_SAFE_QUOTER,
PATH_SAFE_UNQUOTER,
PATH_UNQUOTER,
QS_UNQUOTER,
Expand Down Expand Up @@ -1471,6 +1472,27 @@ def joinpath(self, *other: str, encoded: bool = False) -> "URL":
"""Return a new URL with the elements in other appended to the path."""
return self._make_child(other, encoded=encoded)

def joinpath_safe(self, *other: str) -> "URL":
"""Append path segments while preventing path traversal.

Unlike :meth:`joinpath`, each argument is treated as a single path
segment: ``/`` characters inside an argument are percent-encoded so
they cannot introduce additional segments, and segments consisting
entirely of ``.`` or ``..`` are percent-encoded so they cannot be
interpreted as relative-path indicators.
"""
safe_segments: list[str] = []
for segment in other:
if not isinstance(segment, str):
raise TypeError("Argument should be str")
if segment == ".":
safe_segments.append("%2E")
elif segment == "..":
safe_segments.append("%2E%2E")
else:
safe_segments.append(PATH_SAFE_QUOTER(segment))
return self._make_child(safe_segments, encoded=True)

def human_repr(self) -> str:
"""Return decoded human readable string for URL representation."""
user = human_quote(self.user, "#/:?@[]")
Expand Down
Loading