Skip to content

Commit 5cb5cde

Browse files
committed
Fix gateway_client cookie handling and test
1 parent 27cd252 commit 5cb5cde

File tree

2 files changed

+66
-43
lines changed

2 files changed

+66
-43
lines changed

jupyter_server/gateway/gateway_client.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
import typing as ty
1212
from abc import ABC, ABCMeta, abstractmethod
1313
from datetime import datetime, timezone
14-
from email.utils import parsedate_to_datetime
15-
from http.cookies import SimpleCookie
14+
from http.cookiejar import http2time
15+
from http.cookies import SimpleCookie, Morsel
1616
from socket import gaierror
1717

1818
from jupyter_events import EventLogger
1919
from tornado import web
2020
from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse
21+
from tornado.httputil import HTTPHeaders as Headers
2122
from traitlets import (
2223
Bool,
2324
Float,
@@ -630,26 +631,41 @@ def load_connection_args(self, **kwargs):
630631

631632
return kwargs
632633

633-
def update_cookies(self, headers: dict) -> None:
634-
"""Update cookies from response headers for load balancers"""
634+
def update_cookies(self, headers: Headers) -> None:
635+
"""Update cookies from response headers"""
636+
635637
if not self.accept_cookies:
636638
return
637639

638-
# Create a SimpleCookie from the Set-Cookie header
639-
cookie = SimpleCookie()
640-
set_cookie = headers.get('Set-Cookie')
641-
if set_cookie:
642-
cookie.load(set_cookie)
640+
# Get individual Set-Cookie headers in list form. This handles multiple cookies
641+
# that are otherwise comma-separated in the header and will break the parsing logic
642+
# if only headers.get() is used.
643+
cookie_headers = headers.get_list('Set-Cookie')
644+
if not cookie_headers:
645+
return
643646

644647
store_time = datetime.now(tz=timezone.utc)
645-
for key, item in cookie.items():
648+
for header in cookie_headers:
649+
cookie = SimpleCookie()
650+
try:
651+
cookie.load(header)
652+
except Exception as e:
653+
self.log.warning("Failed to parse cookie header %s: %s", header, e)
654+
continue
655+
656+
if not cookie:
657+
self.log.warning("No cookies found in header: %s", header)
658+
continue
659+
name, morsel = next(iter(cookie.items()))
660+
646661
# Convert "expires" arg into "max-age" to facilitate expiration management.
647662
# As "max-age" has precedence, ignore "expires" when "max-age" exists.
648-
if item.get("expires") and not item.get("max-age"):
649-
expire_timedelta = parsedate_to_datetime(item["expires"]) - store_time
650-
item["max-age"] = str(expire_timedelta.total_seconds())
663+
if morsel.get("expires") and not morsel.get("max-age"):
664+
expire_time = datetime.fromtimestamp(http2time(morsel["expires"]), tz=timezone.utc)
665+
expire_timedelta = expire_time - store_time
666+
morsel["max-age"] = str(expire_timedelta.total_seconds())
651667

652-
self._cookies[key] = (item, store_time)
668+
self._cookies[name] = (morsel, store_time)
653669

654670
def _clear_expired_cookies(self) -> None:
655671
"""Clear expired cookies."""
@@ -826,7 +842,7 @@ async def gateway_request(endpoint: str, **kwargs: ty.Any) -> HTTPResponse:
826842
)
827843
raise e
828844

845+
if gateway_client.accept_cookies:
846+
gateway_client.update_cookies(response.headers)
829847

830-
gateway_client.update_cookies(response.headers)
831-
832-
return response
848+
return response

tests/test_gateway.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import os
77
import uuid
88
from datetime import datetime, timedelta, timezone
9-
from email.utils import format_datetime
10-
from http.cookies import SimpleCookie
119
from io import BytesIO
1210
from queue import Empty
1311
from typing import Any, Union
@@ -18,7 +16,7 @@
1816
from jupyter_core.utils import ensure_async
1917
from tornado.concurrent import Future
2018
from tornado.httpclient import HTTPRequest, HTTPResponse
21-
from tornado.httputil import HTTPServerRequest
19+
from tornado.httputil import HTTPServerRequest, HTTPHeaders
2220
from tornado.queues import Queue
2321
from tornado.web import HTTPError
2422
from traitlets import Int, Unicode
@@ -374,14 +372,13 @@ def test_gateway_request_timeout_pad_option(
374372

375373
GatewayClient.clear_instance()
376374

377-
378375
@pytest.mark.parametrize(
379-
"accept_cookies,expire_arg,expire_param,existing_cookies,cookie_exists",
376+
"accept_cookies,expire_arg,expire_param,existing_cookies",
380377
[
381-
(False, None, None, "EXISTING=1", False),
382-
(True, None, None, "EXISTING=1", True),
383-
(True, "Expires", 180, None, True),
384-
(True, "Max-Age", "-360", "EXISTING=1", False),
378+
(False, None, 0, "EXISTING=1"),
379+
(True, None, 0, "EXISTING=1"),
380+
(True, "expires", 180, None),
381+
(True, "Max-Age", -360, "EXISTING=1"),
385382
],
386383
)
387384
def test_gateway_request_with_expiring_cookies(
@@ -390,39 +387,49 @@ def test_gateway_request_with_expiring_cookies(
390387
expire_arg,
391388
expire_param,
392389
existing_cookies,
393-
cookie_exists,
394390
):
395391
argv = [f"--GatewayClient.accept_cookies={accept_cookies}"]
396392

397393
GatewayClient.clear_instance()
398394
_ = jp_configurable_serverapp(argv=argv)
399395

400-
headers = {}
401-
# Use comma-separated Set-Cookie headers to demonstrate the issue
402-
headers["Set-Cookie"] = "SERVERID=016723f5|12345678; Max-Age=172800; Path=/; HttpOnly," \
403-
"username-my-server=2|1:0|10:1756250589|35:username-my-server|144:123abc789|87654321; " \
404-
"Max-Age=172800; Path=/; HttpOnly"
405-
if expire_arg == "Expires":
406-
expire_param = format_datetime(
407-
datetime.now(tz=timezone.utc) + timedelta(seconds=expire_param)
408-
)
409-
if expire_arg:
410-
# Replace the first SERVERID cookie's Max-Age with the new expire parameter
411-
headers["Cookie"] = headers["Set-Cookie"].replace("Max-Age=172800", f"{expire_arg}={expire_param}")
396+
test_expiration = bool(expire_param < 0)
397+
# Create mock headers with Set-Cookie values
398+
headers = HTTPHeaders()
399+
cookie_value = "SERVERID=1234567; Path=/; HttpOnly"
400+
if expire_arg == "expires":
401+
# Convert expire_param to a string in the format of "Expires: <date>" (RFC 7231)
402+
expire_param = (datetime.now(tz=timezone.utc) + timedelta(seconds=expire_param))\
403+
.strftime("%a, %d %b %Y %H:%M:%S GMT")
404+
cookie_value = f"SERVERID=1234567; Path=/; expires={expire_param}; HttpOnly"
405+
elif expire_arg == "Max-Age":
406+
cookie_value = f"SERVERID=1234567; Path=/; Max-Age={expire_param}; HttpOnly"
407+
408+
# Add a second cookie to test comma-separated cookies
409+
headers.add("Set-Cookie", cookie_value)
410+
headers.add("Set-Cookie", "ADDITIONAL_COOKIE=8901234; Path=/; HttpOnly")
411+
412+
headers_list = headers.get_list("Set-Cookie")
413+
print(headers_list)
412414

413415
GatewayClient.instance().update_cookies(headers)
414416

415417
args = {}
416418
if existing_cookies:
417419
args["headers"] = {"Cookie": existing_cookies}
420+
418421
connection_args = GatewayClient.instance().load_connection_args(**args)
419422

420-
if not cookie_exists:
421-
assert "SERVERID" not in (connection_args["headers"].get("Cookie") or "")
423+
if not accept_cookies or test_expiration:
424+
# The first condition ensure the response cookie is not accepted,
425+
# the second condition ensures that the cookie is not accepted if it is expired.
426+
assert "SERVERID" not in connection_args["headers"].get("Cookie")
422427
else:
423-
assert "SERVERID" in connection_args["headers"].get("Cookie")
428+
# The cookie is accepted if it is not expired and accept_cookies is True.
429+
assert "SERVERID" in connection_args["headers"].get("Cookie", "")
430+
424431
if existing_cookies:
425-
assert "EXISTING" in connection_args["headers"].get("Cookie")
432+
assert "EXISTING" in connection_args["headers"].get("Cookie", "")
426433

427434
GatewayClient.clear_instance()
428435

0 commit comments

Comments
 (0)