Skip to content

URL.__repr__: mask the password component so repr() does not leak credentials into logs#1793

Open
HrachShah wants to merge 1 commit into
aio-libs:masterfrom
HrachShah:fix/repr-mask-password
Open

URL.__repr__: mask the password component so repr() does not leak credentials into logs#1793
HrachShah wants to merge 1 commit into
aio-libs:masterfrom
HrachShah:fix/repr-mask-password

Conversation

@HrachShah

Copy link
Copy Markdown

Summary

URL.__repr__ currently embeds the raw password component from the URL's
userinfo (when present), so any time a URL instance is rendered through
repr() (logging frameworks, REPL sessions, exception tracebacks) the
credentials flow through:

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

This is a common source of credential leakage in operational settings:
an exception bubbled up through an HTTP client, an interactive debugger,
or a JSON dump of context all surface the password.

The fix replaces the password in __repr__ with a fixed redaction marker
whose length matches the original password, so a redacted URL('https://user:******@example.com') keeps a stable width regardless of password
value (handy for log diffing) but does not reveal anything about the
password itself.

>>> from yarl import URL
>>> URL("https://user:secret@example.com")
URL('https://user:******@example.com')   # password masked
>>> URL("https://user:p%40ssword@example.com")
URL('https://user:**********@example.com')  # matches raw_password length
>>> str(URL("https://user:secret@example.com"))
'https://user:secret@example.com'   # str() is unchanged: callers that
                                    # explicitly opted in to rendering
                                    # the URL still see the password.

Changes

  • yarl/_url.py: add a _URL_REPR_PASSWORD_MASK constant and a
    _repr_str() helper. __repr__ now uses _repr_str() which rebuilds
    the netloc with the password replaced by the marker of equal length.
    When raw_password is None (the common case), it just returns
    str(self) so the no-credentials path keeps its existing behavior and
    no string is allocated for the masked form.
  • tests/test_url.py: new test_repr_masks_password covering
    user+password, empty-user+password, empty-password (no-op), plain URL
    (no-op), and a percent-encoded password. The test also pins the
    behavior that str() is unchanged so the new masking in repr() is
    strictly opt-out for callers that really want to see the password.

Verification

  • python -m pytest tests/ -o "addopts=" --timeout=60 -q reports
    1455 passed, 101 skipped, 4 xfailed (4 xfailed are pre-existing and
    unrelated to this change).
  • The new test_repr_masks_password is the only new test, and it
    fails on the unpatched code (regr test covers the regression).

Migration impact

__repr__ is documented as "for developers" and is allowed to change
across releases. The string returned by str(URL) is unchanged, so
serialization paths and any code that explicitly renders a URL through
str() keep working as before.

Refs: #1629.

…dentials into logs

str() still surfaces the real password for callers that explicitly opted in; only the auto-rendered repr() used by logs, tracebacks, and debuggers is masked. The marker length matches the password length so the redacted URL keeps a stable width regardless of password value.

Closes aio-libs#1629.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant