Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/tests/web/server_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
38 changes: 28 additions & 10 deletions src/web/server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import asyncio
import http.cookies
import json
import logging.config
import os
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading