From 99f0c269be5d88312d953144995b6bc2a42244ed Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 1 Nov 2026 20:04:06 -0700 Subject: [PATCH] fix(api): refuse a non-public proxy host for a key without server_proxy Closes the loopback and LAN route to the server's own forwarders, including backslash, percent-escape and *.localhost forms. --- docs/dev/rest-api/endpoints.md | 8 +-- docs/dev/rest-api/remote-sessions.md | 2 +- docs/reference/configuration/services.md | 6 ++ .../remote/unit/test_handler_proxy_policy.py | 61 +++++++++++++++++++ unshackle/core/api/handlers.py | 47 +++++++++++++- unshackle/core/api/routes.py | 6 +- 6 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index f556ce51..d3e2ef43 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -151,7 +151,7 @@ For a service with `anonymous_search`, the server does not log in and ignores `c | `service` | string | yes | - | Service tag. | | `query` | string | yes | - | Search query. | | `profile` | string | no | `null` | Credential/cookie profile to use. | -| `proxy` | string | no | `null` | Full proxy URI, a Control D resolver as `controld://@dns.controld.com`, or a country code when the API key has `server_proxy`. | +| `proxy` | string | no | `null` | Full proxy URI on a public host, a Control D resolver as `controld://@dns.controld.com`, or a country code when the API key has `server_proxy`. | | `no_proxy` | boolean | no | `false` | Force-disable all proxy use. | | `credentials`, `cookies`, `cache` | - | no | `null` | Your own login material, on a `--remote-only` server only. See [Client cache on a remote-only server](#client-cache-on-a-remote-only-server). | @@ -200,7 +200,7 @@ Get the list of titles behind a title ID, for example episodes or a movie, witho | `service` | string | yes | Service tag. | | `title_id` | string | yes | Title identifier. | | `profile` | string | no | Credential/cookie profile. | -| `proxy` | string | no | Full proxy URI, a Control D resolver as `controld://@dns.controld.com`, or a country code when the API key has `server_proxy`. | +| `proxy` | string | no | Full proxy URI on a public host, a Control D resolver as `controld://@dns.controld.com`, or a country code when the API key has `server_proxy`. | | `no_proxy` | boolean | no | Force-disable proxy. | | `cdm_type` | string | no | Preferred CDM type. | | `credentials`, `cookies`, `cache` | - | no | Your own login material, on a `--remote-only` server only. See [Client cache on a remote-only server](#client-cache-on-a-remote-only-server). | @@ -468,7 +468,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `all_drm` | boolean | `false` | License each track with both Widevine and PlayReady. Needs a CDM for each DRM system. Key vaults are read first; `cdm_only: true` sends a challenge for each system. The server refuses it with `cdm`, which selects one device. | | `export` | boolean | `false` | Export manifest/keys/subs to JSON. | | `cdm_only` | boolean | `null` | Force CDM-only (`true`) or vault-only (`false`) key retrieval. | -| `proxy` / `no_proxy` / `no_proxy_download` / `proxy_download` | string / bool / bool / string | `null` / `false` / `false` / `null` | Proxy controls. `proxy` and `proxy_download` must be full proxy URIs unless the API key has `server_proxy`. A Control D resolver, `controld://@dns.controld.com`, counts as a full proxy URI: the server runs a forwarder for it. | +| `proxy` / `no_proxy` / `no_proxy_download` / `proxy_download` | string / bool / bool / string | `null` / `false` / `false` / `null` | Proxy controls. `proxy` and `proxy_download` must be full proxy URIs on a public host unless the API key has `server_proxy`. A Control D resolver, `controld://@dns.controld.com`, counts as a full proxy URI: the server runs a forwarder for it. | | `no_folder` / `no_source` / `no_mux` | boolean | `false` | Output/mux controls. | | `sample` | boolean/string | `null` | Download a short sample and write it with a `SAMPLE` tag on the filename in a `Sample` folder inside the output folder. `true` is 60 seconds from 2 minutes in; `"0-60"` is a window in seconds. | | `include_sample` | boolean | `false` | Download the full title, then also cut a 60 second sample from each muxed file into a `Sample` folder next to it. | @@ -973,7 +973,7 @@ Make a remote session for a service and title. Authentication runs asynchronousl Service CLI options also travel in a nested `service_params` object. When the body has `service_params`, the server reads service options only from it, and a flat key with the same name stays a `dl` value. `profile` at the top level always means the credentials profile, never a service's own `--profile` option. -The `proxy` value must be a full proxy URI, unless the operator gives your API key `server_proxy` in the `serve.users` config. Without `server_proxy`, the server does not resolve country codes with its own proxy providers. It rejects the request with `INVALID_PROXY` when no `proxy` is set and the reported `client_region` differs from the server's region. A request that reports no `client_region` is not blocked. Pass `proxy` with your own proxy, or `no_proxy` to accept the server's own connection. With `server_proxy`, the server resolves a country code and picks a proxy for your `client_region` itself. A Control D resolver, `controld://@dns.controld.com`, counts as a full proxy URI: the server runs a forwarder for it and needs no Control D configuration of its own. +The `proxy` value must be a full proxy URI, unless the operator gives your API key `server_proxy` in the `serve.users` config. Without `server_proxy`, a proxy URI gets `INVALID_PROXY` when its host is not a public address or is not a plain host name or IP address. Without `server_proxy`, the server does not resolve country codes with its own proxy providers. It rejects the request with `INVALID_PROXY` when no `proxy` is set and the reported `client_region` differs from the server's region. A request that reports no `client_region` is not blocked. Pass `proxy` with your own proxy, or `no_proxy` to accept the server's own connection. With `server_proxy`, the server resolves a country code and picks a proxy for your `client_region` itself. A Control D resolver, `controld://@dns.controld.com`, counts as a full proxy URI: the server runs a forwarder for it and needs no Control D configuration of its own. === "Response `200`" diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 9750e005..c90d9d26 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -136,7 +136,7 @@ hood the client walks a remote session through its lifecycle. - Local **cookies**, compressed and base64-encoded - A resolved **proxy**, or, if you did not set one, your detected **client region**. A client that reports no region is not blocked. The - proxy must be a full proxy URI unless the operator gives your API key + proxy must be a full proxy URI on a public host unless the operator gives your API key `server_proxy`. Without `server_proxy` the server does not resolve country codes with its own proxy providers, and it rejects the remote session when it sits in a different region from the reported one, asking the client to diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index b99533cd..9d6656df 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -309,6 +309,12 @@ to let that API key send a country code or a `provider:country` value, and to le pick a proxy for the client's region and for a service geofence. The client reports its own region; a client that reports none is not blocked. +Without `server_proxy`, the server refuses a proxy URI whose host is not a public address: +loopback, private, link-local and other special-purpose addresses, and `localhost`. This also +applies to a server on a LAN, so a client proxy on that LAN needs `server_proxy` or an admin key. +The server checks the proxy values the job uses, including a `proxy` default from the server's +own config. + ```yaml serve: api_secret: change-me diff --git a/tests/remote/unit/test_handler_proxy_policy.py b/tests/remote/unit/test_handler_proxy_policy.py index 7aa6ae77..264bebef 100644 --- a/tests/remote/unit/test_handler_proxy_policy.py +++ b/tests/remote/unit/test_handler_proxy_policy.py @@ -246,6 +246,13 @@ class TestDownloadGates: handlers.enforce_download_gates({"service": "EXAMPLE", "client_region": "gb"}, request("plainkey")) assert exc_info.value.error_code == APIErrorCode.INVALID_PROXY + def test_download_proxy_on_local_network_refused(self): + with pytest.raises(APIError) as exc_info: + handlers.enforce_download_gates( + {"service": "EXAMPLE", "proxy_download": "http://127.0.0.1:9"}, request("plainkey") + ) + assert exc_info.value.error_code == APIErrorCode.INVALID_PROXY + class TestDownloadEnforcement: """The server_proxy stamp survives from submission to the dl construction.""" @@ -349,3 +356,57 @@ def test_session_create_builds_service_off_the_event_loop(monkeypatch): assert seen["thread"] != threading.get_ident() assert [r["message"] for r in seen["buffer"].since(0)] == ["checking proxy"] assert json.loads(response.body)["error_code"] == "INVALID_PROXY" + + +@pytest.mark.parametrize( + "proxy", + [ + "http://127.0.0.1:8080", + "http://localhost:3128", + "socks5://user:pass@10.0.0.5:1080", + "https://[::1]:8443", + "http://169.254.169.254", + "socks5h://192.168.1.1:1080", + "http://198.18.0.1:8080", + "http://[2002:7f00:1::]:8080", + "http://foo.localhost:8080", + "http://127.0.0.1:8080\\@example.com", + "http://%31%32%37.0.0.1:8080", + ], +) +def test_local_proxy_uri_refused_without_server_proxy(proxy): + with pytest.raises(APIError) as exc_info: + handlers.resolve_handler_proxy({"proxy": proxy}, "EXAMPLE", request("plainkey")) + assert exc_info.value.error_code == APIErrorCode.INVALID_PROXY + assert "pass" not in exc_info.value.message # credentials never echo back + + +@pytest.mark.parametrize("key", ["proxykey", "adminkey"]) # adminkey has no serve.users entry +def test_local_proxy_uri_allowed_with_server_proxy_or_admin(monkeypatch, key): + monkeypatch.setattr(handlers, "initialize_proxy_providers", lambda: []) + proxy, _ = handlers.resolve_handler_proxy({"proxy": "http://127.0.0.1:8080"}, "EXAMPLE", request(key)) + assert proxy == "http://127.0.0.1:8080" + + +def test_hostname_resolving_to_a_private_address_refused(monkeypatch): + monkeypatch.setattr(handlers.socket, "getaddrinfo", lambda host, port: [(2, 1, 6, "", ("10.0.0.7", 0))]) + with pytest.raises(APIError) as exc_info: + handlers.resolve_handler_proxy({"proxy": "http://proxy.lan:3128"}, "EXAMPLE", request("plainkey")) + assert exc_info.value.error_code == APIErrorCode.INVALID_PROXY + + +def test_local_proxy_uri_ignored_under_no_proxy(): + handlers.resolve_handler_proxy({"proxy": "http://127.0.0.1:8080", "no_proxy": True}, "EXAMPLE", request("plainkey")) + + +def test_server_built_loopback_proxies_pass_without_server_proxy(monkeypatch): + """The check reads the client's value, so the loopback URIs the server builds for tunnel: and controld:// pass.""" + monkeypatch.setattr(handlers, "tunnel_proxy", lambda tunnel_id, key: "http://u:p@127.0.0.1:5000") + proxy, _ = handlers.resolve_handler_proxy({"proxy": "tunnel:abc"}, "EXAMPLE", request("plainkey")) + assert proxy == "http://u:p@127.0.0.1:5000" + + monkeypatch.setattr(handlers, "resolve_proxy", lambda proxy, providers: "http://127.0.0.1:5001") + proxy, _ = handlers.resolve_handler_proxy( + {"proxy": "controld://abc@dns.controld.com"}, "EXAMPLE", request("plainkey") + ) + assert proxy == "http://127.0.0.1:5001" diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 91534d3e..61885927 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -4,6 +4,7 @@ import enum import json import logging import re +import socket import threading import time import uuid @@ -16,6 +17,7 @@ from functools import partial from http.cookiejar import CookieJar from pathlib import Path from typing import AbstractSet, Any, Callable, Dict, Iterable, List, Optional, Set, cast +from urllib.parse import urlsplit import click from aiohttp import web @@ -32,14 +34,23 @@ from unshackle.core.config import config, load_yaml from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP, AnyTrack from unshackle.core.drm import BlowfishStripe, cbcs from unshackle.core.providers.anilist import parse_anilist_ref -from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy +from unshackle.core.proxies.resolve import initialize_proxy_providers, is_loopback, resolve_proxy from unshackle.core.services import Services from unshackle.core.session import RnetSession, utf8_text from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.titles.music import SONG_FIELDS from unshackle.core.titles.title import rename from unshackle.core.tracks import Audio, Subtitle, Tracks, Video -from unshackle.core.tunnel import HEARTBEAT, MAX_MESSAGE, MAX_TUNNELS, TUNNELS, Tunnel, tunnel_proxy +from unshackle.core.tunnel import ( + HEARTBEAT, + MAX_MESSAGE, + MAX_TUNNELS, + TUNNELS, + Tunnel, + is_ip_literal, + is_public_ip, + tunnel_proxy, +) from unshackle.core.utilities import declared_kwargs, kind_only_conflict, original_tag from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, QUALITY_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST from unshackle.core.utils.collections import ci_get @@ -4564,6 +4575,33 @@ async def get_validated_session(session_id: str, request: Optional[web.Request]) return session +def local_proxy_refusal(proxy: str) -> Optional[str]: + """Why an http(s) or socks proxy URI may reach the server's own machine or network, or None when it cannot. + + The host must be a plain DNS name or an IP literal: an HTTP client that follows the WHATWG URL standard + reads a backslash or a percent-escape in the authority differently from ``urlsplit``. + """ + if not re.match(r"^(https?://|socks)", proxy): + return None + try: + host = urlsplit(proxy).hostname or "" + except ValueError: + host = "" + literal = is_ip_literal(host) + if "\\" in proxy or not (literal or re.fullmatch(r"[a-z0-9._-]+", host)): + return "Proxy URI has no plain host name or IP address" + if literal or is_loopback(proxy): + addresses = {host} + else: + try: + addresses = {str(info[4][0]) for info in socket.getaddrinfo(host, None)} + except (socket.gaierror, UnicodeError): + return None # an unresolvable proxy fails at connect time anyway + if not all(is_public_ip(address) for address in addresses): + return f"Proxy host {sanitize_log(host)} is not a public address" + return None + + def resolve_handler_proxy( data: Dict[str, Any], normalized_service: str, request: Optional[web.Request] = None ) -> tuple[Optional[str], list]: @@ -4595,6 +4633,11 @@ def resolve_handler_proxy( details={"service": normalized_service}, ) + if isinstance(proxy_param, str) and not no_proxy and not (allowed or is_admin(request)): + refusal = local_proxy_refusal(proxy_param) + if refusal: + raise APIError(APIErrorCode.INVALID_PROXY, refusal, details={"service": normalized_service}) + if isinstance(proxy_param, str) and proxy_param.startswith("tunnel:") and not no_proxy: proxy_param = tunnel_proxy( proxy_param.removeprefix("tunnel:"), request_secret_key(request) if request else None diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index e1b6b362..a8915fab 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -537,7 +537,7 @@ async def search(request: web.Request) -> web.Response: description: Profile to use for credentials and cookies (default - None) proxy: type: string - description: Full proxy URI, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy, or tunnel: for a tunnel the same API key opened on /api/tunnel (default - None) + description: Full proxy URI on a public host, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy, or tunnel: for a tunnel the same API key opened on /api/tunnel (default - None) no_proxy: type: boolean description: Force disable all proxy use (default - false) @@ -817,7 +817,7 @@ async def list_tracks(request: web.Request) -> web.Response: description: Specific episode/season, or song ("1-5", "2x3" for disc 2 track 3) (optional) proxy: type: string - description: Full proxy URI, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy (default - None) + description: Full proxy URI on a public host, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy (default - None) dl_params: type: object description: | @@ -1072,7 +1072,7 @@ async def download(request: web.Request) -> web.Response: description: CDM device name on the server to license with, overriding the cdm config mapping. Requires serve.cdm_overrides to allow it (default - None) proxy: type: string - description: Full proxy URI, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy (default - None) + description: Full proxy URI on a public host, a Control D resolver as controld://@dns.controld.com, or a country code when the API key has server_proxy (default - None) no_proxy: type: boolean description: Force disable all proxy use (default - false)