diff --git a/HISTORY.md b/HISTORY.md index 5f1f27690f..e7f4c92d04 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,17 @@ Release History =============== +3.6.5 (2024-05-??) +------------------ + +**Fixed** +- Support `localhost` as a valid domain for cookies. The standard library does not allow this special + domain. Researches showed that a valid domain should have at least two dots (e.g. abc.com. and xyz.tld. but not com.). + Public suffixes cannot be used as a cookie domain for security reasons, but as `localhost` isn't one we are explicitly + allowing it. Reported in https://github.com/httpie/cli/issues/602 + `RequestsCookieJar` set a default policy that circumvent that limitation, if you specified a custom cookie policy then this + fix won't be applied. + 3.6.4 (2024-05-16) ------------------ diff --git a/src/niquests/__version__.py b/src/niquests/__version__.py index 12078d1119..e5be4b0dce 100644 --- a/src/niquests/__version__.py +++ b/src/niquests/__version__.py @@ -9,9 +9,9 @@ __url__: str = "https://niquests.readthedocs.io" __version__: str -__version__ = "3.6.4" +__version__ = "3.6.5" -__build__: int = 0x030604 +__build__: int = 0x030605 __author__: str = "Kenneth Reitz" __author_email__: str = "me@kennethreitz.org" __license__: str = "Apache-2.0" diff --git a/src/niquests/cookies.py b/src/niquests/cookies.py index a5deaa194f..9f24195169 100644 --- a/src/niquests/cookies.py +++ b/src/niquests/cookies.py @@ -32,6 +32,21 @@ from .models import PreparedRequest, Request +class CookiePolicyLocalhostBypass(cookielib.DefaultCookiePolicy): + """A subclass of DefaultCookiePolicy to allow cookie set for domain=localhost. + Credit goes to https://github.com/Pylons/webtest/blob/main/webtest/app.py#L60""" + + def return_ok_domain(self, cookie, request): + if cookie.domain == ".localhost": + return True + return cookielib.DefaultCookiePolicy.return_ok_domain(self, cookie, request) + + def set_ok_domain(self, cookie, request): + if cookie.domain == ".localhost": + return True + return cookielib.DefaultCookiePolicy.set_ok_domain(self, cookie, request) + + class MockRequest: """Wraps a `requests.Request` to mimic a `urllib2.Request`. @@ -218,6 +233,9 @@ class RequestsCookieJar(cookielib.CookieJar, MutableMapping): .. warning:: dictionary operations that are normally O(1) may be O(n). """ + def __init__(self, policy: cookielib.CookiePolicy | None = None): + super().__init__(policy=policy or CookiePolicyLocalhostBypass()) + def get(self, name, default=None, domain=None, path=None): """Dict-like get() that also supports optional domain and path args in order to resolve naming collisions from using one cookie jar over diff --git a/tests/conftest.py b/tests/conftest.py index 24ca249d67..a3189c0f39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,22 +34,37 @@ def httpbin_secure(httpbin_secure): return prepare_url(httpbin_secure) +class LocalhostCookieTestServer(SimpleHTTPRequestHandler): + def do_GET(self): + spot = self.headers.get("Cookie", None) + + self.send_response(204) + self.send_header("Content-Length", "0") + + if spot is None: + self.send_header("Set-Cookie", "hello=world; Domain=localhost; Max-Age=120") + else: + self.send_header("X-Cookie-Pass", "1" if "hello=world" in spot else "0") + + self.end_headers() + + @pytest.fixture -def nosan_server(tmp_path_factory): +def san_server(tmp_path_factory): # delay importing until the fixture in order to make it possible # to deselect the test via command-line when trustme is not available import trustme tmpdir = tmp_path_factory.mktemp("certs") ca = trustme.CA() - # only commonName, no subjectAltName - server_cert = ca.issue_cert(common_name="localhost") + + server_cert = ca.issue_cert("localhost", common_name="localhost") ca_bundle = str(tmpdir / "ca.pem") ca.cert_pem.write_to_path(ca_bundle) context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) server_cert.configure_cert(context) - server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler) + server = HTTPServer(("localhost", 0), LocalhostCookieTestServer) server.socket = context.wrap_socket(server.socket, server_side=True) server_thread = threading.Thread(target=server.serve_forever) server_thread.start() diff --git a/tests/test_requests.py b/tests/test_requests.py index 62e1ed79c3..fcab4202dc 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1355,6 +1355,21 @@ class MyCookiePolicy(cookielib.DefaultCookiePolicy): jar.set_policy(MyCookiePolicy()) assert isinstance(jar.copy().get_policy(), MyCookiePolicy) + def test_cookie_allow_localhost_default(self, san_server): + server, port, ca_bundle = san_server + + s = niquests.Session() + + r = s.get(f"https://localhost:{port}/", verify=ca_bundle) + + assert r.cookies + assert r.cookies["hello"] == "world" + + r = s.get(f"https://localhost:{port}/", verify=ca_bundle) + + assert "x-cookie-pass" in r.headers + assert r.headers["x-cookie-pass"] == "1" + def test_time_elapsed_blank(self, httpbin): r = niquests.get(httpbin("get")) td = r.elapsed