From 409c6d81c18f09547ab42217c8a1dec4b9d1e3d2 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sun, 1 Feb 2026 11:49:27 +0000 Subject: [PATCH 1/2] Add check for same-origin requests for unsafe methods --- tornado/test/web_test.py | 39 +++++++++++++++++++++++++++++++++++++++ tornado/web.py | 30 ++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index 27df3f7a8..0fd63a632 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -3130,6 +3130,45 @@ def test_xsrf_httponly(self): self.assertTrue(abs((expires - header_expires).total_seconds()) < 10) +class CheckSameOriginTest(SimpleHandlerTestCase): + class Handler(RequestHandler): + def post(self): + self.write("ok") + + def get_app_kwargs(self): + return dict(check_same_origin=True) + + def _post(self, headers): + return self.fetch("/", method="POST", body="x=1", headers=headers) + + def test_sec_fetch_site_success(self): + response = self._post({"Sec-Fetch-Site": "same-origin"}) + self.assertEqual(response.code, 200) + + def test_sec_fetch_site_fail(self): + with ExpectLog(gen_log, ".*Cross-origin request"): + response = self._post({"Sec-Fetch-Site": "cross-site"}) + self.assertEqual(response.code, 403) + + def test_fallback_success(self): + response = self._post({"Origin": self.get_url("")}) + self.assertEqual(response.code, 200) + + def test_fallback_referrer_success(self): + response = self._post({"Referrer": self.get_url("/foo/bar")}) + self.assertEqual(response.code, 200) + + def test_fallback_fail(self): + with ExpectLog(gen_log, ".*Cross-origin request"): + response = self._post({"Origin": "https://evil.example.com/"}) + self.assertEqual(response.code, 403) + + def test_fallback_no_origin(self): + with ExpectLog(gen_log, ".*No Origin/Referrer"): + response = self._post({}) + self.assertEqual(response.code, 403) + + class FinishExceptionTest(SimpleHandlerTestCase): class Handler(RequestHandler): def get(self): diff --git a/tornado/web.py b/tornado/web.py index 2351afdbe..17950bbfa 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -1690,6 +1690,25 @@ def xsrf_form_html(self) -> str: + '"/>' ) + def check_same_origin(self) -> None: + """Verify that non-safe methods come from a same-origin request""" + headers = self.request.headers + if (sfs := headers.get("Sec-Fetch-Site")) is not None: + # All major browsers send the Sec-Fetch-Site header since ~2023 + # for 'potentially trustworthy' URLs (roughly, HTTPS or localhost) + if sfs not in ('same-origin', 'none'): + raise HTTPError(403, "Cross-origin request with unsafe method") + + else: + # Fallback: The Origin or Referrer header gives the domain + # the request came from, Host should tell us where we're running. + src_origin = headers.get("Origin") or headers.get("Referrer") + if src_origin is None: + raise HTTPError(403, "No Origin/Referrer header with unsafe method") + src_scheme, src_netloc = urllib.parse.urlsplit(src_origin)[:2] + if src_scheme != self.request.protocol or src_netloc != self.request.host: + raise HTTPError(403, "Cross-origin request with unsafe method") + def static_url( self, path: str, include_host: Optional[bool] = None, **kwargs: Any ) -> str: @@ -1826,12 +1845,11 @@ async def _execute( } # If XSRF cookies are turned on, reject form submissions without # the proper cookie - if self.request.method not in ( - "GET", - "HEAD", - "OPTIONS", - ) and self.application.settings.get("xsrf_cookies"): - self.check_xsrf_cookie() + if self.request.method not in ("GET", "HEAD", "OPTIONS"): + if self.application.settings.get("xsrf_cookies"): + self.check_xsrf_cookie() + if self.application.settings.get("check_same_origin"): + self.check_same_origin() result = self.prepare() if result is not None: From 1bc38dedee3e67e1cee3a52dd8c64c5cc3a1da7a Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sun, 1 Feb 2026 12:56:42 +0000 Subject: [PATCH 2/2] Separate out fallback origin check, allow missing Origin/Referrer --- tornado/test/web_test.py | 7 +++---- tornado/web.py | 35 ++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index 0fd63a632..162a700cb 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -3136,7 +3136,7 @@ def post(self): self.write("ok") def get_app_kwargs(self): - return dict(check_same_origin=True) + return dict(check_fetch_header=True, check_origin=self.get_url("")) def _post(self, headers): return self.fetch("/", method="POST", body="x=1", headers=headers) @@ -3164,9 +3164,8 @@ def test_fallback_fail(self): self.assertEqual(response.code, 403) def test_fallback_no_origin(self): - with ExpectLog(gen_log, ".*No Origin/Referrer"): - response = self._post({}) - self.assertEqual(response.code, 403) + response = self._post({}) + self.assertEqual(response.code, 200) class FinishExceptionTest(SimpleHandlerTestCase): diff --git a/tornado/web.py b/tornado/web.py index 17950bbfa..7c70566de 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -1690,24 +1690,27 @@ def xsrf_form_html(self) -> str: + '"/>' ) - def check_same_origin(self) -> None: + def check_fetch_header(self) -> bool: """Verify that non-safe methods come from a same-origin request""" - headers = self.request.headers - if (sfs := headers.get("Sec-Fetch-Site")) is not None: + if (sfs := self.request.headers.get("Sec-Fetch-Site")) is not None: # All major browsers send the Sec-Fetch-Site header since ~2023 # for 'potentially trustworthy' URLs (roughly, HTTPS or localhost) - if sfs not in ('same-origin', 'none'): + if sfs not in ("same-origin", "none"): raise HTTPError(403, "Cross-origin request with unsafe method") + return True + return False - else: - # Fallback: The Origin or Referrer header gives the domain - # the request came from, Host should tell us where we're running. - src_origin = headers.get("Origin") or headers.get("Referrer") - if src_origin is None: - raise HTTPError(403, "No Origin/Referrer header with unsafe method") - src_scheme, src_netloc = urllib.parse.urlsplit(src_origin)[:2] - if src_scheme != self.request.protocol or src_netloc != self.request.host: - raise HTTPError(403, "Cross-origin request with unsafe method") + def check_request_origin(self) -> None: + # Fallback: The Origin or Referrer header gives the domain + # the request came from, Host should tell us where we're running. + headers = self.request.headers + src_origin = headers.get("Origin") or headers.get("Referrer") + if src_origin is None: + return # Probably non-browser request + src_scheme, src_netloc = urllib.parse.urlsplit(src_origin)[:2] + target_origin = self.application.settings["check_origin"] + if f"{src_scheme}://{src_netloc}" != target_origin: + raise HTTPError(403, "Cross-origin request with unsafe method") def static_url( self, path: str, include_host: Optional[bool] = None, **kwargs: Any @@ -1848,8 +1851,10 @@ async def _execute( if self.request.method not in ("GET", "HEAD", "OPTIONS"): if self.application.settings.get("xsrf_cookies"): self.check_xsrf_cookie() - if self.application.settings.get("check_same_origin"): - self.check_same_origin() + if self.application.settings.get("check_fetch_header"): + checked = self.check_fetch_header() + if not checked and self.application.settings.get("check_origin"): + self.check_request_origin() result = self.prepare() if result is not None: