diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 4e8d0784..f8673684 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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 diff --git a/unshackle/commands/search.py b/unshackle/commands/search.py index 2158f57d..2087dc8f 100644 --- a/unshackle/commands/search.py +++ b/unshackle/commands/search.py @@ -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) diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 01311b74..eb167603 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -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, ) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 33abbdf9..f1462166 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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, diff --git a/unshackle/core/proxies/basic.py b/unshackle/core/proxies/basic.py index 0392afdd..60e2ce38 100644 --- a/unshackle/core/proxies/basic.py +++ b/unshackle/core/proxies/basic.py @@ -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 diff --git a/unshackle/core/proxies/resolve.py b/unshackle/core/proxies/resolve.py index 83ea9982..dae6f721 100644 --- a/unshackle/core/proxies/resolve.py +++ b/unshackle/core/proxies/resolve.py @@ -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}") diff --git a/unshackle/core/service.py b/unshackle/core/service.py index 835d3833..c654026b 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -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: diff --git a/unshackle/core/utils/redact.py b/unshackle/core/utils/redact.py index 1be79afe..e06f52a1 100644 --- a/unshackle/core/utils/redact.py +++ b/unshackle/core/utils/redact.py @@ -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)