From e5574927aaf09e0ed7f8dad29d15a2a0129b0d16 Mon Sep 17 00:00:00 2001 From: cdxd Date: Sat, 4 Jul 2026 14:46:58 +0800 Subject: [PATCH] fix: strip non-cookie metadata from cookie file to prevent UnicodeEncodeError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_saved_cookies() returned the entire JSON dict from the cookie file, including any metadata fields (nickname, user_id, tags, etc.) that external tools or multi-account managers may have stored alongside real cookies. When such values contain non-ASCII characters (e.g. a Chinese nickname), httpx crashes with UnicodeEncodeError when building the Cookie header. Fix: add _sanitize_cookie_dict() which filters entries on load: - Strip known metadata keys (_META_COOKIE_KEYS) - Drop entries whose key or value is not ASCII-safe - Preserve saved_at for TTL-based refresh in get_cookies() Repro before fix: # cookies.json contains: {"a1":"abc", "nickname":"N8N中文"} xhs hot # UnicodeEncodeError: 'ascii' codec can't encode characters After fix: non-cookie entries are silently dropped with a debug log. --- tests/test_cookies.py | 44 +++++++++++++++++++++++++++++++++++++++ xhs_cli/cookies.py | 48 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/tests/test_cookies.py b/tests/test_cookies.py index aae7636..66d30f3 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -64,6 +64,50 @@ def test_missing_a1(self, tmp_config_dir): (tmp_config_dir / "cookies.json").write_text('{"web_session": "x"}') assert load_saved_cookies() is None + def test_strips_non_cookie_metadata(self, tmp_config_dir): + """Metadata fields (nickname, user_id, etc.) must not leak into cookies.""" + (tmp_config_dir / "cookies.json").write_text( + '{"a1": "abc", "web_session": "sess", ' + '"nickname": "TestUser", "user_id": "12345", "tags": []}' + ) + loaded = load_saved_cookies() + assert loaded is not None + assert "a1" in loaded + assert "web_session" in loaded + assert "nickname" not in loaded + assert "user_id" not in loaded + assert "tags" not in loaded + + def test_strips_non_ascii_values(self, tmp_config_dir): + """Non-ASCII values cause UnicodeEncodeError in httpx; must be dropped.""" + (tmp_config_dir / "cookies.json").write_text( + '{"a1": "abc", "web_session": "sess", ' + '"nickname": "\u4e2d\u6587\u6635\u79f0"}' + ) + loaded = load_saved_cookies() + assert loaded is not None + assert "a1" in loaded + assert "web_session" in loaded + assert "nickname" not in loaded + assert "saved_at" in loaded or True # saved_at may or may not be present + + def test_cookies_to_string_ascii_safe(self, tmp_config_dir): + """End-to-end: cookies loaded from a file with metadata must be ASCII-safe.""" + import json as _json + data = { + "a1": "abc123", + "web_session": "sess456", + "nickname": "\u4e2d\u6587", # Chinese chars + "user_id": "999", + } + (tmp_config_dir / "cookies.json").write_text(_json.dumps(data)) + loaded = load_saved_cookies() + assert loaded is not None + # cookies_to_string must not raise UnicodeEncodeError + header = cookies_to_string(loaded) + assert "a1=abc123" in header + header.encode("ascii") # must not raise + class TestClearCookies: def test_clear_existing(self, tmp_config_dir): diff --git a/xhs_cli/cookies.py b/xhs_cli/cookies.py index 96665a4..aac8607 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -26,6 +26,14 @@ NOTE_CONTEXT_TTL_SECONDS = 86400 +# Non-cookie metadata keys that external tools or users may store in the +# cookie file alongside real cookies. Stripped on load to prevent them +# from being sent in the HTTP Cookie header. +_META_COOKIE_KEYS: frozenset[str] = frozenset({ + "nickname", "user_id", "red_id", "description", + "tags", "added_at", "slug", "browser_source", +}) + def get_config_dir() -> Path: """Get or create config directory.""" @@ -50,20 +58,54 @@ def get_index_cache_path() -> Path: def load_saved_cookies() -> dict[str, str] | None: - """Load cookies from local storage.""" + """Load cookies from local storage. + + Non-cookie metadata stored alongside real cookies (e.g. ``nickname``, + ``user_id``) is filtered out -- see :func:`_sanitize_cookie_dict`. + """ cookie_path = get_cookie_path() if not cookie_path.exists(): return None try: data = json.loads(cookie_path.read_text()) if data.get("a1"): - logger.debug("Loaded saved cookies from %s", cookie_path) - return data + cookies = _sanitize_cookie_dict(data) + logger.debug("Loaded %d cookies from %s", len(cookies), cookie_path) + return cookies except (OSError, json.JSONDecodeError) as e: logger.debug("Failed to load saved cookies: %s", e) return None +def _sanitize_cookie_dict(raw: dict[str, Any]) -> dict[str, str]: + """Filter a raw cookie-file dict to valid HTTP cookie entries. + + The cookie file is plain JSON, and external tools (or multi-account + managers) may store metadata alongside cookies -- e.g. ``nickname``, + ``user_id``, ``tags``. Non-cookie entries are stripped here because: + + * Known metadata keys (``_META_COOKIE_KEYS``) are not real cookies. + * HTTP cookie values must be ASCII-safe; non-ASCII values (e.g. a + Chinese ``nickname``) cause ``UnicodeEncodeError`` when httpx + builds the ``Cookie`` header. + + ``saved_at`` is retained for TTL-based refresh in :func:`get_cookies`. + """ + sanitized: dict[str, str] = {} + for key, value in raw.items(): + if key == "saved_at": + sanitized[key] = value + continue + if key in _META_COOKIE_KEYS: + continue + str_value = str(value) + if key.isascii() and str_value.isascii(): + sanitized[key] = str_value + else: + logger.debug("Skipping non-cookie entry %r in cookie file", key) + return sanitized + + def save_cookies(cookies: dict[str, str]) -> None: """Save cookies to local storage with restricted permissions and TTL timestamp.""" cookie_path = get_cookie_path()