From e86184986fe33f04b63820ec397ec79a2163c63b Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 3 Jul 2026 16:20:50 -0400 Subject: [PATCH] fix: mask_url re-encodes non-sensitive query values on rebuild (v0.3.4) the identity quote_via lambda disabled percent-encoding on re-emit, so a non-sensitive value with a reserved character (e.g. x=%26%3D) came out structurally corrupted (x=&=). re-encode with quote_plus (safe="*" so the masked "***" stays literal) instead of the identity function - display fidelity only, the secret is still always masked to "***" first. Signed-off-by: disqualifier --- README.md | 12 +++++++----- pyproject.toml | 2 +- src/commons/__init__.py | 2 +- src/commons/masking.py | 10 +++++++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 55f418e..18b4134 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,15 @@ Small sync helpers shared across projects. Base is stdlib only — **no dependen ## Install ``` -commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.3 +commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.4 # async address/geo lookups (fetch_ip / ip_location / fetch_location) need the extra: -commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.3 +commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.3.4 ``` The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and only for the geo lookups — the pure `commons.addr.ip` utilities ship in base. -Drop the `@v0.3.3` suffix from the line above to install the latest unpinned. +Drop the `@v0.3.4` suffix from the line above to install the latest unpinned. ## timing @@ -135,8 +135,10 @@ mask_proxy("1.2.3.4:8080") # -> "1.2.3.4:8080" (no auth, unt `mask_url` strips `user:pass@` userinfo and replaces the values of sensitive query params (`apiKey`, `token`, `password`, `secret`, …; override via `keys=`) with `***`. -`mask_proxy` bullets the password of a `host:port:user:password` spec. Non-URL / -non-conforming input is returned unchanged. +Non-sensitive query values are re-percent-encoded on the way out, so a value with a +reserved character (`&`, `=`, a space, …) round-trips correctly instead of corrupting +the rebuilt URL. `mask_proxy` bullets the password of a `host:port:user:password` +spec. Non-URL / non-conforming input is returned unchanged. ## retry diff --git a/pyproject.toml b/pyproject.toml index fec0281..38605d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "commons" -version = "0.3.3" +version = "0.3.4" description = "small stdlib-based sync helpers: time/timezone deltas, dotted-path dict access, display masking, ip/address tooling, and retry/backoff" requires-python = ">=3.10" dependencies = [] diff --git a/src/commons/__init__.py b/src/commons/__init__.py index b451640..9e671e6 100644 --- a/src/commons/__init__.py +++ b/src/commons/__init__.py @@ -53,4 +53,4 @@ __all__ = [ "aretry", ] -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/src/commons/masking.py b/src/commons/masking.py index 0cf1ea6..d637377 100644 --- a/src/commons/masking.py +++ b/src/commons/masking.py @@ -3,7 +3,7 @@ DISPLAY only, not a security control: the underlying value is unchanged and still needs proper handling (encryption at rest, etc.). """ -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import parse_qsl, quote_plus, urlencode, urlsplit, urlunsplit SENSITIVE_QUERY_KEYS = frozenset( {"apikey", "api_key", "key", "token", "access_token", "refresh_token", @@ -69,7 +69,10 @@ def mask_url(url: str, *, keys: "frozenset[str] | None" = None) -> str: """redact credentials in a url for logging: drop userinfo, ``***`` sensitive query values param-name matching is case-insensitive; ``keys`` overrides the default sensitive-name - set. non-url or unparseable input is returned unchanged. + set. non-url or unparseable input is returned unchanged. non-sensitive query values are + re-percent-encoded on the way out (``quote_plus``), so a value containing a reserved + character (e.g. ``&``, ``=``, a space) round-trips correctly instead of corrupting the + rebuilt url. """ sensitive = SENSITIVE_QUERY_KEYS if keys is None else frozenset(k.lower() for k in keys) try: @@ -86,7 +89,8 @@ def mask_url(url: str, *, keys: "frozenset[str] | None" = None) -> str: pairs = parse_qsl(query, keep_blank_values=True) query = urlencode( [(k, "***" if k.lower() in sensitive else v) for k, v in pairs], - quote_via=lambda s, *_: s, + safe="*", + quote_via=quote_plus, ) return urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))