mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +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:
@@ -157,13 +157,13 @@ def test_load_remote_decryptlabs_widevine_is_stamped():
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, **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 = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "EXAMPLE", None)
|
||||
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):
|
||||
import pywidevine.remotecdm as wvmod
|
||||
import unshackle.core.cdm.unprobed_remote_cdm as unprobed_mod
|
||||
|
||||
class FakeWidevineRemote:
|
||||
def __init__(self, **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 = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "EXAMPLE", None)
|
||||
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)]
|
||||
Reference in New Issue
Block a user