From 08a2257be3c086398aeaf96dd22a2c969eb4dbc7 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 20 Oct 2026 12:46:44 -0600 Subject: [PATCH] fix(api): parse client cookies with the dl cookie parser --- tests/core/test_client_cookies_flag.py | 8 ++++++++ unshackle/commands/dl.py | 19 +++++++++++++------ unshackle/core/api/handlers.py | 24 ++++++++---------------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/tests/core/test_client_cookies_flag.py b/tests/core/test_client_cookies_flag.py index 079631cf..92c380cd 100644 --- a/tests/core/test_client_cookies_flag.py +++ b/tests/core/test_client_cookies_flag.py @@ -29,6 +29,14 @@ def test_load_client_cookies_reads_a_transport_blob(): assert [(c.name, c.value) for c in jar] == [("session-id", "123-456")] +def test_load_client_cookies_reads_what_local_dl_reads(): + """A file with no Netscape header, space-separated rows and an HttpOnly prefix loads as it does locally.""" + text = "#HttpOnly_.example.com TRUE / TRUE 2000000000 session-id 123-456\n" + jar = load_client_cookies(transport_blob(text)) + 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 diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 662b2e99..df31d2db 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -5627,11 +5627,18 @@ class dl: @staticmethod def load_cookie_file(cookie_file: Path) -> MozillaCookieJar: """Load a Netscape cookie file with every expiry blanked, so no layer drops an expired cookie.""" - cookie_jar = MozillaCookieJar(cookie_file) - cookie_data = html.unescape(cookie_file.read_text("utf-8-sig")).splitlines(keepends=False) + cookie_jar = dl.parse_cookie_text(cookie_file.read_text("utf-8-sig"), str(cookie_file)) + cookie_jar.filename = str(cookie_file) + return cookie_jar + + @staticmethod + def parse_cookie_text(text: str, source: str) -> MozillaCookieJar: + """Parse Netscape cookie text the way :meth:`load_cookie_file` does; ``source`` names it in errors.""" + cookie_jar = MozillaCookieJar() + cookie_data = html.unescape(text).splitlines(keepends=False) head = next((line.lstrip() for line in cookie_data if line.strip()), "") if head[:1] in ("[", "{"): - raise ValueError(f"{cookie_file} is a JSON export; export it again in Netscape format") + raise ValueError(f"{source} is a JSON export; export it again in Netscape format") for i, line in enumerate(cookie_data): body = line.lstrip() prefix = "#HttpOnly_" if body.startswith("#HttpOnly_") else "" @@ -5642,14 +5649,14 @@ class dl: if len(line_data) == 6: line_data.append("") if len(line_data) != 7: - raise ValueError(f"{cookie_file} line {i + 1} is not a Netscape cookie row: {line!r}") + raise ValueError(f"{source} line {i + 1} is not a Netscape cookie row: {line!r}") line_data[4] = "" line_data[1] = str(line_data[0].startswith(".")).upper() cookie_data[i] = prefix + "\t".join(line_data) cookie_data.insert(0, "# Netscape HTTP Cookie File") - cookie_jar._really_load(StringIO("\n".join(cookie_data)), str(cookie_file), True, True) # type: ignore[attr-defined] + cookie_jar._really_load(StringIO("\n".join(cookie_data)), source, True, True) # type: ignore[attr-defined] if not len(cookie_jar): - raise ValueError(f"{cookie_file} holds no cookies; export it again in Netscape format") + raise ValueError(f"{source} holds no cookies; export it again in Netscape format") return cookie_jar @staticmethod diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index add9b158..2f854203 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3,9 +3,7 @@ import base64 import enum import json import logging -import os import re -import tempfile import threading import time import uuid @@ -15,7 +13,7 @@ from contextlib import suppress from dataclasses import dataclass, field from datetime import date as date_ from functools import partial -from http.cookiejar import CookieJar, MozillaCookieJar +from http.cookiejar import CookieJar from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, cast @@ -1146,24 +1144,18 @@ def server_account_cookies(service: str, profile: Optional[str]) -> Any: 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. + The client sends a Netscape cookie file compressed and base64 encoded. It is parsed + the way local ``dl`` parses a cookie file, so a file that works locally also works + over ``--remote``. """ + from unshackle.commands.dl import dl + 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) + cookie_str = safe_inflate(base64.b64decode(cookie_text)).decode("utf-8-sig") + return dl.parse_cookie_text(cookie_str, "client cookies") except (ValueError, zlib.error, OSError) as e: raise APIError(APIErrorCode.INVALID_INPUT, f"cookies is not a compressed Netscape cookie file: {e}")