From b8c78c1a82599828a3d1bbc1bb4740ced6c960c8 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 7 Sep 2026 13:20:55 -0600 Subject: [PATCH] fix(api): tell the service that the client sent a cookie jar A service that picks its auth path in init could only check the server's cookies directory, so a client-sent jar was invisible and the service fell back to the wrong path. The ctx now carries cookies_supplied, and a malformed cookie blob answers 400 instead of 500. --- tests/core/test_client_cookies_flag.py | 74 ++++++++++ .../remote/unit/test_handler_proxy_policy.py | 2 +- unshackle/core/api/handlers.py | 130 +++++++++++------- 3 files changed, 158 insertions(+), 48 deletions(-) create mode 100644 tests/core/test_client_cookies_flag.py diff --git a/tests/core/test_client_cookies_flag.py b/tests/core/test_client_cookies_flag.py new file mode 100644 index 00000000..bc73681c --- /dev/null +++ b/tests/core/test_client_cookies_flag.py @@ -0,0 +1,74 @@ +"""A client-sent cookie jar must reach the service before its __init__ picks an auth path.""" + +import base64 +import zlib + +import pytest + +from unshackle.core.api import handlers +from unshackle.core.api.errors import APIError, APIErrorCode +from unshackle.core.api.handlers import load_client_cookies + +COOKIE_FILE = "\n".join( + [ + "# Netscape HTTP Cookie File", + "\t".join([".primevideo.com", "TRUE", "/", "TRUE", "2000000000", "session-id", "123-456"]), + "", + ] +) + + +def transport_blob(text: str) -> str: + """Encode a cookie file the way remote_service.load_cookies_for_transport does.""" + return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii") + + +def test_load_client_cookies_reads_a_transport_blob(): + jar = load_client_cookies(transport_blob(COOKIE_FILE)) + assert jar is not None + assert [(c.name, c.value) for c in jar] == [("session-id", "123-456")] + + +def test_load_client_cookies_without_a_blob(): + assert load_client_cookies(None) is None + assert load_client_cookies("") is None + + +@pytest.mark.parametrize("blob", ["not base64!", base64.b64encode(b"not zlib").decode(), transport_blob("garbage")]) +def test_load_client_cookies_rejects_a_malformed_blob(blob): + with pytest.raises(APIError) as exc_info: + load_client_cookies(blob) + assert exc_info.value.error_code == APIErrorCode.INVALID_INPUT + + +@pytest.fixture +def built_ctx(monkeypatch): + """Stub the service machinery so create_service_instance runs without a real service; return the captured ctx.""" + seen = {} + + def fake_instantiate(parent_ctx, *args): + seen["ctx"] = parent_ctx + return object() + + monkeypatch.setattr(handlers, "load_service_yaml", lambda service: {}) + monkeypatch.setattr(handlers, "load_full_cdm", lambda *args: None) + monkeypatch.setattr(handlers.Services, "load", lambda service: None) + monkeypatch.setattr(handlers, "instantiate_service", fake_instantiate) + + def build(data): + _, cookies, _ = handlers.create_service_instance("EXAMPLE", "title", data, None, [], None) + return seen["ctx"], cookies + + return build + + +def test_client_jar_sets_the_flag_before_the_service_is_built(built_ctx): + ctx, cookies = built_ctx({"cookies": transport_blob(COOKIE_FILE)}) + assert ctx.params["cookies_supplied"] is True + assert cookies is not None and len(cookies) == 1 + + +def test_no_client_jar_leaves_the_flag_false(built_ctx): + ctx, cookies = built_ctx({}) + assert ctx.params["cookies_supplied"] is False + assert cookies is None diff --git a/tests/remote/unit/test_handler_proxy_policy.py b/tests/remote/unit/test_handler_proxy_policy.py index 66bb153d..9b8c9047 100644 --- a/tests/remote/unit/test_handler_proxy_policy.py +++ b/tests/remote/unit/test_handler_proxy_policy.py @@ -167,7 +167,7 @@ def test_setup_list_service_threads_request(monkeypatch): monkeypatch.setattr(handlers, "load_service_yaml", lambda service: {}) monkeypatch.setattr(handlers, "resolve_handler_proxy", fake_resolve) monkeypatch.setattr(handlers, "load_full_cdm", lambda *args: None) - monkeypatch.setattr(handlers, "build_parent_ctx", lambda *args: None) + monkeypatch.setattr(handlers, "build_parent_ctx", lambda *args, **kwargs: None) monkeypatch.setattr(handlers.Services, "load", lambda service: None) monkeypatch.setattr(handlers, "instantiate_service", lambda *args: FakeService()) monkeypatch.setattr("unshackle.commands.dl.dl.get_cookie_jar", staticmethod(lambda service, profile: None)) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 8f79247e..2647eef1 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3,10 +3,15 @@ import base64 import enum import json import logging +import os import re +import tempfile import time +import zlib from collections import Counter +from contextlib import suppress from datetime import date as date_ +from http.cookiejar import CookieJar, MozillaCookieJar from pathlib import Path from typing import Any, Dict, Iterable, List, Optional @@ -184,6 +189,11 @@ def build_parent_ctx( The service's CLI callback uses ``ctx.parent.params`` (proxy, range_, vcodec, and the other dl options) and ``ctx.obj`` (ContextData). Both flow through Click's parent chain. + + ``extra_params`` carries ``cookies_supplied``: True when a cookie jar goes to + ``authenticate()`` later in this request. A service that picks an authentication path in + ``__init__`` must read that flag, because a client-sent jar is in the request body, not in + the server's ``cookies/`` directory, so a file check answers False for it. """ import click @@ -264,16 +274,27 @@ def setup_list_service( profile = None if account == "default" else account cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type")) - parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config) + cookies = ( + server_account_cookies(normalized_service, profile) + if account + else dl.get_cookie_jar(normalized_service, profile) + ) + credential = dl.get_credentials(normalized_service, profile) + + parent_ctx = build_parent_ctx( + profile, + cdm, + proxy_param, + no_proxy, + proxy_providers, + service_config, + extra_params={"cookies_supplied": cookies is not None}, + ) service_module = Services.load(normalized_service) service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS) if account: service_instance.cache = Cacher(f"_accounts/{normalized_service}/{account}") - cookies = server_account_cookies(normalized_service, profile) - else: - cookies = dl.get_cookie_jar(normalized_service, profile) - credential = dl.get_credentials(normalized_service, profile) service_instance.authenticate(cookies, credential) return service_instance @@ -303,7 +324,22 @@ def run_service_search( profile = None if account == "default" else account cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type")) - parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config) + cookies = ( + server_account_cookies(normalized_service, profile) + if account + else dl.get_cookie_jar(normalized_service, profile) + ) + credential = dl.get_credentials(normalized_service, profile) + + parent_ctx = build_parent_ctx( + profile, + cdm, + proxy_param, + no_proxy, + proxy_providers, + service_config, + extra_params={"cookies_supplied": cookies is not None}, + ) service_module = Services.load(normalized_service) try: @@ -317,10 +353,6 @@ def run_service_search( if account: service_instance.cache = Cacher(f"_accounts/{normalized_service}/{account}") - cookies = server_account_cookies(normalized_service, profile) - else: - cookies = dl.get_cookie_jar(normalized_service, profile) - credential = dl.get_credentials(normalized_service, profile) service_instance.authenticate(cookies, credential) results: List[Dict[str, Any]] = [] @@ -456,6 +488,31 @@ def server_account_cookies(service: str, profile: Optional[str]) -> Any: return dl.load_cookie_file(path) if path.exists() else None +def load_client_cookies(cookie_text: Any) -> Optional[CookieJar]: + """Read the cookie jar a client sent with its request, or None when it sent none. + + The client sends a Netscape cookie file compressed and base64 encoded, and + ``MozillaCookieJar`` reads from a path only, so the text goes through a temp file. + """ + if not cookie_text or not isinstance(cookie_text, str): + return None + + try: + cookie_str = safe_inflate(base64.b64decode(cookie_text)).decode("utf-8") + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: + f.write(cookie_str) + tmp_path = f.name + try: + jar = MozillaCookieJar(tmp_path) + jar.load(ignore_discard=True, ignore_expires=True) + return jar + finally: + with suppress(OSError): + os.unlink(tmp_path) + except (ValueError, zlib.error, OSError) as e: + raise APIError(APIErrorCode.INVALID_INPUT, f"cookies is not a compressed Netscape cookie file: {e}") + + def server_account_regions(service: str) -> Optional[Dict[str, Any]]: """What ``GET /api/services`` advertises: the union of regions the server's accounts cover.""" spec = server_account_spec(service) @@ -2726,6 +2783,22 @@ def create_service_instance( "best_available": data.get("best_available", False), } + if server_account: + cookies = server_account_cookies(normalized_service, profile) + credential = dl.get_credentials(normalized_service, profile) + else: + credential = None + cred_data = data.get("credentials") + if cred_data and isinstance(cred_data, dict): + credential = Credential( + username=cred_data["username"], + password=cred_data["password"], + extra=cred_data.get("extra"), + ) + cookies = load_client_cookies(data.get("cookies")) + + extra_params["cookies_supplied"] = cookies is not None + parent_ctx = build_parent_ctx( profile, cdm, @@ -2739,43 +2812,6 @@ def create_service_instance( service_module = Services.load(normalized_service) service_instance = instantiate_service(parent_ctx, service_module, title_id, data, SESSION_TRANSPORT_KEYS) - if server_account: - return ( - service_instance, - server_account_cookies(normalized_service, profile), - dl.get_credentials(normalized_service, profile), - ) - - credential = None - cred_data = data.get("credentials") - if cred_data and isinstance(cred_data, dict): - credential = Credential( - username=cred_data["username"], - password=cred_data["password"], - extra=cred_data.get("extra"), - ) - - cookies = None - cookie_text = data.get("cookies") - if cookie_text and isinstance(cookie_text, str): - import base64 - import tempfile - from http.cookiejar import MozillaCookieJar - - cookie_str = safe_inflate(base64.b64decode(cookie_text)).decode("utf-8") - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: - f.write(cookie_str) - tmp_path = f.name - try: - cookies = MozillaCookieJar(tmp_path) - cookies.load(ignore_discard=True, ignore_expires=True) - finally: - import os - from contextlib import suppress - - with suppress(OSError): - os.unlink(tmp_path) - return service_instance, cookies, credential