From 3050e636b52bbb5cfde7fbad22c440ed9bc60c32 Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 25 Jun 2026 10:27:47 -0400 Subject: [PATCH] fix(auth): don't set SameSite cookie attr on Python < 3.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Python 3.6/3.7 the XSRF cookie's 'samesite' attribute raises http.cookies.CookieError ("Invalid attribute 'samesite'") — samesite was only added to http.cookies.Morsel in Python 3.8 — which 500s the login page. Only include samesite='Lax' in xsrf_cookie_kwargs when the interpreter supports it (detected via http.cookies.Morsel._reserved). On older Python the cookie is set without SameSite (the double-submit XSRF token protection still applies); on 3.8+ behaviour is unchanged. Extracted as build_xsrf_cookie_kwargs() with unit tests for both branches. Note: the project's documented minimum is Python 3.9+, but this avoids a hard 500 on older interpreters. Co-Authored-By: Claude Opus 4.8 --- src/tests/web/server_test.py | 26 ++++++++++++++++++++++++ src/web/server.py | 38 ++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/tests/web/server_test.py b/src/tests/web/server_test.py index 5fd20c08..4574e391 100644 --- a/src/tests/web/server_test.py +++ b/src/tests/web/server_test.py @@ -431,3 +431,29 @@ def kill_ioloop(self, io_loop): self.ioloop_thread.join(timeout=50) io_loop.close() asyncio.set_event_loop(None) + + +class BuildXsrfCookieKwargsTest(TestCase): + def test_includes_samesite_when_supported(self): + kwargs = server.build_xsrf_cookie_kwargs(True, samesite_supported=True) + self.assertEqual('Lax', kwargs.get('samesite')) + self.assertFalse(kwargs['httponly']) + self.assertTrue(kwargs['secure']) + + def test_omits_samesite_when_unsupported(self): + # Reproduces Python < 3.8 (e.g. 3.6): setting samesite would raise + # http.cookies.CookieError and 500 the login page. + kwargs = server.build_xsrf_cookie_kwargs(True, samesite_supported=False) + self.assertNotIn('samesite', kwargs) + self.assertFalse(kwargs['httponly']) + self.assertTrue(kwargs['secure']) + + def test_passes_cookie_secure_through(self): + self.assertFalse(server.build_xsrf_cookie_kwargs(False, samesite_supported=False)['secure']) + self.assertTrue(server.build_xsrf_cookie_kwargs(True, samesite_supported=False)['secure']) + + def test_default_detection_matches_interpreter(self): + import http.cookies + expected = 'samesite' in http.cookies.Morsel._reserved + kwargs = server.build_xsrf_cookie_kwargs(True) + self.assertEqual(expected, 'samesite' in kwargs) diff --git a/src/web/server.py b/src/web/server.py index e338cfa1..1e4ba34a 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import asyncio +import http.cookies import json import logging.config import os @@ -834,6 +835,32 @@ def signal_handler(signum, frame): _http_server = None +def build_xsrf_cookie_kwargs(cookie_secure, samesite_supported=None): + # The SameSite cookie attribute is only understood by http.cookies (and thus + # Tornado's set_cookie) on Python 3.8+. On older interpreters setting it + # raises http.cookies.CookieError ("Invalid attribute 'samesite'"), which + # 500s every response that sets the XSRF cookie — including the login page. + # Only add it where supported; the double-submit XSRF token protection + # applies regardless of SameSite. + if samesite_supported is None: + # http.cookies.Morsel._reserved lists the attributes the interpreter + # accepts; 'samesite' was added in Python 3.8. + samesite_supported = 'samesite' in http.cookies.Morsel._reserved + + kwargs = { + # The XSRF cookie is a double-submit CSRF token, not a secret: in token + # mode (the default) the browser JS must read it and echo it back in the + # X-XSRFToken header. It therefore must NOT be httponly, otherwise every + # POST (e.g. starting an execution) is rejected with 403 "_xsrf argument + # missing". + 'httponly': False, + 'secure': cookie_secure, + } + if samesite_supported: + kwargs['samesite'] = 'Lax' + return kwargs + + def init(server_config: ServerConfig, authenticator, authorizer, @@ -902,16 +929,7 @@ def init(server_config: ServerConfig, 'websocket_ping_timeout': 300, 'compress_response': True, 'xsrf_cookies': server_config.xsrf_protection != XSRF_PROTECTION_DISABLED, - 'xsrf_cookie_kwargs': { - # The XSRF cookie is a double-submit CSRF token, not a secret: in - # token mode (the default) the browser JS must read it and echo it - # back in the X-XSRFToken header. It therefore must NOT be httponly, - # otherwise every POST (e.g. starting an execution) is rejected with - # 403 "_xsrf argument missing". - 'httponly': False, - 'secure': server_config.cookie_secure, - 'samesite': 'Lax' - }, + 'xsrf_cookie_kwargs': build_xsrf_cookie_kwargs(server_config.cookie_secure), } application = tornado.web.Application(handlers, **settings)