Mask password in `URL.__repr__` for safer debugging output
- Lenguaje dominante
- Python
- Estrellas
- 1.5k
- Forks
- 215
- Merge medio
- 1 d 2 min
- PR fusionados (30 d)
- 13
Descripción
### Is your feature request related to a problem?
Currently, `__repr__` of `yarl.URL` (from [yarl](https://github.com/aio-libs/yarl)) includes the raw password when a URL contains user credentials:
```python
>>> 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:
```python
>>> 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:
```python
>>> class MyUrl(yarl.URL):
... def __repr__(self):
... return "SECRET"
...
Traceback (most recent call last):
File "", line 1, in
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 from URL is forbidden
```
### Additional context
We can make a PR request for this specific change. Below is a patch of the required changes:
```patch
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](https://www.soprasteria.nl/) Pythoneers
### Code of Conduct
- [x] I agree to follow the aio-libs Code of Conduct
Guía de contribución
Evaluación
Este issue todavía no se ha evaluado.