fix(remote): keep each session cookie's domain, path and secure flag

The client set server cookies with no domain and sent all of them to every host, so a CDN refused segment requests with 400 header too large. rnet sessions now send cookies too.
This commit is contained in:
imSp4rky
2026-10-23 09:42:01 -06:00
parent af69e8aad4
commit eeaf16cd83
7 changed files with 211 additions and 27 deletions
+4 -1
View File
@@ -1096,6 +1096,7 @@ Get tracks and chapters for a specific title within the remote session. Body: `{
"track_manifests": [ { "track_id": "s-1", "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ], "track_manifests": [ { "track_id": "s-1", "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
"session_headers": { "User-Agent": "..." }, "session_headers": { "User-Agent": "..." },
"session_cookies": { "sid": "..." }, "session_cookies": { "sid": "..." },
"session_cookie_jar": [ { "name": "sid", "value": "...", "domain": ".example.com", "path": "/", "secure": true } ],
"server_cdm_type": "widevine" "server_cdm_type": "widevine"
} }
``` ```
@@ -1115,11 +1116,13 @@ Get the download URL, DRM info, headers, cookies, and raw track data for the sel
```json ```json
{ {
"tracks": { "tracks": {
"v-1": { "descriptor": "DASH", "url": "https://...", "drm": [ ... ], "headers": { ... }, "cookies": { ... }, "data": { ... } } "v-1": { "descriptor": "DASH", "url": "https://...", "drm": [ ... ], "headers": { ... }, "cookies": { ... }, "cookie_jar": [ ... ], "data": { ... } }
} }
} }
``` ```
`cookies` gives names and values only. `cookie_jar` gives the same cookies with their domain, path and `secure` flag, in the `session_cookie_jar` form of the tracks response.
| Status | Error code | Meaning | | Status | Error code | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `200` | - | Segment info returned. | | `200` | - | Segment info returned. |
+5 -3
View File
@@ -214,8 +214,10 @@ hood the client walks a remote session through its lifecycle.
downloading. A service can build an AdaptationSet of its own that the served downloading. A service can build an AdaptationSet of its own that the served
manifest does not contain. Each such track gets a one-AdaptationSet MPD in manifest does not contain. Each such track gets a one-AdaptationSet MPD in
`track_manifests`, and the client re-parses that instead. Any `track_manifests`, and the client re-parses that instead. Any
`session_headers` / `session_cookies` the server used are merged into the `session_headers` and cookies the server used are merged into the
client's local HTTP session. client's local HTTP session. Each cookie in `session_cookie_jar` keeps its
domain, path and `secure` flag, so it goes only to its own hosts. `session_cookies` carries
the same cookies as names and values only, for older clients.
The tracks response repeats the title. A service can rewrite title fields inside The tracks response repeats the title. A service can rewrite title fields inside
`get_tracks`, so the client takes this copy in place of the one from the title `get_tracks`, so the client takes this copy in place of the one from the title
@@ -653,7 +655,7 @@ remote session API and a good template for any client.
- **Flow.** `authenticate()` → `create` (+ poll `prompt` every 2s up to a 600s - **Flow.** `authenticate()` → `create` (+ poll `prompt` every 2s up to a 600s
deadline, answering `pending_input` prompts. With stdin closed the client logs an deadline, answering `pending_input` prompts. With stdin closed the client logs an
error and raises `SystemExit(1)` instead of posting an empty answer) → `get_titles()` → `get_tracks()` error and raises `SystemExit(1)` instead of posting an empty answer) → `get_titles()` → `get_tracks()`
(merging returned `session_headers`/`session_cookies`, re-parsing `manifests`) → (merging returned `session_headers`/`session_cookie_jar`, re-parsing `manifests`) →
get the licence in proxy mode or `server_cdm` mode → `close()` (`DELETE`, saving any returned get the licence in proxy mode or `server_cdm` mode → `close()` (`DELETE`, saving any returned
`cache`). `cache`).
- **Server resolution.** `resolve_server()` reads `config.remote_services.<name>` - **Server resolution.** `resolve_server()` reads `config.remote_services.<name>`
+1 -1
View File
@@ -389,7 +389,7 @@ def test_a_lent_device_session_keeps_its_auth_headers_and_track_secrets() -> Non
headers, cookies = handlers.client_session_auth(session) headers, cookies = handlers.client_session_auth(session)
assert headers == {"User-Agent": "ua"} assert headers == {"User-Agent": "ua"}
assert cookies == {"account": "client"} assert handlers.cookie_values(cookies) == {"account": "client"}
assert handlers.scrub_track_data({"token": "server", "id": 1}, session) == {"id": 1} assert handlers.scrub_track_data({"token": "server", "id": 1}, session) == {"id": 1}
+120 -3
View File
@@ -1,13 +1,15 @@
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
import requests
from unshackle.core.api.errors import APIError from unshackle.core.api.errors import APIError
from unshackle.core.api.handlers import client_session_auth, resolve_handler_proxy, scrub_track_data from unshackle.core.api.handlers import client_session_auth, resolve_handler_proxy, scrub_track_data
from unshackle.core.remote_service import apply_session_cookies
def _session(client_auth): def _session(client_auth):
cookie = SimpleNamespace(name="sid", value="secret") cookie = SimpleNamespace(name="sid", value="secret", domain=".example.com", path="/")
svc = SimpleNamespace(headers={"Authorization": "Bearer x", "User-Agent": "ua"}, cookies=[cookie]) svc = SimpleNamespace(headers={"Authorization": "Bearer x", "User-Agent": "ua"}, cookies=[cookie])
return SimpleNamespace(service_instance=SimpleNamespace(session=svc), client_auth=client_auth) return SimpleNamespace(service_instance=SimpleNamespace(session=svc), client_auth=client_auth)
@@ -15,14 +17,129 @@ def _session(client_auth):
def test_client_login_keeps_auth(): def test_client_login_keeps_auth():
headers, cookies = client_session_auth(_session(True)) headers, cookies = client_session_auth(_session(True))
assert headers == {"Authorization": "Bearer x", "User-Agent": "ua"} assert headers == {"Authorization": "Bearer x", "User-Agent": "ua"}
assert cookies == {"sid": "secret"} assert cookies == [{"name": "sid", "value": "secret", "domain": ".example.com", "path": "/", "secure": False}]
assert scrub_track_data({"token": "t", "hls": {}}, _session(True)) == {"token": "t", "hls": {}} assert scrub_track_data({"token": "t", "hls": {}}, _session(True)) == {"token": "t", "hls": {}}
def test_client_cookies_keep_their_domain():
session = requests.Session()
_, cookies = client_session_auth(_session(True))
apply_session_cookies(session, {"session_cookie_jar": cookies})
assert session.cookies.get("sid", domain=".example.com") == "secret"
cdn = session.prepare_request(requests.Request("GET", "https://cdn.other.net/seg.mp4"))
assert "Cookie" not in cdn.headers
own = session.prepare_request(requests.Request("GET", "https://www.example.com/api"))
assert own.headers["Cookie"] == "sid=secret"
def test_client_takes_old_server_cookies_without_domain():
session = requests.Session()
apply_session_cookies(session, {"session_cookies": {"sid": "secret"}})
assert session.cookies.get("sid") == "secret"
def test_client_keeps_secure_cookies_off_http():
session = requests.Session()
jar = [{"name": "sid", "value": "s", "domain": ".example.com", "path": "/", "secure": True}]
apply_session_cookies(session, {"session_cookie_jar": jar})
plain = session.prepare_request(requests.Request("GET", "http://cdn.example.com/seg.mp4"))
assert "Cookie" not in plain.headers
tls = session.prepare_request(requests.Request("GET", "https://cdn.example.com/seg.mp4"))
assert tls.headers["Cookie"] == "sid=s"
def test_client_skips_malformed_server_cookies():
session = requests.Session()
jar = [{"value": "v"}, "str", {"name": "n", "value": 1}, {"name": "ok", "value": "v", "domain": ".example.com"}]
apply_session_cookies(session, {"session_cookie_jar": jar})
assert [c.name for c in session.cookies] == ["ok"]
session = requests.Session()
apply_session_cookies(session, {"session_cookie_jar": 5, "session_cookies": ["x"]})
assert not session.cookies
def test_rnet_session_sends_client_and_set_cookies():
from unshackle.core.session import RnetSession
svc = RnetSession()
svc.cookies.set("sid", "stale", domain=".example.com")
svc.cookies.set("cdn", "c", domain="cdn.other.net")
svc.export_origin_cookies = lambda: {"https://www.example.com": "sid=live; cf=1"}
_, cookies = client_session_auth(SimpleNamespace(service_instance=SimpleNamespace(session=svc), client_auth=True))
assert {(c["name"], c["value"], c["domain"]) for c in cookies} == {
("sid", "live", "www.example.com"),
("cf", "1", "www.example.com"),
("cdn", "c", "cdn.other.net"),
}
def test_tracks_response_carries_the_cookie_jar():
import asyncio
import json
from unshackle.core.api import session_store as store_mod
from unshackle.core.api.handlers import session_tracks_handler
from unshackle.core.tracks import Tracks
cookie = SimpleNamespace(name="sid", value="secret", domain=".example.com", path="/", secure=True)
class StubService:
session = SimpleNamespace(headers={}, cookies=[cookie])
def get_tracks(self, title):
return Tracks()
def get_chapters(self, title):
raise NotImplementedError
async def run():
store = store_mod.get_session_store()
entry = await store.create(
service_tag="EXAMPLE", service_instance=StubService(), session_id="jar", owner_key=None
)
entry.title_map = {"t1": "Example Title"}
entry.client_auth = True
try:
return json.loads((await session_tracks_handler({"title_id": "t1"}, "jar")).body)
finally:
await store.delete("jar")
body = asyncio.run(run())
assert body["session_cookies"] == {"sid": "secret"}
assert body["session_cookie_jar"] == [
{"name": "sid", "value": "secret", "domain": ".example.com", "path": "/", "secure": True}
]
def test_remote_get_tracks_scopes_server_cookies():
import logging
from unshackle.core.remote_service import RemoteService
jar = [{"name": "sid", "value": "secret", "domain": ".example.com", "path": "/"}]
service = object.__new__(RemoteService)
service.__dict__.update(
_tracks_by_title={},
_chapters_by_title={},
_sample_decrypt_tracks=set(),
_server_cdm=None,
_session=requests.Session(),
_session_id="s",
service_tag="EXAMPLE",
log=logging.getLogger("test"),
client=SimpleNamespace(post=lambda path, body: {"session_cookie_jar": jar}),
drain_server_logs=lambda: None,
)
service.get_tracks(SimpleNamespace(id="t1"))
cdn = service._session.prepare_request(requests.Request("GET", "https://cdn.other.net/seg.mp4"))
assert "Cookie" not in cdn.headers
assert service._session.cookies.get("sid", domain=".example.com") == "secret"
def test_server_login_strips_auth(): def test_server_login_strips_auth():
headers, cookies = client_session_auth(_session(False)) headers, cookies = client_session_auth(_session(False))
assert headers == {"User-Agent": "ua"} assert headers == {"User-Agent": "ua"}
assert cookies == {} assert cookies == []
assert scrub_track_data({"token": "t", "hls": {}}, _session(False)) == {"hls": {}} assert scrub_track_data({"token": "t", "hls": {}}, _session(False)) == {"hls": {}}
+28 -12
View File
@@ -34,7 +34,7 @@ from unshackle.core.drm import cbcs
from unshackle.core.providers.anilist import parse_anilist_ref 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, resolve_proxy
from unshackle.core.services import Services from unshackle.core.services import Services
from unshackle.core.session import utf8_text from unshackle.core.session import RnetSession, utf8_text
from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.titles.music import SONG_FIELDS from unshackle.core.titles.music import SONG_FIELDS
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
@@ -4068,8 +4068,8 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request]
) )
def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]: def client_session_auth(session: Any) -> tuple[Dict[str, str], list[Dict[str, Any]]]:
"""The service session headers and cookies a remote client may receive. """The service session headers and cookies (name, value, domain, path, secure) a remote client may receive.
A client whose own login succeeded gets them back. For a server account the cookie jar A client whose own login succeeded gets them back. For a server account the cookie jar
stays on the server and the server drops the auth-bearing headers, so the client cannot stays on the server and the server drops the auth-bearing headers, so the client cannot
@@ -4078,18 +4078,31 @@ def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]:
""" """
svc_session = session.service_instance.session svc_session = session.service_instance.session
headers = dict(svc_session.headers) if hasattr(svc_session, "headers") and svc_session.headers else {} headers = dict(svc_session.headers) if hasattr(svc_session, "headers") and svc_session.headers else {}
cookies: Dict[str, str] = {} cookies: list[Dict[str, Any]] = []
if hasattr(svc_session, "cookies"): jar = svc_session.scoped_cookies() if isinstance(svc_session, RnetSession) else getattr(svc_session, "cookies", [])
for cookie in svc_session.cookies: for cookie in jar:
if hasattr(cookie, "name") and hasattr(cookie, "value"): if hasattr(cookie, "name") and hasattr(cookie, "value"):
cookies[cookie.name] = cookie.value cookies.append(
{
"name": cookie.name,
"value": cookie.value,
"domain": getattr(cookie, "domain", None) or "",
"path": getattr(cookie, "path", None) or "/",
"secure": bool(getattr(cookie, "secure", False)),
}
)
if not client_owns_login(session): if not client_owns_login(session):
headers = {k: v for k, v in headers.items() if not CONFIG_SECRET_KEY_RE.search(str(k))} headers = {k: v for k, v in headers.items() if not CONFIG_SECRET_KEY_RE.search(str(k))}
if not session.client_auth: if not session.client_auth:
cookies = {} cookies = []
return headers, cookies return headers, cookies
def cookie_values(cookies: list[Dict[str, Any]]) -> Dict[str, str]:
"""The ``{name: value}`` form of :func:`client_session_auth` cookies, for clients that predate the jar."""
return {c["name"]: c["value"] for c in cookies}
def client_owns_login(session: Any) -> bool: def client_owns_login(session: Any) -> bool:
"""Whether the login state of ``session`` is the client's to take back. """Whether the login state of ``session`` is the client's to take back.
@@ -4232,7 +4245,8 @@ async def session_tracks_handler(
"manifests": manifests, "manifests": manifests,
"track_manifests": track_manifests, "track_manifests": track_manifests,
"session_headers": session_headers, "session_headers": session_headers,
"session_cookies": session_cookies, "session_cookies": cookie_values(session_cookies),
"session_cookie_jar": session_cookies,
"server_cdm": session.server_cdm, "server_cdm": session.server_cdm,
"server_cdm_type": server_cdm_type, "server_cdm_type": server_cdm_type,
"sample_decrypt_tracks": [ "sample_decrypt_tracks": [
@@ -4269,6 +4283,7 @@ async def session_segments_handler(
try: try:
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
headers, cookies = client_session_auth(session)
for track_id in track_ids: for track_id in track_ids:
track = session.tracks.get(track_id) track = session.tracks.get(track_id)
@@ -4285,10 +4300,11 @@ async def session_segments_handler(
"descriptor": descriptor_name, "descriptor": descriptor_name,
"url": str(track.url) if track.url else None, "url": str(track.url) if track.url else None,
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
"headers": headers,
"cookies": cookie_values(cookies),
"cookie_jar": cookies,
} }
track_info["headers"], track_info["cookies"] = client_session_auth(session)
# Include manifest-specific data for segment resolution. Round-trip through # Include manifest-specific data for segment resolution. Round-trip through
# JSON so any non-serializable value becomes its str() (default=str). # JSON so any non-serializable value becomes its str() (default=str).
if hasattr(track, "data") and track.data: if hasattr(track, "data") and track.data:
+22 -2
View File
@@ -426,6 +426,27 @@ def apply_service_track_data(tracks: Tracks, data: Dict[str, Any]) -> None:
track.data.update(extra) track.data.update(extra)
def apply_session_cookies(session: requests.Session, result: dict) -> None:
"""Copy the server's session cookies into ``session``, each scoped to its own domain.
An older server sends only ``session_cookies`` names and values, so those cookies go to every host.
It skips malformed entries.
"""
jar = result.get("session_cookie_jar")
if not isinstance(jar, list):
old = result.get("session_cookies")
jar = [{"name": k, "value": v} for k, v in old.items()] if isinstance(old, dict) else []
for c in jar:
if isinstance(c, dict) and isinstance(c.get("name"), str) and isinstance(c.get("value"), str):
session.cookies.set(
c["name"],
c["value"],
domain=str(c.get("domain") or ""),
path=str(c.get("path") or "/"),
secure=bool(c.get("secure")),
)
def resolve_track_manifests(tracks: Tracks, track_manifests: list) -> None: def resolve_track_manifests(tracks: Tracks, track_manifests: list) -> None:
"""Fill track.data from the per-track MPD fragments the server sent. """Fill track.data from the per-track MPD fragments the server sent.
@@ -1651,8 +1672,7 @@ class RemoteService:
for k, v in result.get("session_headers", {}).items(): for k, v in result.get("session_headers", {}).items():
if k.lower() not in ("host", "content-length", "content-type"): if k.lower() not in ("host", "content-length", "content-type"):
self._session.headers[k] = v self._session.headers[k] = v
for k, v in result.get("session_cookies", {}).items(): apply_session_cookies(self._session, result)
self._session.cookies.set(k, v)
resolve_track_manifests(tracks, result.get("track_manifests", [])) resolve_track_manifests(tracks, result.get("track_manifests", []))
resolve_manifest_data(tracks, result.get("manifests", [])) resolve_manifest_data(tracks, result.get("manifests", []))
+31 -5
View File
@@ -17,6 +17,7 @@ from urllib.parse import urlencode, urlparse, urlunparse
import rnet import rnet
from requests import HTTPError, Request, Response from requests import HTTPError, Request, Response
from requests.cookies import create_cookie
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from unshackle.core.config import config from unshackle.core.config import config
@@ -664,14 +665,19 @@ class RnetSession:
self._client = client self._client = client
@staticmethod @staticmethod
def set_cookie_header(client: rnet.BlockingClient, origin: str, header: str) -> None: def parse_cookie_header(header: str) -> Iterator[tuple[str, str]]:
"""Set every cookie in a ``name=value; ...`` header string on one origin of *client*.""" """The ``(name, value)`` pairs of a ``name=value; ...`` header string."""
for pair in header.split(";"): for pair in header.split(";"):
name, separator, value = pair.partition("=") name, separator, value = pair.partition("=")
if not separator: if separator:
continue yield name.strip(), value.strip()
@staticmethod
def set_cookie_header(client: rnet.BlockingClient, origin: str, header: str) -> None:
"""Set every cookie in a ``name=value; ...`` header string on one origin of *client*."""
for name, value in RnetSession.parse_cookie_header(header):
try: try:
client.set_cookie(origin, rnet.Cookie(name.strip(), value.strip())) client.set_cookie(origin, rnet.Cookie(name, value))
except Exception: except Exception:
pass pass
@@ -705,6 +711,26 @@ class RnetSession:
exported[origin] = raw.decode("utf-8", errors="replace") exported[origin] = raw.decode("utf-8", errors="replace")
return exported return exported
def scoped_cookies(self) -> CookieJar:
"""Every cookie this HTTP session sends, each scoped to its domain.
The live client's cookies of each requested origin come first, then the cookies set
through :attr:`cookies` for any other host.
"""
jar = CookieJar()
hosts: dict[str, set[str]] = {}
for origin, header in self.export_origin_cookies().items():
host = urlparse(origin).hostname or ""
for name, value in self.parse_cookie_header(header):
jar.set_cookie(create_cookie(name, value, domain=host))
hosts.setdefault(name, set()).add(host)
for domain, values in self.cookies.get_dict_by_domain().items():
bare = domain.lstrip(".")
for name, value in values.items():
if not any(h == bare or h.endswith("." + bare) for h in hosts.get(name, ())):
jar.set_cookie(create_cookie(name, value, domain=domain))
return jar
def import_origin_cookies(self, exported: dict[str, str]) -> None: def import_origin_cookies(self, exported: dict[str, str]) -> None:
"""Load cookie headers from :meth:`export_origin_cookies` into this HTTP session's client.""" """Load cookie headers from :meth:`export_origin_cookies` into this HTTP session's client."""
if not exported: if not exported: