mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 04:55:07 +00:00
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:
@@ -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`"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user