Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
48 changes: 45 additions & 3 deletions xhs_cli/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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()
Expand Down