Skip to content

Commit 7cc85b4

Browse files
committed
fix: clear expired websocket cookies
Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
1 parent c1004f8 commit 7cc85b4

2 files changed

Lines changed: 82 additions & 7 deletions

File tree

‎src/acp/_cookies.py‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
``Set-Cookie`` headers from the upgrade response and echo them back as a
66
``Cookie`` request header for the socket lifetime.
77
8-
This is intentionally minimal: it stores name→value pairs without attribute
9-
parsing (domain/path/expiry), matching the affinity-only use case in the RFD.
8+
This is intentionally minimal: it stores name→value pairs and only honors
9+
expiration attributes that remove a cookie, matching the affinity-only use case
10+
in the RFD.
1011
"""
1112

1213
from __future__ import annotations
1314

15+
from datetime import datetime, timezone
16+
from email.utils import parsedate_to_datetime
17+
1418
__all__ = ["MemoryAcpCookieStore"]
1519

1620

@@ -23,16 +27,23 @@ def __init__(self) -> None:
2327
def store_set_cookie(self, header_value: str) -> None:
2428
"""Ingest a single ``Set-Cookie`` header value.
2529
26-
Only the leading ``name=value`` pair is retained; cookie attributes
27-
(``; Path=/``, ``; HttpOnly`` etc.) are ignored.
30+
Only the leading ``name=value`` pair is retained; most cookie attributes
31+
(``; Path=/``, ``; HttpOnly`` etc.) are ignored. Expiration attributes
32+
that explicitly clear a cookie (non-positive ``Max-Age`` or a past
33+
``Expires`` value) remove any stored cookie with the same name.
2834
"""
29-
first = header_value.split(";", 1)[0].strip()
35+
parts = [part.strip() for part in header_value.split(";")]
36+
first = parts[0]
3037
if not first or "=" not in first:
3138
return
3239
name, _, value = first.partition("=")
3340
name = name.strip()
34-
if name:
35-
self._cookies[name] = value.strip()
41+
if not name:
42+
return
43+
if _is_deletion_cookie(parts[1:]):
44+
self._cookies.pop(name, None)
45+
return
46+
self._cookies[name] = value.strip()
3647

3748
def store_set_cookies(self, header_values: list[str]) -> None:
3849
"""Ingest multiple ``Set-Cookie`` header values."""
@@ -51,3 +62,37 @@ def clear(self) -> None:
5162

5263
def __len__(self) -> int:
5364
return len(self._cookies)
65+
66+
67+
def _is_deletion_cookie(attributes: list[str]) -> bool:
68+
return _expiry_decision(attributes) is True
69+
70+
71+
def _expiry_decision(attributes: list[str]) -> bool | None:
72+
"""Return whether attributes expire the cookie now.
73+
74+
``True`` means delete now, ``False`` means keep the cookie, and ``None``
75+
means no usable expiration attribute was present. Per RFC 6265 section 5.3,
76+
a valid ``Max-Age`` attribute takes precedence over ``Expires``.
77+
"""
78+
expires_verdict: bool | None = None
79+
for attribute in attributes:
80+
key, separator, value = attribute.partition("=")
81+
if not separator:
82+
continue
83+
key = key.strip().lower()
84+
value = value.strip()
85+
if key == "max-age":
86+
try:
87+
return int(value) <= 0
88+
except ValueError:
89+
continue
90+
if key == "expires" and expires_verdict is None:
91+
try:
92+
expires_at = parsedate_to_datetime(value)
93+
except (TypeError, ValueError):
94+
continue
95+
if expires_at.tzinfo is None:
96+
expires_at = expires_at.replace(tzinfo=timezone.utc)
97+
expires_verdict = expires_at <= datetime.now(timezone.utc)
98+
return expires_verdict

‎tests/http/test_cookies.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import pytest
4+
35
from acp._cookies import MemoryAcpCookieStore
46

57

@@ -23,6 +25,34 @@ def test_later_value_overwrites_same_name() -> None:
2325
assert len(store) == 1
2426

2527

28+
@pytest.mark.parametrize(
29+
"expiration_attribute",
30+
[
31+
"Max-Age=0",
32+
"Max-Age=-1",
33+
"Max-Age=00",
34+
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
35+
"Expires=Thu, 01 Jan 1970 00:00:01 GMT",
36+
"Expires=Wed, 31 Dec 1969 23:59:59 GMT",
37+
"Expires=Thu, 01-Jan-1970 00:00:00 GMT",
38+
"Expires=Mon, 01 Jan 2024 00:00:00 GMT",
39+
],
40+
)
41+
def test_expiring_cookie_removes_stored_value(expiration_attribute: str) -> None:
42+
store = MemoryAcpCookieStore()
43+
store.store_set_cookie("affinity=abc123; Path=/")
44+
store.store_set_cookie(f"affinity=; {expiration_attribute}; Path=/")
45+
assert store.cookie_header() is None
46+
assert len(store) == 0
47+
48+
49+
def test_positive_max_age_takes_precedence_over_past_expires() -> None:
50+
store = MemoryAcpCookieStore()
51+
store.store_set_cookie("affinity=abc123; Path=/")
52+
store.store_set_cookie("affinity=keepme; Max-Age=3600; Expires=Thu, 01 Jan 1970 00:00:00 GMT")
53+
assert store.cookie_header() == "affinity=keepme"
54+
55+
2656
def test_empty_store_returns_none() -> None:
2757
store = MemoryAcpCookieStore()
2858
assert store.cookie_header() is None

0 commit comments

Comments
 (0)