mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
fix(cdm): skip the server header probe for pywidevine and pyplayready remote CDMs
A reverse proxy or CDN rewrites the Server header, so upstream refused a working serve API.
This commit is contained in:
@@ -324,6 +324,9 @@ remote_cdm:
|
|||||||
so a host without the suffix points the requests at the wrong location. The symptom
|
so a host without the suffix points the requests at the wrong location. The symptom
|
||||||
is `404` responses from the server rather than an obvious configuration error.
|
is `404` responses from the server rather than an obvious configuration error.
|
||||||
|
|
||||||
|
A pywidevine or pyplayready serve API can operate behind a reverse proxy or a CDN. Refer to
|
||||||
|
[Serve APIs behind a proxy](../reference/configuration/drm.md#remote_cdm).
|
||||||
|
|
||||||
### Decrypt Labs (hosted KeyXtractor API)
|
### Decrypt Labs (hosted KeyXtractor API)
|
||||||
|
|
||||||
Set `type: decrypt_labs` to use Decrypt Labs' hosted KeyXtractor service, which
|
Set `type: decrypt_labs` to use Decrypt Labs' hosted KeyXtractor service, which
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ For the two pywidevine/pyplayready backends, field names are read case-insensiti
|
|||||||
styles: `Device Type`/`device_type`, `System ID`/`system_id`, `Security Level`/`security_level`,
|
styles: `Device Type`/`device_type`, `System ID`/`system_id`, `Security Level`/`security_level`,
|
||||||
`Host`/`host`, `Secret`/`secret`, `Device Name`/`device_name`.
|
`Host`/`host`, `Secret`/`secret`, `Device Name`/`device_name`.
|
||||||
|
|
||||||
|
!!! note "Serve APIs behind a proxy"
|
||||||
|
unshackle does not send upstream's `HEAD` request or examine the `Server` response
|
||||||
|
header, so the server can operate behind a reverse proxy or a CDN that removes or
|
||||||
|
changes that header. Thus a `host` that is not a serve API fails at the first licence
|
||||||
|
request, not when unshackle loads the CDM. On a redirect to a different host or port,
|
||||||
|
unshackle removes the `secret` from the request.
|
||||||
|
|
||||||
!!! warning "PlayReady `host` needs a `/playready` suffix"
|
!!! warning "PlayReady `host` needs a `/playready` suffix"
|
||||||
pyplayready's `RemoteCdm` treats `host` as a base URL and appends its own endpoint paths,
|
pyplayready's `RemoteCdm` treats `host` as a base URL and appends its own endpoint paths,
|
||||||
so a PlayReady entry's `host` must include the trailing `/playready` segment (for example
|
so a PlayReady entry's `host` must include the trailing `/playready` segment (for example
|
||||||
|
|||||||
@@ -157,13 +157,13 @@ def test_load_remote_decryptlabs_widevine_is_stamped():
|
|||||||
|
|
||||||
|
|
||||||
def test_load_remote_native_playready_is_stamped(monkeypatch):
|
def test_load_remote_native_playready_is_stamped(monkeypatch):
|
||||||
import pyplayready.remote.remotecdm as prmod
|
import unshackle.core.cdm.unprobed_remote_cdm as unprobed_mod
|
||||||
|
|
||||||
class FakePlayReadyRemote:
|
class FakePlayReadyRemote:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(prmod, "RemoteCdm", FakePlayReadyRemote)
|
monkeypatch.setattr(unprobed_mod, "UnprobedPlayReadyRemoteCdm", FakePlayReadyRemote)
|
||||||
cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"}
|
cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"}
|
||||||
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "EXAMPLE", None)
|
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "EXAMPLE", None)
|
||||||
assert isinstance(cdm, FakePlayReadyRemote)
|
assert isinstance(cdm, FakePlayReadyRemote)
|
||||||
@@ -173,13 +173,13 @@ def test_load_remote_native_playready_is_stamped(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_load_remote_native_widevine_is_stamped(monkeypatch):
|
def test_load_remote_native_widevine_is_stamped(monkeypatch):
|
||||||
import pywidevine.remotecdm as wvmod
|
import unshackle.core.cdm.unprobed_remote_cdm as unprobed_mod
|
||||||
|
|
||||||
class FakeWidevineRemote:
|
class FakeWidevineRemote:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
monkeypatch.setattr(wvmod, "RemoteCdm", FakeWidevineRemote)
|
monkeypatch.setattr(unprobed_mod, "UnprobedWidevineRemoteCdm", FakeWidevineRemote)
|
||||||
cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"}
|
cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"}
|
||||||
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "EXAMPLE", None)
|
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "EXAMPLE", None)
|
||||||
assert isinstance(cdm, FakeWidevineRemote)
|
assert isinstance(cdm, FakeWidevineRemote)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||||
|
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||||
|
|
||||||
|
from unshackle.core.cdm.unprobed_remote_cdm import UnprobedPlayReadyRemoteCdm, UnprobedWidevineRemoteCdm
|
||||||
|
|
||||||
|
HOST = "http://cdm.invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def widevine(**overrides: Any) -> Any:
|
||||||
|
args = dict(
|
||||||
|
device_type="ANDROID", system_id=4464, security_level=3000, host=HOST, secret="s3cret", device_name="dev"
|
||||||
|
)
|
||||||
|
return UnprobedWidevineRemoteCdm(**{**args, **overrides})
|
||||||
|
|
||||||
|
|
||||||
|
def playready(**overrides: Any) -> Any:
|
||||||
|
args = dict(security_level=3000, host=HOST, secret="s3cret", device_name="dev")
|
||||||
|
return UnprobedPlayReadyRemoteCdm(**{**args, **overrides})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
device = {"system_id": 4464, "security_level": 3000}
|
||||||
|
return {"status": 200, "data": {"device": device, "session_id": "00ff"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sent(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, Any]]:
|
||||||
|
"""Record every GET, so a test sees what the upstream methods send through the session."""
|
||||||
|
calls: list[tuple[str, Any]] = []
|
||||||
|
|
||||||
|
def get(self: requests.Session, url: str, **kwargs: Any) -> FakeResponse:
|
||||||
|
calls.append((url, self.headers.get("X-Secret-Key")))
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(requests.Session, "get", get)
|
||||||
|
monkeypatch.setattr(requests, "head", lambda *a, **k: pytest.fail("the Server header probe ran"))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("make, base", [(widevine, WidevineRemoteCdm), (playready, PlayReadyRemoteCdm)])
|
||||||
|
def test_open_goes_through_upstream_session_without_probe(make: Any, base: type, sent: list) -> None:
|
||||||
|
cdm = make()
|
||||||
|
assert isinstance(cdm, base)
|
||||||
|
assert cdm.open() == b"\x00\xff"
|
||||||
|
assert sent == [(f"{HOST}/dev/open", "s3cret")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("make", [widevine, playready])
|
||||||
|
@pytest.mark.parametrize("field", ["host", "secret", "device_name"])
|
||||||
|
def test_missing_connection_field_raises(make: Any, field: str) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
make(**{field: ""})
|
||||||
|
|
||||||
|
|
||||||
|
def test_playready_rejects_quoted_security_level() -> None:
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
playready(security_level="3000")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def redirecting_host() -> Iterator[tuple[int, list[tuple[str, Any]]]]:
|
||||||
|
"""Redirect `/dev/open` to the `Location` in the `to` query, and answer the target like a serve API."""
|
||||||
|
seen: list[tuple[str, Any]] = []
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
seen.append((self.path.split("?")[0], self.headers.get("X-Secret-Key")))
|
||||||
|
if self.path.startswith("/dev/open"):
|
||||||
|
self.send_response(307)
|
||||||
|
self.send_header("Location", f"http://{self.headers['X-Redirect-To']}:{port}/moved")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
body = json.dumps(FakeResponse().json()).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
port = server.server_port
|
||||||
|
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True).start()
|
||||||
|
yield port, seen
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("make", [widevine, playready])
|
||||||
|
@pytest.mark.parametrize("target, secret", [("127.0.0.1", "s3cret"), ("localhost", None)])
|
||||||
|
def test_secret_is_dropped_on_a_redirect_to_another_host(
|
||||||
|
make: Any, target: str, secret: Any, redirecting_host: tuple[int, list[tuple[str, Any]]]
|
||||||
|
) -> None:
|
||||||
|
port, seen = redirecting_host
|
||||||
|
cdm = make(host=f"http://127.0.0.1:{port}")
|
||||||
|
cdm._RemoteCdm__session.headers["X-Redirect-To"] = target
|
||||||
|
assert cdm.open() == b"\x00\xff"
|
||||||
|
assert seen == [("/dev/open", "s3cret"), ("/moved", secret)]
|
||||||
@@ -92,10 +92,10 @@ def load_remote_cdm(
|
|||||||
|
|
||||||
device_type = cdm_api.get("Device Type", cdm_api.get("device_type", ""))
|
device_type = cdm_api.get("Device Type", cdm_api.get("device_type", ""))
|
||||||
if str(device_type).upper() == "PLAYREADY":
|
if str(device_type).upper() == "PLAYREADY":
|
||||||
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
from unshackle.core.cdm.unprobed_remote_cdm import UnprobedPlayReadyRemoteCdm
|
||||||
|
|
||||||
return stamp_remote(
|
return stamp_remote(
|
||||||
PlayReadyRemoteCdm(
|
UnprobedPlayReadyRemoteCdm(
|
||||||
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
||||||
host=cdm_api.get("Host", cdm_api.get("host")),
|
host=cdm_api.get("Host", cdm_api.get("host")),
|
||||||
secret=cdm_api.get("Secret", cdm_api.get("secret")),
|
secret=cdm_api.get("Secret", cdm_api.get("secret")),
|
||||||
@@ -104,10 +104,10 @@ def load_remote_cdm(
|
|||||||
"playready",
|
"playready",
|
||||||
)
|
)
|
||||||
|
|
||||||
from pywidevine.remotecdm import RemoteCdm
|
from unshackle.core.cdm.unprobed_remote_cdm import UnprobedWidevineRemoteCdm
|
||||||
|
|
||||||
return stamp_remote(
|
return stamp_remote(
|
||||||
RemoteCdm(
|
UnprobedWidevineRemoteCdm(
|
||||||
device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")),
|
device_type=cdm_api.get("Device Type", cdm_api.get("device_type", "")),
|
||||||
system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")),
|
system_id=cdm_api.get("System ID", cdm_api.get("system_id", "")),
|
||||||
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
security_level=cdm_api.get("Security Level", cdm_api.get("security_level", 3000)),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""pywidevine and pyplayready remote CDMs without the upstream `Server` header probe.
|
||||||
|
|
||||||
|
Upstream sends a HEAD request to the host and reads the `Server` header: pywidevine refuses a host
|
||||||
|
that does not name pywidevine serve, and pyplayready logs a warning. A reverse proxy or CDN in front
|
||||||
|
of the server strips or rewrites that header, so a working server fails. A host that is not a serve
|
||||||
|
API now fails at the first API call instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||||
|
from pyplayready.remote.remotecdm import RemoteCdm as PlayReadyRemoteCdm
|
||||||
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
|
from pywidevine.license_protocol_pb2 import ClientIdentification
|
||||||
|
from pywidevine.remotecdm import RemoteCdm as WidevineRemoteCdm
|
||||||
|
|
||||||
|
|
||||||
|
class SecretSession(requests.Session):
|
||||||
|
"""Drop the serve API secret on a redirect where requests would drop `Authorization`."""
|
||||||
|
|
||||||
|
def rebuild_auth(self, prepared_request: requests.PreparedRequest, response: requests.Response) -> None:
|
||||||
|
super().rebuild_auth(prepared_request, response)
|
||||||
|
if self.should_strip_auth(response.request.url, prepared_request.url):
|
||||||
|
prepared_request.headers.pop("X-Secret-Key", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(cdm: Any, drm: str, host: Optional[str], secret: Optional[str], device_name: Optional[str]) -> None:
|
||||||
|
"""Set what upstream `__init__` sets after the probe, on the name-mangled upstream session."""
|
||||||
|
if not (host and secret and device_name):
|
||||||
|
raise ValueError(f"A {drm} remote CDM needs host, secret and device_name")
|
||||||
|
cdm.host = host
|
||||||
|
cdm.device_name = device_name
|
||||||
|
cdm._RemoteCdm__session = SecretSession()
|
||||||
|
cdm._RemoteCdm__session.headers["X-Secret-Key"] = secret
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _placeholder_key() -> RSA.RsaKey:
|
||||||
|
"""An RSA private key only to satisfy `Cdm.__init__`; the remote CDM never uses it."""
|
||||||
|
return RSA.generate(2048)
|
||||||
|
|
||||||
|
|
||||||
|
class UnprobedWidevineRemoteCdm(WidevineRemoteCdm):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_type: Any,
|
||||||
|
system_id: int,
|
||||||
|
security_level: int,
|
||||||
|
host: Optional[str],
|
||||||
|
secret: Optional[str],
|
||||||
|
device_name: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
_connect(self, "Widevine", host, secret, device_name)
|
||||||
|
WidevineCdm.__init__(self, device_type, system_id, security_level, ClientIdentification(), _placeholder_key())
|
||||||
|
|
||||||
|
|
||||||
|
class UnprobedPlayReadyRemoteCdm(PlayReadyRemoteCdm):
|
||||||
|
def __init__(
|
||||||
|
self, security_level: int, host: Optional[str], secret: Optional[str], device_name: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(security_level, int):
|
||||||
|
raise TypeError(f"A PlayReady remote CDM needs an int security_level, not {security_level!r}")
|
||||||
|
_connect(self, "PlayReady", host, secret, device_name)
|
||||||
|
PlayReadyCdm.__init__(self, security_level, None, None, None)
|
||||||
Reference in New Issue
Block a user