Skip to content

Mask password in URL.__repr__ for safer debugging output #1629

@mbaas038

Description

@mbaas038

Is your feature request related to a problem?

Currently, __repr__ of yarl.URL (from yarl) includes the raw password when a URL contains user credentials:

>>> from yarl import URL
>>> URL("https://user:secret@example.com")
URL('https://user:secret@example.com')

This behavior can unintentionally expose sensitive credentials in logs, tracebacks, debugging sessions, or error reporting systems where repr() is implicitly used.

Describe the solution you'd like

__repr__ should mask the password component, similar to how many HTTP clients and URL tooling libraries handle credential rendering. For example:

>>> from yarl import URL
>>> URL('https://user:secret@example.com')
URL('https://user:********@example.com')

or another clearly redacted placeholder.

Rationale

  • repr() is frequently surfaced in logs and diagnostics.
  • Accidental credential leakage via logging is a common operational risk.
  • Masking credentials in representations aligns with the principle of least exposure.
  • This change makes the ecosystem incrementally ("epsilon") safer with minimal behavioral disruption.

Importantly, this proposal affects only the string representation — not the internal storage or functional behavior of the URL object.

Backward Compatibility Considerations

  • The change alters the exact repr() output, which may affect strict string-based tests.
  • However, this impact is limited and security-positive.
  • If necessary, the full URL (including password) remains accessible via explicit API accessors or by str().

Describe alternatives you've considered

Creating a subclass from the yarl.URL to implement masking logic. However, inheriting from the URL class is forbidden:

>>> class MyUrl(yarl.URL):
...     def __repr__(self):
...         return "SECRET"
... 
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "********/.venv/Lib/site-packages/yarl/_url.py", line 482, in __init_subclass__
    raise TypeError(f"Inheriting a class {cls!r} from URL is forbidden")
TypeError: Inheriting a class <class '__main__.MyUrl'> from URL is forbidden
 

Additional context

We can make a PR request for this specific change. Below is a patch of the required changes:

Index: CHANGES/1629.feature.rst
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/CHANGES/1629.feature.rst b/CHANGES/1629.feature.rst
new file mode 100644
--- /dev/null	(revision 2f914e6f3a72719e30dd434c99609159aa562c57)
+++ b/CHANGES/1629.feature.rst	(revision 2f914e6f3a72719e30dd434c99609159aa562c57)
@@ -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`.
Index: tests/test_url.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/test_url.py b/tests/test_url.py
--- a/tests/test_url.py	(revision 7829b01ec6217349dfce3a1dd3190d29ddbb14a9)
+++ b/tests/test_url.py	(revision 2f914e6f3a72719e30dd434c99609159aa562c57)
@@ -57,13 +57,13 @@
 
 
 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:
Index: yarl/_parse.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/yarl/_parse.py b/yarl/_parse.py
--- a/yarl/_parse.py	(revision 7829b01ec6217349dfce3a1dd3190d29ddbb14a9)
+++ b/yarl/_parse.py	(revision 2f914e6f3a72719e30dd434c99609159aa562c57)
@@ -161,6 +161,7 @@
     host: str | None,
     port: int | None,
     encode: bool = False,
+    mask_password: bool = False,
 ) -> str:
     """Make netloc from parts.
 
@@ -176,6 +177,8 @@
     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:
Index: yarl/_url.py
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/yarl/_url.py b/yarl/_url.py
--- a/yarl/_url.py	(revision 7829b01ec6217349dfce3a1dd3190d29ddbb14a9)
+++ b/yarl/_url.py	(revision 2f914e6f3a72719e30dd434c99609159aa562c57)
@@ -491,24 +491,31 @@
     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)
+            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=True
+            )
         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)}')"
 
     def __bytes__(self) -> bytes:
         return str(self).encode("ascii")

This patch was kindly supported by Sopra Steria Pythoneers

Code of Conduct

  • I agree to follow the aio-libs Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions