feat(proxy): mask proxy credentials in console and API output

Show user:pass as xxxxx:xxxxx in "Using ... Proxy" lines (full URI with -d), hide Basic provider hostnames, and redact proxy URIs in REST API error responses and remote session logs.
This commit is contained in:
imSp4rky
2026-08-31 09:45:19 -06:00
parent a01ff594da
commit 57ddefc69b
8 changed files with 81 additions and 48 deletions
+12 -5
View File
@@ -97,6 +97,7 @@ from unshackle.core.utils.click_types import (
)
from unshackle.core.utils.collections import ci_get, merge_dict
from unshackle.core.utils.post_scripts import build_context, dispatch, season_context
from unshackle.core.utils.redact import mask_proxy
from unshackle.core.utils.selector import select_multiple
from unshackle.core.utils.subprocess import ffprobe
from unshackle.core.vaults import Vaults
@@ -1342,6 +1343,12 @@ class dl:
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
if proxy:
def log_proxy_used(provider: object, uri: str) -> None:
self.log.info(
f"Using {provider.__class__.__name__} Proxy: {mask_proxy(uri, isinstance(provider, Basic))}"
)
requested_provider = None
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
# requesting proxy from a specific proxy provider
@@ -1386,7 +1393,7 @@ class dl:
location = ", ".join(p for p in location_parts if p)
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log_proxy_used(proxy_provider, proxy)
else:
display = None
if hasattr(proxy_provider, "last_connection_display"):
@@ -1394,7 +1401,7 @@ class dl:
if display:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log_proxy_used(proxy_provider, proxy)
else:
for proxy_provider in self.proxy_providers:
proxy_uri = proxy_provider.get_proxy(proxy)
@@ -1408,7 +1415,7 @@ class dl:
location = ", ".join(p for p in location_parts if p)
self.log.info(f"VPN Connected: {conn_info['public_ip']} ({location})")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log_proxy_used(proxy_provider, proxy)
else:
display = None
if hasattr(proxy_provider, "last_connection_display"):
@@ -1416,13 +1423,13 @@ class dl:
if display:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
else:
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log_proxy_used(proxy_provider, proxy)
break
# Store proxy query info for service-specific overrides
ctx.params["proxy_query"] = proxy_query
ctx.params["proxy_provider"] = requested_provider
else:
self.log.info(f"Using explicit Proxy: {proxy}")
self.log.info(f"Using explicit Proxy: {mask_proxy(ctx.params['proxy'])}")
# For explicit proxies, store None for query/provider
ctx.params["proxy_query"] = None
ctx.params["proxy_provider"] = None
+10 -3
View File
@@ -21,6 +21,7 @@ from unshackle.core.service import Service
from unshackle.core.services import Services
from unshackle.core.utils.click_types import ContextData
from unshackle.core.utils.collections import merge_dict
from unshackle.core.utils.redact import mask_proxy
@click.command(
@@ -113,7 +114,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
if display:
log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
else:
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log.info(
f"Using {proxy_provider.__class__.__name__} Proxy: "
f"{mask_proxy(proxy, isinstance(proxy_provider, Basic))}"
)
else:
for proxy_provider in proxy_providers:
proxy_uri = proxy_provider.get_proxy(proxy)
@@ -125,10 +129,13 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
if display:
log.info(f"Using {proxy_provider.__class__.__name__} Proxy {display}")
else:
log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
log.info(
f"Using {proxy_provider.__class__.__name__} Proxy: "
f"{mask_proxy(proxy, isinstance(proxy_provider, Basic))}"
)
break
else:
log.info(f"Using explicit Proxy: {proxy}")
log.info(f"Using explicit Proxy: {mask_proxy(ctx.params['proxy'])}")
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
+3 -1
View File
@@ -14,6 +14,8 @@ from typing import Any
from aiohttp import web
from unshackle.core.utils.redact import redact_text
class APIErrorCode(str, Enum):
"""Standard API error codes for programmatic error handling."""
@@ -185,7 +187,7 @@ def categorize_exception(
if "proxy" in str(root).lower():
return APIError(
error_code=APIErrorCode.INVALID_PROXY,
message=f"The proxy for this session failed: {root}",
message=f"The proxy for this session failed: {redact_text(str(root))}",
details={**context, "reason": "proxy_error"},
retryable=False,
)
+3 -3
View File
@@ -26,7 +26,7 @@ from unshackle.core.services import Services
from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.utils.collections import ci_get
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_text
log = logging.getLogger("api")
@@ -2959,8 +2959,8 @@ def resolve_handler_proxy(
if allowed:
raise APIError(
APIErrorCode.INVALID_PROXY,
f"Proxy error: {e}",
details={"proxy": data.get("proxy"), "service": normalized_service},
f"Proxy error: {redact_text(str(e))}",
details={"proxy": redact_text(data.get("proxy")), "service": normalized_service},
)
raise APIError(
APIErrorCode.INVALID_PROXY,
+5 -1
View File
@@ -6,6 +6,7 @@ from requests.utils import prepend_scheme_if_needed
from urllib3.util import parse_url
from unshackle.core.proxies.proxy import Proxy
from unshackle.core.utils.redact import mask_proxy
class Basic(Proxy):
@@ -49,6 +50,9 @@ class Basic(Proxy):
proxy = prepend_scheme_if_needed(proxy, "http")
parsed_proxy = parse_url(proxy)
if not parsed_proxy.host:
raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.")
raise ValueError(
f"The proxy '{mask_proxy(proxy, mask_host=True, allow_debug=False)}' "
"is not a valid proxy URI supported by Python-Requests."
)
return proxy
+10 -2
View File
@@ -9,6 +9,8 @@ import logging
import re
from typing import Any, List, Optional
from unshackle.core.utils.redact import mask_proxy
log = logging.getLogger("proxies")
@@ -85,13 +87,19 @@ def resolve_proxy(proxy: str, proxy_providers: List[Any]) -> Optional[str]:
proxy_uri = provider.get_proxy(query)
if not proxy_uri:
raise ValueError(f"Proxy provider {requested_provider} had no proxy for {query}")
log.info(f"Using {provider.__class__.__name__} Proxy: {proxy_uri}")
log.info(
f"Using {provider.__class__.__name__} Proxy: "
f"{mask_proxy(proxy_uri, provider.__class__.__name__ == 'Basic', allow_debug=False)}"
)
return proxy_uri
for provider in proxy_providers:
proxy_uri = provider.get_proxy(query)
if proxy_uri:
log.info(f"Using {provider.__class__.__name__} Proxy: {proxy_uri}")
log.info(
f"Using {provider.__class__.__name__} Proxy: "
f"{mask_proxy(proxy_uri, provider.__class__.__name__ == 'Basic', allow_debug=False)}"
)
return proxy_uri
raise ValueError(f"No proxy provider had a proxy for {proxy}")
+12 -33
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass, field
from http.cookiejar import CookieJar
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
from urllib.parse import urlparse, urlunparse
if TYPE_CHECKING:
from unshackle.core.api.input_bridge import InputBridge
@@ -23,6 +22,7 @@ from unshackle.core.console import console, prompt_user
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.drm import DRM_T
from unshackle.core.proxies.basic import Basic
from unshackle.core.search_result import SearchResult
from unshackle.core.session import (
BACKOFF_FACTOR,
@@ -39,6 +39,7 @@ from unshackle.core.titles import Title_T, Titles_T, remap_titles
from unshackle.core.tracks import Chapters, Tracks
from unshackle.core.tracks.video import Video
from unshackle.core.utils.ip_info import get_ip_info
from unshackle.core.utils.redact import mask_proxy
# Default (connect, read) timeout for the requests path, mirroring RnetSession's
# connect_timeout / read_timeout construction defaults. A per-request timeout= wins.
@@ -123,43 +124,19 @@ class TrackRequest:
best_available: bool = False
def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optional[str]:
"""
Sanitise a proxy URI for logs by redacting any embedded userinfo (username/password).
Sanitise a proxy URI for logs by masking any embedded userinfo (username/password).
Examples:
- http://user:pass@host:8080 -> http://REDACTED@host:8080
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
``serve`` sends these log lines to the client of a remote session, so the mask is
unconditional and debug mode never lifts it. ``mask_host`` hides the hostname as
well, for a proxy that came from the user-supplied ``Basic`` proxy provider.
"""
if uri is None:
return None
if not isinstance(uri, str):
return str(uri)
if not uri:
return uri
try:
parsed = urlparse(uri)
# Handle schemeless proxies like "user:pass@host:port"
if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri:
# Parse as netloc using a dummy scheme, then strip scheme back out.
dummy = urlparse(f"http://{uri}")
netloc = dummy.netloc
if "@" in netloc:
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
# urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case.
return f"{netloc}{dummy.path}"
netloc = parsed.netloc
if "@" in netloc:
netloc = f"REDACTED@{netloc.split('@', 1)[1]}"
return urlunparse(parsed._replace(netloc=netloc))
except ValueError:
if "@" in uri:
return f"REDACTED@{uri.split('@', 1)[1]}"
return uri
return mask_proxy(uri, mask_host=mask_host, allow_debug=False)
class Service(metaclass=ABCMeta):
@@ -244,7 +221,8 @@ class Service(metaclass=ABCMeta):
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
)
else:
self.log.warning(
@@ -258,7 +236,8 @@ class Service(metaclass=ABCMeta):
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(
f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}"
f"Using mapped proxy from {proxy_provider.__class__.__name__}: "
f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}"
)
break
else:
+26
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import re
import sys
from pathlib import Path
@@ -32,6 +33,31 @@ def redact_text(text: Optional[str], secrets: Iterable[str] = ()) -> Optional[st
return text
PROXY_USERINFO_RE = re.compile(r"(^|://)[^:/@]+(?::[^/@]*)?@")
PROXY_HOST_RE = re.compile(r"(://(?:[^/@]*@)?)(\[[^\]]+\]|[^:/?#]+)")
def mask_proxy(uri: str, mask_host: bool = False, allow_debug: bool = True) -> str:
"""Replace proxy userinfo with ``xxxxx:xxxxx@host`` for console display.
``mask_host`` also replaces the hostname and keeps the scheme and the port, for
a user-supplied proxy whose hostname identifies the account on its own.
The full URI passes through while DEBUG logging is active (``-d``), so a debug
run keeps the complete proxy URI and a normal run never shows the credentials.
Pass ``allow_debug=False`` where the output can reach more than the operator's
own terminal, such as ``serve`` logs and the dashboard, so that debug mode does
not widen the exposure.
"""
if not isinstance(uri, str) or not uri:
return uri
if allow_debug and logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
return uri
uri = PROXY_USERINFO_RE.sub(r"\1xxxxx:xxxxx@", uri)
if mask_host:
uri = PROXY_HOST_RE.sub(r"\1xxxxx", uri)
return uri
def safe_display_url(url: str) -> str:
"""Rebuild a URL from its scheme/host/port/path only, so unshackle can never log the userinfo."""
parts = urlsplit(url)