diff --git a/CHANGES/13168.bugfix.rst b/CHANGES/13168.bugfix.rst new file mode 100644 index 00000000000..90051787e6d --- /dev/null +++ b/CHANGES/13168.bugfix.rst @@ -0,0 +1,4 @@ +Enforced the ``__Secure-`` and ``__Host-`` cookie name prefixes +(RFC 6265bis, section 4.1.3) in :class:`~aiohttp.CookieJar`, so a +prefixed cookie that is not host-locked and secure is no longer stored +-- by :user:`arshsmith1`. diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py index 8e945f3470d..82262fbb2d6 100644 --- a/aiohttp/cookiejar.py +++ b/aiohttp/cookiejar.py @@ -337,6 +337,21 @@ def update_cookies(self, cookies: LooseCookies, response_url: URL = URL()) -> No tmp[name] = cookie # type: ignore[assignment] cookie = tmp[name] + # RFC 6265bis 4.1.3: enforce the __Secure- and __Host- name + # prefixes so a related-origin (sibling subdomain) or on-path party + # cannot forge a cookie the target server trusts as host-locked and + # secure. Only applied to cookies from an actual response; a jar + # seeded programmatically has no response host to check against. + if hostname is not None: + lower_name = name.lower() + if lower_name.startswith(("__secure-", "__host-")): + if not cookie["secure"] or not self._is_secure_origin(response_url): + continue + if lower_name.startswith("__host-") and ( + cookie["domain"] or cookie["path"] != "/" + ): + continue + domain = cookie["domain"] # ignore domains with trailing dots @@ -495,6 +510,15 @@ def _build_morsel(self, cookie: Morsel[str]) -> Morsel[str]: morsel.__setstate__({"key": cookie.key, "value": value, "coded_value": coded_value}) # type: ignore[attr-defined] return morsel + def _is_secure_origin(self, url: URL) -> bool: + """Whether cookies from url should be treated as a secure origin.""" + if url.scheme in ("https", "wss"): + return True + if self._treat_as_secure_origin: + with contextlib.suppress(ValueError): + return url.origin() in self._treat_as_secure_origin + return False + @staticmethod def _is_domain_match(domain: str, hostname: str) -> bool: """Implements domain matching adhering to RFC 6265.""" diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 0c24df71c0b..7872c4f3f60 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -421,6 +421,65 @@ async def test_ignore_domain_ending_with_dot() -> None: assert cookies_sent.output(header="Cookie:") == "" +@pytest.mark.parametrize( + ("set_cookie", "url"), + [ + # __Host- with a Domain attribute is not host-only. + ( + "__Host-sid=x; Domain=example.com; Path=/; Secure", + "https://sub.example.com/", + ), + # __Host- without Path=/. + ("__Host-sid=x; Path=/admin; Secure", "https://example.com/admin"), + # __Host- without Secure. + ("__Host-sid=x; Path=/", "https://example.com/"), + # Prefixes must not be accepted over an insecure origin. + ("__Host-sid=x; Path=/; Secure", "http://example.com/"), + ("__Secure-sid=x; Secure", "http://example.com/"), + # __Secure- without the Secure attribute. + ("__Secure-sid=x", "https://example.com/"), + # Prefix match is case-insensitive, so casing cannot bypass it. + ( + "__host-sid=x; Domain=example.com; Path=/; Secure", + "https://sub.example.com/", + ), + ], +) +async def test_cookie_prefix_rejected(set_cookie: str, url: str) -> None: + jar = CookieJar() + jar.update_cookies(SimpleCookie(set_cookie), URL(url)) + assert len(jar) == 0 + + +@pytest.mark.parametrize( + ("set_cookie", "url"), + [ + ("__Host-sid=x; Path=/; Secure", "https://example.com/"), + ("__Secure-sid=x; Secure", "https://example.com/"), + ("__Secure-sid=x; Domain=example.com; Secure", "https://www.example.com/"), + ], +) +async def test_cookie_prefix_accepted(set_cookie: str, url: str) -> None: + jar = CookieJar() + jar.update_cookies(SimpleCookie(set_cookie), URL(url)) + assert len(jar) == 1 + + +async def test_cookie_prefix_programmatic_seed_unaffected() -> None: + # Seeding the jar without a response URL is a trusted path and keeps working. + jar = CookieJar() + jar.update_cookies(SimpleCookie("__Host-seed=x; Path=/; Secure")) + assert "__Host-seed" in jar.filter_cookies(URL("https://any.example/")) + + +async def test_cookie_prefix_treat_as_secure_origin() -> None: + jar = CookieJar(treat_as_secure_origin=URL("http://example.com")) + jar.update_cookies( + SimpleCookie("__Host-sid=x; Path=/; Secure"), URL("http://example.com/") + ) + assert "__Host-sid" in jar.filter_cookies(URL("http://example.com/")) + + class TestCookieJarSafe: @pytest.fixture(autouse=True) def setup_cookies(