From e9eb34a851c0c094440aba2f9cbd7e7d11d4cdce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 06:45:28 +0000 Subject: [PATCH 1/7] fix(python): match cookie domains on label boundaries to prevent leakage The Python cookie jar decided whether to attach a stored cookie to an outgoing request with a raw substring check (`host.contains(domain)`). Because that is a substring test, cookies leaked to look-alike and superstring hosts: a cookie scoped to `example.com` was also sent to `notexample.com` and to `example.com.attacker.net`. Secure/auth cookies in a shared jar could therefore leak across hosts. The intended subdomain behaviour (`example.com` matching `www.example.com`) only worked as an accidental side effect of the substring match. Replace the substring check with proper RFC 6265 section 5.1.3 host/domain matching via a new `domain_matches` helper: a host matches when it equals the cookie domain or is a subdomain of it (a suffix match on a `.` label boundary). Matching is case-insensitive and ignores a leading dot on the cookie domain. Add a regression test that crafts cookies for the request host, a true parent domain, and two look-alike substrings, and asserts only the genuinely matching cookies are sent. Fixes #473 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- impit-python/src/cookies.rs | 30 ++++++++++++++++++++++++++++- impit-python/test/no_client_test.py | 26 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index 218f56a4..e0d9a8b1 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -123,7 +123,7 @@ impl CookieStore for PythonCookieJar { .and_then(|attr| attr.extract::()) .unwrap_or_default(); - if !domain.is_empty() && !url.host_str().unwrap_or_default().contains(&domain) { + if !domain_matches(url.host_str().unwrap_or_default(), &domain) { return None; } if !url.path().starts_with(&path) { @@ -175,6 +175,34 @@ impl CookieStore for PythonCookieJar { } } +/// Checks whether a request `host` may receive a cookie scoped to `cookie_domain`, +/// following the domain matching rules of +/// [RFC 6265, §5.1.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.1.3). +/// +/// A host domain-matches a cookie domain when the two are equal, or when the host +/// is a subdomain of the cookie domain — that is, the host ends with the cookie +/// domain preceded by a `.` label separator. The comparison is case-insensitive, +/// and a leading dot on the cookie domain (e.g. `.example.com`) is ignored, as +/// permitted by [RFC 6265, §4.1.2.3](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3). +/// +/// This is deliberately *not* a substring check: doing so would leak cookies to +/// look-alike or superstring hosts. For example, a cookie scoped to `example.com` +/// must not be sent to `notexample.com` or `example.com.attacker.net`. +/// +/// An empty cookie domain imposes no host restriction and therefore matches any host. +fn domain_matches(host: &str, cookie_domain: &str) -> bool { + let cookie_domain = cookie_domain.strip_prefix('.').unwrap_or(cookie_domain); + + if cookie_domain.is_empty() { + return true; + } + + let host = host.to_ascii_lowercase(); + let cookie_domain = cookie_domain.to_ascii_lowercase(); + + host == cookie_domain || host.ends_with(&format!(".{cookie_domain}")) +} + impl PythonCookieJar { pub fn new(py: Python<'_>, cookie_jar: Py) -> Self { let httpmodule = PyModule::import(py, "http.cookiejar").unwrap(); diff --git a/impit-python/test/no_client_test.py b/impit-python/test/no_client_test.py index e9f0a9e8..30d4923a 100644 --- a/impit-python/test/no_client_test.py +++ b/impit-python/test/no_client_test.py @@ -2,6 +2,7 @@ import socket import threading import time +import urllib.parse from http.cookiejar import CookieJar import pytest @@ -174,6 +175,31 @@ def test_cookies_param_works(self) -> None: assert cookies.get('preset-cookie') == '123' assert cookies.get('set-by-server') == '321' + def test_cookie_domain_matching_does_not_leak_to_lookalike_hosts(self) -> None: + # Regression test for https://github.com/apify/impit/issues/473. + # Cookie domain matching must follow RFC 6265 host/domain matching, not a raw + # substring check, otherwise a cookie scoped to e.g. `example.com` would leak to + # look-alike (`notexample.com`) or superstring (`example.com.attacker.net`) hosts. + url = get_httpbin_url('/cookies') + host = urllib.parse.urlparse(url).hostname or '' + # The host must have at least two labels for this test to be meaningful. + assert '.' in host + + first_label, _, parent_domain = host.partition('.') + + cookies = Cookies() + # These domain-match the host and must be sent. + cookies.set('exact_match', 'yes', domain=host) # host == domain + cookies.set('subdomain_ok', 'yes', domain=parent_domain) # host is a subdomain of the cookie domain + # These only match the host as a substring (not on a label boundary) and must NOT + # be sent. With the old `.contains()` check both of these would leak. + cookies.set('substring_leak', 'no', domain=host[1:]) # e.g. host `httpbin.org` vs `ttpbin.org` + cookies.set('prefix_leak', 'no', domain=first_label) # e.g. host `httpbin.org` vs `httpbin` + + response = impit.get(url, cookies=cookies).json() + + assert response['cookies'] == {'exact_match': 'yes', 'subdomain_ok': 'yes'} + @pytest.mark.skip(reason='Flaky under the CI environment') def test_http3_works(self) -> None: response = impit.get('https://curl.se', force_http3=True) From b1c487bb97ddf683386364873ef376a9f897e3a8 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Tue, 23 Jun 2026 09:37:00 +0200 Subject: [PATCH 2/7] Reduce the comments --- impit-python/src/cookies.rs | 9 +-------- impit-python/test/no_client_test.py | 5 +---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index e0d9a8b1..c085926f 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -179,16 +179,9 @@ impl CookieStore for PythonCookieJar { /// following the domain matching rules of /// [RFC 6265, §5.1.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.1.3). /// -/// A host domain-matches a cookie domain when the two are equal, or when the host -/// is a subdomain of the cookie domain — that is, the host ends with the cookie -/// domain preceded by a `.` label separator. The comparison is case-insensitive, -/// and a leading dot on the cookie domain (e.g. `.example.com`) is ignored, as +/// Leading dot on the cookie domain (e.g. `.example.com`) is ignored, as /// permitted by [RFC 6265, §4.1.2.3](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3). /// -/// This is deliberately *not* a substring check: doing so would leak cookies to -/// look-alike or superstring hosts. For example, a cookie scoped to `example.com` -/// must not be sent to `notexample.com` or `example.com.attacker.net`. -/// /// An empty cookie domain imposes no host restriction and therefore matches any host. fn domain_matches(host: &str, cookie_domain: &str) -> bool { let cookie_domain = cookie_domain.strip_prefix('.').unwrap_or(cookie_domain); diff --git a/impit-python/test/no_client_test.py b/impit-python/test/no_client_test.py index 30d4923a..1c18d453 100644 --- a/impit-python/test/no_client_test.py +++ b/impit-python/test/no_client_test.py @@ -176,10 +176,6 @@ def test_cookies_param_works(self) -> None: assert cookies.get('set-by-server') == '321' def test_cookie_domain_matching_does_not_leak_to_lookalike_hosts(self) -> None: - # Regression test for https://github.com/apify/impit/issues/473. - # Cookie domain matching must follow RFC 6265 host/domain matching, not a raw - # substring check, otherwise a cookie scoped to e.g. `example.com` would leak to - # look-alike (`notexample.com`) or superstring (`example.com.attacker.net`) hosts. url = get_httpbin_url('/cookies') host = urllib.parse.urlparse(url).hostname or '' # The host must have at least two labels for this test to be meaningful. @@ -191,6 +187,7 @@ def test_cookie_domain_matching_does_not_leak_to_lookalike_hosts(self) -> None: # These domain-match the host and must be sent. cookies.set('exact_match', 'yes', domain=host) # host == domain cookies.set('subdomain_ok', 'yes', domain=parent_domain) # host is a subdomain of the cookie domain + # These only match the host as a substring (not on a label boundary) and must NOT # be sent. With the old `.contains()` check both of these would leak. cookies.set('substring_leak', 'no', domain=host[1:]) # e.g. host `httpbin.org` vs `ttpbin.org` From d993fe1944a566f5f6dc2748029e80945acaeb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 09:23:03 +0000 Subject: [PATCH 3/7] fix(python): refine cookie domain matching per review feedback Address review comments on #488: - Avoid the per-call `format!` heap allocation in `domain_matches` by using `&str`-only operations (`eq_ignore_ascii_case` for the exact and IP comparisons, and `strip_suffix(..).is_some_and(..)` for the subdomain suffix check). - Implement the remaining RFC 6265 section 5.1.3 rule: suffix (subdomain) matching applies to host names only, so an IP-address host must match the cookie domain exactly. Without this, e.g. cookie domain `0.0.1` would match host `127.0.0.1`. Add a regression test that exercises an IP-address request host against a local server and asserts only the exactly-matching cookie is sent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- impit-python/src/cookies.rs | 20 ++++++++++-- impit-python/test/no_client_test.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index c085926f..6f06f892 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -190,10 +190,24 @@ fn domain_matches(host: &str, cookie_domain: &str) -> bool { return true; } - let host = host.to_ascii_lowercase(); - let cookie_domain = cookie_domain.to_ascii_lowercase(); + // Exact match (host names are case-insensitive). + if host.eq_ignore_ascii_case(cookie_domain) { + return true; + } - host == cookie_domain || host.ends_with(&format!(".{cookie_domain}")) + // RFC 6265 §5.1.3: suffix (subdomain) matching applies to host names only. + // An IP-address host must match the cookie domain exactly (handled above), + // otherwise e.g. cookie domain `0.0.1` would match host `127.0.0.1`. + if host.parse::().is_ok() { + return false; + } + + // Subdomain match: the cookie domain must be a suffix of the host on a `.` + // label boundary. `host` (from `Url::host_str`) is already lowercase for + // http(s) URLs, so lowercase the cookie domain to keep the match case-insensitive. + let cookie_domain = cookie_domain.to_ascii_lowercase(); + host.strip_suffix(cookie_domain.as_str()) + .is_some_and(|prefix| prefix.ends_with('.')) } impl PythonCookieJar { diff --git a/impit-python/test/no_client_test.py b/impit-python/test/no_client_test.py index 1c18d453..6090b976 100644 --- a/impit-python/test/no_client_test.py +++ b/impit-python/test/no_client_test.py @@ -31,6 +31,31 @@ def thread_server(port_holder: list[int]) -> None: server.close() +def cookie_echo_server(port_holder: list[int], captured: dict[str, str]) -> None: + """Serve a single HTTP/1.1 request on 127.0.0.1 and record its `Cookie` header.""" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(('127.0.0.1', 0)) + port_holder[0] = server.getsockname()[1] + server.listen(1) + + conn, _ = server.accept() + request = b'' + while b'\r\n\r\n' not in request: + chunk = conn.recv(4096) + if not chunk: + break + request += chunk + + for line in request.decode('utf-8', errors='replace').split('\r\n'): + if line.lower().startswith('cookie:'): + captured['cookie'] = line.split(':', 1)[1].strip() + + conn.send(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') + conn.close() + server.close() + + class TestBasicRequests: @pytest.mark.parametrize( ('protocol'), @@ -197,6 +222,28 @@ def test_cookie_domain_matching_does_not_leak_to_lookalike_hosts(self) -> None: assert response['cookies'] == {'exact_match': 'yes', 'subdomain_ok': 'yes'} + def test_cookie_domain_matching_for_ip_hosts(self) -> None: + # RFC 6265 §5.1.3: subdomain/suffix matching applies to host names only, so an + # IP-address host must match the cookie domain exactly. Without that rule a + # cookie scoped to `0.0.1` would leak to host `127.0.0.1` (it is a label-boundary + # suffix of the IP). A local server is used so the request host is a real IP. + port_holder = [0] + captured: dict[str, str] = {} + thread = threading.Thread(target=cookie_echo_server, args=(port_holder, captured)) + thread.start() + time.sleep(0.1) + + cookies = Cookies() + cookies.set('exact_ip', 'yes', domain='127.0.0.1') # exact IP match -> sent + cookies.set('ip_suffix_leak', 'no', domain='0.0.1') # IP host matches exactly -> NOT sent + + impit.get(f'http://127.0.0.1:{port_holder[0]}/', cookies=cookies, timeout=5) + thread.join() + + sent = captured.get('cookie', '') + sent_names = {part.split('=')[0].strip() for part in sent.split(';') if '=' in part} + assert sent_names == {'exact_ip'} + @pytest.mark.skip(reason='Flaky under the CI environment') def test_http3_works(self) -> None: response = impit.get('https://curl.se', force_http3=True) From 41e2a4a305309a48afc7c1e2cd849930419f02d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 11:34:22 +0000 Subject: [PATCH 4/7] fix(python): harden cookie domain matching (case-insensitive suffix, typed IP check) Address follow-up review hardening on `domain_matches`: - Make the subdomain suffix comparison case-insensitive without allocating (compare the tail slice with `eq_ignore_ascii_case` on a `.` label boundary). This drops the implicit assumption that the request host is already lowercase and removes the per-call allocation. - Detect IP-address hosts via the typed `url::Host` enum instead of string-parsing `host_str()`, which also classifies bracketed IPv6 literals correctly. Adds `url` as a direct dependency (already locked transitively at 2.5.8). No behavioural change for real inputs; the existing IP-host test now exercises the typed-Host code path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- Cargo.lock | 1 + impit-python/Cargo.toml | 1 + impit-python/src/cookies.rs | 31 +++++++++++++++++++++++-------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aecc7e83..e8480b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ "rustls", "tokio", "tokio-stream", + "url", "urlencoding", ] diff --git a/impit-python/Cargo.toml b/impit-python/Cargo.toml index bf15c003..6a3d38d2 100644 --- a/impit-python/Cargo.toml +++ b/impit-python/Cargo.toml @@ -13,6 +13,7 @@ rustls = { version="0.23.36" } tokio = { version="1.41.1", features = ["full"] } h2 = "0.4.7" reqwest = "0.13.1" +url = "2.5.8" tokio-stream = "0.1.17" bytes = "1.9.0" pyo3 = { version = "0.29", features = ["extension-module", "auto-initialize", "either"] } diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index 6f06f892..61d3d675 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -104,6 +104,11 @@ impl CookieStore for PythonCookieJar { fn cookies(&self, url: &Url) -> Option { Python::attach(|py| { + let host = url.host_str().unwrap_or_default(); + // Detect IP-literal hosts via the typed `Host` enum so the check is robust to + // IPv6 bracket notation and casing (unlike string-parsing `host_str()`). + let host_is_ip = matches!(url.host(), Some(url::Host::Ipv4(_) | url::Host::Ipv6(_))); + let cookie_list = PyIterator::from_object(&self.cookie_jar.bind_borrowed(py)).unwrap(); cookie_list @@ -123,7 +128,7 @@ impl CookieStore for PythonCookieJar { .and_then(|attr| attr.extract::()) .unwrap_or_default(); - if !domain_matches(url.host_str().unwrap_or_default(), &domain) { + if !domain_matches(host, host_is_ip, &domain) { return None; } if !url.path().starts_with(&path) { @@ -179,11 +184,14 @@ impl CookieStore for PythonCookieJar { /// following the domain matching rules of /// [RFC 6265, §5.1.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.1.3). /// +/// `host_is_ip` must be `true` when `host` is an IP-address literal; such hosts only +/// match an identical cookie domain (no subdomain matching), per §5.1.3. +/// /// Leading dot on the cookie domain (e.g. `.example.com`) is ignored, as /// permitted by [RFC 6265, §4.1.2.3](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3). /// /// An empty cookie domain imposes no host restriction and therefore matches any host. -fn domain_matches(host: &str, cookie_domain: &str) -> bool { +fn domain_matches(host: &str, host_is_ip: bool, cookie_domain: &str) -> bool { let cookie_domain = cookie_domain.strip_prefix('.').unwrap_or(cookie_domain); if cookie_domain.is_empty() { @@ -198,16 +206,23 @@ fn domain_matches(host: &str, cookie_domain: &str) -> bool { // RFC 6265 §5.1.3: suffix (subdomain) matching applies to host names only. // An IP-address host must match the cookie domain exactly (handled above), // otherwise e.g. cookie domain `0.0.1` would match host `127.0.0.1`. - if host.parse::().is_ok() { + if host_is_ip { return false; } // Subdomain match: the cookie domain must be a suffix of the host on a `.` - // label boundary. `host` (from `Url::host_str`) is already lowercase for - // http(s) URLs, so lowercase the cookie domain to keep the match case-insensitive. - let cookie_domain = cookie_domain.to_ascii_lowercase(); - host.strip_suffix(cookie_domain.as_str()) - .is_some_and(|prefix| prefix.ends_with('.')) + // label boundary (e.g. host `www.example.com`, cookie domain `example.com`). + // Compared case-insensitively without allocating, so it does not depend on the + // host already being lowercased. + let Some(prefix_len) = host + .len() + .checked_sub(cookie_domain.len()) + .filter(|&n| n > 0) + else { + return false; + }; + host.as_bytes()[prefix_len - 1] == b'.' + && host[prefix_len..].eq_ignore_ascii_case(cookie_domain) } impl PythonCookieJar { From 27042fc3421f5f40b433a8b25c397ee233e3c542 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 12:02:35 +0000 Subject: [PATCH 5/7] fix(python): detect IP cookie hosts without adding the url dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `url::Host` based IP-literal detection with a dependency-free check: strip IPv6 brackets and parse the host with `std::net::IpAddr`. This removes the `url` direct dependency while still classifying both IPv4 and bracketed IPv6 literals as IP hosts (which, per RFC 6265 §5.1.3, must match a cookie domain exactly rather than by suffix). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- Cargo.lock | 1 - impit-python/Cargo.toml | 1 - impit-python/src/cookies.rs | 11 ++++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8480b51..aecc7e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,7 +1376,6 @@ dependencies = [ "rustls", "tokio", "tokio-stream", - "url", "urlencoding", ] diff --git a/impit-python/Cargo.toml b/impit-python/Cargo.toml index 6a3d38d2..bf15c003 100644 --- a/impit-python/Cargo.toml +++ b/impit-python/Cargo.toml @@ -13,7 +13,6 @@ rustls = { version="0.23.36" } tokio = { version="1.41.1", features = ["full"] } h2 = "0.4.7" reqwest = "0.13.1" -url = "2.5.8" tokio-stream = "0.1.17" bytes = "1.9.0" pyo3 = { version = "0.29", features = ["extension-module", "auto-initialize", "either"] } diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index 61d3d675..b43a7d65 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -105,9 +105,14 @@ impl CookieStore for PythonCookieJar { fn cookies(&self, url: &Url) -> Option { Python::attach(|py| { let host = url.host_str().unwrap_or_default(); - // Detect IP-literal hosts via the typed `Host` enum so the check is robust to - // IPv6 bracket notation and casing (unlike string-parsing `host_str()`). - let host_is_ip = matches!(url.host(), Some(url::Host::Ipv4(_) | url::Host::Ipv6(_))); + // An IP-literal host (including bracketed IPv6 like `[::1]`) only matches a + // cookie domain exactly. Strip IPv6 brackets before parsing as an IP address. + let host_is_ip = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host) + .parse::() + .is_ok(); let cookie_list = PyIterator::from_object(&self.cookie_jar.bind_borrowed(py)).unwrap(); From 424d4878a04755be5ba28f1fcf869a44653f0077 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 12:38:10 +0000 Subject: [PATCH 6/7] fix(python): simplify cookie domain matching to the reviewed approach Handle the IP-host check inside `domain_matches` (drop the `host_is_ip` parameter and the IPv6 bracket special-casing) so the helper directly mirrors the two review suggestions on #488: - avoid the per-call `format!` allocation via `strip_suffix(..).is_some_and(..)` - match IP-address hosts exactly via `host.parse::()` Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- impit-python/src/cookies.rs | 49 +++++++++---------------------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index b43a7d65..11539763 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -104,16 +104,6 @@ impl CookieStore for PythonCookieJar { fn cookies(&self, url: &Url) -> Option { Python::attach(|py| { - let host = url.host_str().unwrap_or_default(); - // An IP-literal host (including bracketed IPv6 like `[::1]`) only matches a - // cookie domain exactly. Strip IPv6 brackets before parsing as an IP address. - let host_is_ip = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host) - .parse::() - .is_ok(); - let cookie_list = PyIterator::from_object(&self.cookie_jar.bind_borrowed(py)).unwrap(); cookie_list @@ -133,7 +123,7 @@ impl CookieStore for PythonCookieJar { .and_then(|attr| attr.extract::()) .unwrap_or_default(); - if !domain_matches(host, host_is_ip, &domain) { + if !domain_matches(url.host_str().unwrap_or_default(), &domain) { return None; } if !url.path().starts_with(&path) { @@ -189,45 +179,28 @@ impl CookieStore for PythonCookieJar { /// following the domain matching rules of /// [RFC 6265, §5.1.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.1.3). /// -/// `host_is_ip` must be `true` when `host` is an IP-address literal; such hosts only -/// match an identical cookie domain (no subdomain matching), per §5.1.3. -/// /// Leading dot on the cookie domain (e.g. `.example.com`) is ignored, as /// permitted by [RFC 6265, §4.1.2.3](https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3). /// /// An empty cookie domain imposes no host restriction and therefore matches any host. -fn domain_matches(host: &str, host_is_ip: bool, cookie_domain: &str) -> bool { +fn domain_matches(host: &str, cookie_domain: &str) -> bool { let cookie_domain = cookie_domain.strip_prefix('.').unwrap_or(cookie_domain); if cookie_domain.is_empty() { return true; } - // Exact match (host names are case-insensitive). - if host.eq_ignore_ascii_case(cookie_domain) { - return true; - } - - // RFC 6265 §5.1.3: suffix (subdomain) matching applies to host names only. - // An IP-address host must match the cookie domain exactly (handled above), - // otherwise e.g. cookie domain `0.0.1` would match host `127.0.0.1`. - if host_is_ip { - return false; + // RFC 6265 §5.1.3: an IP-address host only matches an identical cookie domain. + if host.parse::().is_ok() { + return host == cookie_domain; } - // Subdomain match: the cookie domain must be a suffix of the host on a `.` - // label boundary (e.g. host `www.example.com`, cookie domain `example.com`). - // Compared case-insensitively without allocating, so it does not depend on the - // host already being lowercased. - let Some(prefix_len) = host - .len() - .checked_sub(cookie_domain.len()) - .filter(|&n| n > 0) - else { - return false; - }; - host.as_bytes()[prefix_len - 1] == b'.' - && host[prefix_len..].eq_ignore_ascii_case(cookie_domain) + // Exact match, or subdomain match where the cookie domain is a suffix of the host + // on a `.` label boundary (e.g. host `www.example.com`, cookie domain `example.com`). + host == cookie_domain + || host + .strip_suffix(cookie_domain) + .is_some_and(|prefix| prefix.ends_with('.')) } impl PythonCookieJar { From 1db17a5f4b06ed110356dfa79fe5f8aa81fd4086 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 12:40:44 +0000 Subject: [PATCH 7/7] fix(python): lowercase host and cookie domain before matching Normalise both the request host and the cookie domain to ASCII lowercase inside `domain_matches`, so matching is case-insensitive regardless of the casing supplied by the caller or stored in the cookie jar rather than relying on external code to pass a lowercased host. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019euhvbh1gHZ99eSgeVNPSf --- impit-python/src/cookies.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/impit-python/src/cookies.rs b/impit-python/src/cookies.rs index 11539763..a7dbb079 100644 --- a/impit-python/src/cookies.rs +++ b/impit-python/src/cookies.rs @@ -190,6 +190,11 @@ fn domain_matches(host: &str, cookie_domain: &str) -> bool { return true; } + // Host names are case-insensitive; normalise both sides instead of trusting the + // caller to pass a lowercased host. + let host = host.to_ascii_lowercase(); + let cookie_domain = cookie_domain.to_ascii_lowercase(); + // RFC 6265 §5.1.3: an IP-address host only matches an identical cookie domain. if host.parse::().is_ok() { return host == cookie_domain; @@ -199,7 +204,7 @@ fn domain_matches(host: &str, cookie_domain: &str) -> bool { // on a `.` label boundary (e.g. host `www.example.com`, cookie domain `example.com`). host == cookie_domain || host - .strip_suffix(cookie_domain) + .strip_suffix(cookie_domain.as_str()) .is_some_and(|prefix| prefix.ends_with('.')) }