Files
unshackle/tests/core/test_session_headers.py
T
imSp4rky 96c3840b82 fix(session): keep cookie paths so same-name cookies stay on their own path
RnetCookieAdapter stores cookies by domain, path and name and sends path, secure and a leading-dot domain to rnet. It also takes server-rotated values, and worker children and track sessions get the cookies with their paths.
2026-10-31 22:57:22 -06:00

428 lines
15 KiB
Python

"""Removing a header or a cookie from an RnetSession must stop it reaching the wire.
``client.update(headers=...)`` merges into the rnet client default headers and has no
removal, and the client keeps its own cookie store. These tests read what a real local
server received, because the session dicts pass even with the leak in place.
"""
from __future__ import annotations
import threading
from http.cookiejar import Cookie, CookieJar
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterator
import pytest
import rnet
from unshackle.core.session import RnetCookieAdapter, RnetSession
pytestmark = pytest.mark.unit
class Recorder(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
received: list[dict[str, str]] = []
cookies_sent: list[str] = []
def do_GET(self) -> None:
type(self).received.append({k.lower(): v for k, v in self.headers.items()})
# rnet sends one Cookie header per cookie on HTTP/1.1, so a dict of the headers
# keeps only the last one and hides a cookie that leaked.
type(self).cookies_sent.append("; ".join(self.headers.get_all("Cookie") or []))
body = b"ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
if self.path == "/login":
self.send_header("Set-Cookie", "sid=abc123; Path=/")
elif self.path.startswith("/set/"):
self.send_header("Set-Cookie", f"{self.path[len('/set/') :]}; Path=/")
elif self.path.startswith("/scoped/set/"):
self.send_header("Set-Cookie", f"{self.path[len('/scoped/set/') :]}; Path=/scoped")
elif self.path.startswith("/scoped/expire/"):
self.send_header("Set-Cookie", f"{self.path[len('/scoped/expire/') :]}=; Max-Age=0; Path=/scoped")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
@pytest.fixture
def server() -> Iterator[str]:
Recorder.received = []
Recorder.cookies_sent = []
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Recorder)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
yield f"http://127.0.0.1:{httpd.server_port}"
finally:
httpd.shutdown()
httpd.server_close()
def make_cookie(name: str, value: str, domain: str, path: str = "/") -> Cookie:
return Cookie(
version=0,
name=name,
value=value,
port=None,
port_specified=False,
domain=domain,
domain_specified=True,
domain_initial_dot=domain.startswith("."),
path=path,
path_specified=True,
secure=False,
expires=None,
discard=False,
comment=None,
comment_url=None,
rest={},
)
def test_popped_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
assert Recorder.received[-1]["x-auth"] == "secret"
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert Recorder.received[-1]["x-keep"] == "yes"
def test_deleted_header_stops_being_sent(server: str) -> None:
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
del session.headers["X-Auth"]
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_removes_every_header(server: str) -> None:
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/one")
assert "x-auth" in Recorder.received[-1]
session.headers.clear()
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
assert "x-trace" not in Recorder.received[-1]
def test_header_removal_keeps_cookies(server: str) -> None:
"""A removed header must not cost the session the cookies it has already earned."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/login")
session.get(f"{server}/before")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.received[-1].get("cookie") == "sid=abc123"
def test_surviving_headers_keep_the_impersonate_order(server: str) -> None:
"""A rebuilt client must overlay the preset headers, not replace them."""
session = RnetSession(impersonate=rnet.Impersonate.Chrome131)
session.headers.update({"X-Auth": "secret", "X-Keep": "yes"})
session.get(f"{server}/one")
before = [k for k in Recorder.received[-1] if k != "x-auth"]
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert [k for k in Recorder.received[-1]] == before
def test_header_removal_keeps_cookies_for_a_host_not_yet_visited(server: str) -> None:
"""A cookie unshackle set itself must survive the rebuild.
``rebuild_client`` can only read the rnet cookie jar per origin, and it knows only the
origins the session has requested. Cookies loaded from a cookie file, or set through
``session.cookies``, are for a host the session often reaches later.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
session.cookies.set("sid", "abc123", domain="example.test")
session.headers.pop("X-Auth", None)
raw = session._client.get_cookies("https://example.test")
assert raw is not None and b"sid=abc123" in raw
def test_rebuild_survives_an_origin_arriving_mid_rebuild(server: str, monkeypatch) -> None:
"""``request`` records origins from the download threads while the rebuild reads them.
Iterating the live set raises ``RuntimeError: Set changed size during iteration`` out of the
header removal. Adding an origin from inside the copy stands in for that race.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/one")
original = RnetSession.__dict__["copy_cookies"].__func__
def racing_copy(source, target, origin: str) -> None:
session._origins.add(f"https://late-{len(session._origins)}.test")
original(source, target, origin)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(racing_copy))
session.headers.pop("X-Auth", None)
session.get(f"{server}/two")
assert "x-auth" not in Recorder.received[-1]
def test_clear_drops_a_server_set_cookie(server: str) -> None:
"""``cookies.clear()`` must reach the rnet cookie store, not only the local dicts."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear()
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_leaves_the_session_able_to_take_new_cookies(server: str) -> None:
"""A cleared session must still hold the next cookie, and only that one."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.get(f"{server}/set/fresh=1")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "fresh=1"
def test_delete_drops_only_the_named_server_set_cookie(server: str) -> None:
"""``del cookies[name]`` must drop that name from the store and leave the rest."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/set/other=keep")
del session.cookies["sid"]
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == "other=keep"
def test_clear_by_name_drops_a_server_set_cookie(server: str) -> None:
"""``clear(name=...)`` is the requests spelling a service reaches for."""
session = RnetSession()
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
session.cookies.clear(name="sid")
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_clear_before_the_client_exists_empties_the_jar_and_sends_nothing(server: str) -> None:
"""``clear()`` on a session that has made no request must empty every cookie view.
``dl.save_cookies`` writes ``cookies.jar`` back to the cookie file and only ever merges,
so a cleared cookie left in the jar returns to disk.
"""
session = RnetSession()
jar = CookieJar()
jar.set_cookie(make_cookie("sid", "abc123", "127.0.0.1"))
session.cookies.update(jar)
session.cookies.set("other", "keep", domain="127.0.0.1")
session.cookies.clear()
assert dict(session.cookies) == {}
assert session.cookies.get_dict() == {}
assert list(session.cookies.jar) == []
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_later_header_rebuild_does_not_resurrect_cleared_cookies(server: str) -> None:
"""``rebuild_client`` replays the buffered cookies and copies the old jar per origin.
A ``clear()`` that left either populated hands the cookie back on the next header removal.
"""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
session.cookies.clear()
session.headers.pop("X-Auth", None)
session.get(f"{server}/after")
assert Recorder.cookies_sent[-1] == ""
def test_a_request_during_the_rebuild_still_carries_headers_and_cookies(server: str, monkeypatch) -> None:
"""A request from another thread must never see a client with neither headers nor cookies.
``rebuild_client`` publishes the new client before it carries the headers and the cookies,
so a request in that window goes out unauthenticated.
"""
session = RnetSession()
session.headers.update({"X-Auth": "secret", "X-Trace": "on"})
session.get(f"{server}/set/sid=abc123")
session.get(f"{server}/before")
assert Recorder.cookies_sent[-1] == "sid=abc123"
original = RnetCookieAdapter.flush_to_client
rebuilding: list[bool] = []
def flush_with_a_request(self) -> None:
if rebuilding:
session.get(f"{server}/mid")
original(self)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", flush_with_a_request)
rebuilding.append(True)
session.headers.pop("X-Trace", None)
assert Recorder.received[-1].get("x-auth") == "secret"
assert Recorder.cookies_sent[-1] == "sid=abc123"
def test_the_rebuilt_client_is_published_only_when_it_is_ready(server: str, monkeypatch) -> None:
"""The new client must go on ``_client`` last, after the headers, the flush and the copy."""
session = RnetSession()
session.headers["X-Auth"] = "secret"
session.get(f"{server}/set/sid=abc123")
old = session._client
seen: list[object] = []
rebuilding: list[bool] = []
original_flush = RnetCookieAdapter.flush_to_client
original_copy = RnetSession.__dict__["copy_cookies"].__func__
def recording_flush(self) -> None:
if rebuilding:
seen.append(session._client)
original_flush(self)
def recording_copy(source, target, origin: str) -> None:
seen.append(session._client)
original_copy(source, target, origin)
monkeypatch.setattr(RnetCookieAdapter, "flush_to_client", recording_flush)
monkeypatch.setattr(RnetSession, "copy_cookies", staticmethod(recording_copy))
rebuilding.append(True)
session.headers.pop("X-Auth", None)
assert seen and all(client is old for client in seen)
assert session._client is not old
def path_session(*cookies: Cookie) -> RnetSession:
session = RnetSession()
session.cookies.update(list(cookies))
return session
def test_same_name_cookies_reach_only_their_own_path(server: str) -> None:
"""Two cookies with one name on two paths must each reach only their own path."""
session = path_session(make_cookie("h", "A", "127.0.0.1", "/a"), make_cookie("h", "B", "127.0.0.1", "/b"))
for path, expected in (("/a/seg", "h=A"), ("/b/seg", "h=B"), ("/", "")):
session.get(f"{server}{path}")
assert Recorder.cookies_sent[-1] == expected
session.headers["X-Rebuild"] = "1"
del session.headers["X-Rebuild"]
session.get(f"{server}/a/seg")
assert Recorder.cookies_sent[-1] == "h=A"
def test_delete_drops_every_path_copy(server: str) -> None:
"""``del cookies[name]`` must reach a cookie stored on a path, not only the root-path one."""
session = path_session(make_cookie("h", "A", "127.0.0.1", "/a"), make_cookie("h", "B", "127.0.0.1", "/b"))
session.get(f"{server}/a/seg")
del session.cookies["h"]
session.get(f"{server}/a/seg")
session.get(f"{server}/b/seg")
assert Recorder.cookies_sent[-2:] == ["", ""]
assert list(session.cookies.jar) == []
def test_clear_by_path_spares_the_other_path(server: str) -> None:
session = path_session(make_cookie("h", "A", "127.0.0.1", "/a"), make_cookie("h", "B", "127.0.0.1", "/b"))
session.get(f"{server}/a/seg")
session.cookies.clear(domain="127.0.0.1", path="/a", name="h")
session.get(f"{server}/a/seg")
session.get(f"{server}/b/seg")
assert Recorder.cookies_sent[-2:] == ["", "h=B"]
assert session.cookies.get("h", path="/b") == "B"
assert session.cookies.get("h", path="/a") is None
def test_a_rotated_path_cookie_survives_a_rebuild(server: str) -> None:
"""A value the server set for a stored path cookie must win after ``rebuild_client`` and on save."""
session = path_session(make_cookie("h", "STALE", "127.0.0.1", "/scoped"))
session.headers["X-Auth"] = "secret"
session.get(f"{server}/scoped/set/h=FRESH")
session.headers.pop("X-Auth", None)
session.get(f"{server}/scoped/seg")
assert Recorder.cookies_sent[-1] == "h=FRESH"
assert [(c.path, c.value) for c in session.cookies.jar] == [("/scoped", "FRESH")]
def test_a_server_expiry_drops_the_stored_cookie(server: str) -> None:
session = path_session(make_cookie("h", "STALE", "127.0.0.1", "/scoped"))
session.headers["X-Auth"] = "secret"
session.get(f"{server}/scoped/expire/h")
session.headers.pop("X-Auth", None)
session.get(f"{server}/scoped/seg")
assert Recorder.cookies_sent[-1] == ""
assert "h" not in session.cookies
def test_only_a_leading_dot_widens_a_cookie_to_subdomains() -> None:
"""A Netscape row with a leading dot covers subdomains; any other domain stays host-only."""
assert RnetCookieAdapter.to_rnet(make_cookie("s", "1", ".example.com")).domain == "example.com"
assert RnetCookieAdapter.to_rnet(make_cookie("s", "1", "www.example.com")).domain is None
def test_the_latest_write_answers_a_lookup_by_name() -> None:
session = path_session(make_cookie("tok", "file", ".example.com"))
session.cookies.update({"tok": "dict"})
assert session.cookies.get("tok") == "dict"
assert session.cookies["tok"] == "dict"
session.cookies.set("tok", "set", domain=".example.com")
assert session.cookies.get("tok") == "set"
assert session.cookies.get("tok", domain="") == "dict"
assert [c.value for c in session.cookies.jar] == ["set"]