Skip to content

Commit 5fcfe0c

Browse files
authored
🐛 Support localhost as a valid domain for cookies (#123)
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 httpie/cli#602 `RequestsCookieJar` set a default policy that circumvent that limitation, if you specified a custom cookie policy then this fix won't be applied.
1 parent 2bcae83 commit 5fcfe0c

File tree

5 files changed

+65
-6
lines changed

5 files changed

+65
-6
lines changed

HISTORY.md

+11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
Release History
22
===============
33

4+
3.6.5 (2024-05-??)
5+
------------------
6+
7+
**Fixed**
8+
- Support `localhost` as a valid domain for cookies. The standard library does not allow this special
9+
domain. Researches showed that a valid domain should have at least two dots (e.g. abc.com. and xyz.tld. but not com.).
10+
Public suffixes cannot be used as a cookie domain for security reasons, but as `localhost` isn't one we are explicitly
11+
allowing it. Reported in https://github.com/httpie/cli/issues/602
12+
`RequestsCookieJar` set a default policy that circumvent that limitation, if you specified a custom cookie policy then this
13+
fix won't be applied.
14+
415
3.6.4 (2024-05-16)
516
------------------
617

src/niquests/__version__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
__url__: str = "https://niquests.readthedocs.io"
1010

1111
__version__: str
12-
__version__ = "3.6.4"
12+
__version__ = "3.6.5"
1313

14-
__build__: int = 0x030604
14+
__build__: int = 0x030605
1515
__author__: str = "Kenneth Reitz"
1616
__author_email__: str = "me@kennethreitz.org"
1717
__license__: str = "Apache-2.0"

src/niquests/cookies.py

+18
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
from .models import PreparedRequest, Request
3333

3434

35+
class CookiePolicyLocalhostBypass(cookielib.DefaultCookiePolicy):
36+
"""A subclass of DefaultCookiePolicy to allow cookie set for domain=localhost.
37+
Credit goes to https://github.com/Pylons/webtest/blob/main/webtest/app.py#L60"""
38+
39+
def return_ok_domain(self, cookie, request):
40+
if cookie.domain == ".localhost":
41+
return True
42+
return cookielib.DefaultCookiePolicy.return_ok_domain(self, cookie, request)
43+
44+
def set_ok_domain(self, cookie, request):
45+
if cookie.domain == ".localhost":
46+
return True
47+
return cookielib.DefaultCookiePolicy.set_ok_domain(self, cookie, request)
48+
49+
3550
class MockRequest:
3651
"""Wraps a `requests.Request` to mimic a `urllib2.Request`.
3752
@@ -218,6 +233,9 @@ class RequestsCookieJar(cookielib.CookieJar, MutableMapping):
218233
.. warning:: dictionary operations that are normally O(1) may be O(n).
219234
"""
220235

236+
def __init__(self, policy: cookielib.CookiePolicy | None = None):
237+
super().__init__(policy=policy or CookiePolicyLocalhostBypass())
238+
221239
def get(self, name, default=None, domain=None, path=None):
222240
"""Dict-like get() that also supports optional domain and path args in
223241
order to resolve naming collisions from using one cookie jar over

tests/conftest.py

+19-4
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,37 @@ def httpbin_secure(httpbin_secure):
3434
return prepare_url(httpbin_secure)
3535

3636

37+
class LocalhostCookieTestServer(SimpleHTTPRequestHandler):
38+
def do_GET(self):
39+
spot = self.headers.get("Cookie", None)
40+
41+
self.send_response(204)
42+
self.send_header("Content-Length", "0")
43+
44+
if spot is None:
45+
self.send_header("Set-Cookie", "hello=world; Domain=localhost; Max-Age=120")
46+
else:
47+
self.send_header("X-Cookie-Pass", "1" if "hello=world" in spot else "0")
48+
49+
self.end_headers()
50+
51+
3752
@pytest.fixture
38-
def nosan_server(tmp_path_factory):
53+
def san_server(tmp_path_factory):
3954
# delay importing until the fixture in order to make it possible
4055
# to deselect the test via command-line when trustme is not available
4156
import trustme
4257

4358
tmpdir = tmp_path_factory.mktemp("certs")
4459
ca = trustme.CA()
45-
# only commonName, no subjectAltName
46-
server_cert = ca.issue_cert(common_name="localhost")
60+
61+
server_cert = ca.issue_cert("localhost", common_name="localhost")
4762
ca_bundle = str(tmpdir / "ca.pem")
4863
ca.cert_pem.write_to_path(ca_bundle)
4964

5065
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
5166
server_cert.configure_cert(context)
52-
server = HTTPServer(("localhost", 0), SimpleHTTPRequestHandler)
67+
server = HTTPServer(("localhost", 0), LocalhostCookieTestServer)
5368
server.socket = context.wrap_socket(server.socket, server_side=True)
5469
server_thread = threading.Thread(target=server.serve_forever)
5570
server_thread.start()

tests/test_requests.py

+15
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,21 @@ class MyCookiePolicy(cookielib.DefaultCookiePolicy):
13551355
jar.set_policy(MyCookiePolicy())
13561356
assert isinstance(jar.copy().get_policy(), MyCookiePolicy)
13571357

1358+
def test_cookie_allow_localhost_default(self, san_server):
1359+
server, port, ca_bundle = san_server
1360+
1361+
s = niquests.Session()
1362+
1363+
r = s.get(f"https://localhost:{port}/", verify=ca_bundle)
1364+
1365+
assert r.cookies
1366+
assert r.cookies["hello"] == "world"
1367+
1368+
r = s.get(f"https://localhost:{port}/", verify=ca_bundle)
1369+
1370+
assert "x-cookie-pass" in r.headers
1371+
assert r.headers["x-cookie-pass"] == "1"
1372+
13581373
def test_time_elapsed_blank(self, httpbin):
13591374
r = niquests.get(httpbin("get"))
13601375
td = r.elapsed

0 commit comments

Comments
 (0)