Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGES/1629.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Mask the password component in ``yarl.URL.__repr__`` to prevent accidental credential exposure in logs and debug
output -- by :user:`mbaas038` and :user:`jhbuhrman`.
8 changes: 4 additions & 4 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ def test_url_is_not_str() -> None:


def test_str() -> None:
url = URL("http://example.com:8888/path/to?a=1&b=2")
assert str(url) == "http://example.com:8888/path/to?a=1&b=2"
url = URL("http://user:password@example.com:8888/path/to?a=1&b=2")
assert str(url) == "http://user:password@example.com:8888/path/to?a=1&b=2"


def test_repr() -> None:
url = URL("http://example.com")
assert "URL('http://example.com')" == repr(url)
url = URL("http://user:password@example.com")
assert "URL('http://user:********@example.com')" == repr(url)


def test_origin() -> None:
Expand Down
3 changes: 3 additions & 0 deletions yarl/_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def make_netloc(
host: str | None,
port: int | None,
encode: bool = False,
mask_password: bool = False,
) -> str:
"""Make netloc from parts.

Expand All @@ -176,6 +177,8 @@ def make_netloc(
if user is None and password is None:
return ret
if password is not None:
if mask_password:
password = "********"
if not user:
user = ""
elif encode:
Expand Down
22 changes: 16 additions & 6 deletions yarl/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,24 +491,34 @@ def build(
def __init_subclass__(cls) -> NoReturn:
raise TypeError(f"Inheriting a class {cls!r} from URL is forbidden")

def __str__(self) -> str:
def _make_string(self, mask_password: bool = False) -> str:
if not self._path and self._netloc and (self._query or self._fragment):
path = "/"
else:
path = self._path
if (port := self.explicit_port) is not None and port == DEFAULT_PORTS.get(
self._scheme
):
if (
(port := self.explicit_port) is not None
and port == DEFAULT_PORTS.get(self._scheme)
Comment thread
mbaas038 marked this conversation as resolved.
) or mask_password:
# port normalization - using None for default ports to remove from rendering
# https://datatracker.ietf.org/doc/html/rfc3986.html#section-6.2.3
host = self.host_subcomponent
netloc = make_netloc(self.raw_user, self.raw_password, host, None)
netloc = make_netloc(
self.raw_user,
self.raw_password,
host,
None,
mask_password=mask_password,
)
else:
netloc = self._netloc
return unsplit_result(self._scheme, netloc, path, self._query, self._fragment)

def __str__(self) -> str:
return self._make_string(mask_password=False)

def __repr__(self) -> str:
return f"{self.__class__.__name__}('{str(self)}')"
return f"{self.__class__.__name__}('{self._make_string(mask_password=True)}')"
Copy link
Copy Markdown

@jhbuhrman jhbuhrman Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An additional consideration: since repr() returns now a string that wouldn't yield anymore an object with the same value when passed to eval() in case .password is not None, it could perhaps be better to change the repr (perhaps in that case only, .password is not None) into a string enclosed in angle brackets that contains the name of the type of the object together with additional information often including the name and address of the object. [Italic parts quoted from http://docs.python.org/3/library/functions.html#repr ].

Suggestion:

Suggested change
return f"{self.__class__.__name__}('{self._make_string(mask_password=True)}')"
return (
f"{self.__class__.__name__}(repr({self._make_string(mask_password=False)}))"
if self.password is None
else f"<{self.__class__.__name__} repr({self._make_string(mask_password=True)}) (masked)>"
)

This also requires changing the tests.

A more general remark: the explicit insertion of single quotes is tricky anyway for certain edge cases, better rely on repr() on the generated string value.


def __bytes__(self) -> bytes:
return str(self).encode("ascii")
Expand Down
Loading