aio-libs / aio-libs/yarl

Mask password in `URL.__repr__` for safer debugging output

Abierto
#1,629 0 comentarios 0 reacciones 0 asignados Ver en GitHub
enhancement
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

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.