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.
This commit is contained in:
imSp4rky
2026-11-01 20:04:06 -07:00
parent e509db618e
commit 99f0c269be
6 changed files with 120 additions and 10 deletions
+4 -4
View File
@@ -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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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`"
+1 -1
View File
@@ -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
+6
View File
@@ -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
@@ -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"
+45 -2
View File
@@ -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
+3 -3
View File
@@ -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://<resolver>@dns.controld.com, or a country code when the API key has server_proxy, or tunnel:<id> 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://<resolver>@dns.controld.com, or a country code when the API key has server_proxy, or tunnel:<id> 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://<resolver>@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://<resolver>@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://<resolver>@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://<resolver>@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)