Skip to content

Commit

Permalink
🐛 Support localhost as a valid domain for cookies (#123)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Ousret authored May 21, 2024
1 parent 2bcae83 commit 5fcfe0c
Show file tree
Hide file tree
Showing 5 changed files with 65 additions and 6 deletions.
11 changes: 11 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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)
------------------

Expand Down
4 changes: 2 additions & 2 deletions src/niquests/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"
__license__: str = "Apache-2.0"
Expand Down
18 changes: 18 additions & 0 deletions src/niquests/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions tests/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 5fcfe0c

Please sign in to comment.