From ab1bfe6bf71cc804b8c33773f57b3cd688e3a697 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sun, 1 Feb 2026 11:49:27 +0000 Subject: [PATCH 1/4] 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 36049e26b..0e5f02d3a 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -3195,6 +3195,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 39a060f68..22b6471c3 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -1692,6 +1692,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: bool | None = None, **kwargs: Any ) -> str: @@ -1828,12 +1847,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 39766f5e5de842d9075be7749b68045e21c81971 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sun, 1 Feb 2026 12:56:42 +0000 Subject: [PATCH 2/4] 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 0e5f02d3a..a081e4ba2 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -3201,7 +3201,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) @@ -3229,9 +3229,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 22b6471c3..9504fe124 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -1692,24 +1692,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: bool | None = None, **kwargs: Any @@ -1850,8 +1853,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: From e7306e6c0e9c33b3128134bcd9edb7a892e986bb Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Wed, 6 May 2026 09:01:49 +0100 Subject: [PATCH 3/4] Rearrange origin checks --- tornado/test/web_test.py | 2 +- tornado/web.py | 52 ++++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index a081e4ba2..fa4ed119a 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -3201,7 +3201,7 @@ def post(self): self.write("ok") def get_app_kwargs(self): - return dict(check_fetch_header=True, check_origin=self.get_url("")) + return dict(check_allowed_origin=True) def _post(self, headers): return self.fetch("/", method="POST", body="x=1", headers=headers) diff --git a/tornado/web.py b/tornado/web.py index 9504fe124..ff3d357ff 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -1692,27 +1692,39 @@ def xsrf_form_html(self) -> str: + '"/>' ) - def check_fetch_header(self) -> bool: - """Verify that non-safe methods come from a same-origin request""" + def check_allowed_origin(self) -> None: + """Check if a request should be rejected as cross-origin. + + If the setting check_allowed_origin is True, this is called for non-safe + HTTP requests (i.e. not GET, HEAD or OPTIONS). It raises HTTPError 403 + to reject a request. + """ + origin = self.request.headers.get("Origin") + if origin is not None: + origin = origin.lower() + if origin in self.application.settings.get("allowed_origins", ()): + return # Origin in explicit allowlist + 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"): - raise HTTPError(403, "Cross-origin request with unsafe method") - return True - return False + if sfs in ("same-origin", "none"): + return # OK according to Sec-Fetch-Site + raise HTTPError( + 403, + f"Cross-origin request with unsafe method (Sec-Fetch-Site: {sfs})", + ) + + if origin is None: # Sec-Fetch-Site must also be missing to reach here + return # Probably a non-browser request - 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") + host = self.request.headers.get("Host") + if urllib.parse.urlsplit(origin).netloc != host: + raise HTTPError( + 403, + f"Cross-origin request with unsafe method (Origin {origin!r} does not " + f"match Host {host!r} or allowed_origins)", + ) def static_url( self, path: str, include_host: bool | None = None, **kwargs: Any @@ -1853,10 +1865,8 @@ 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_fetch_header"): - checked = self.check_fetch_header() - if not checked and self.application.settings.get("check_origin"): - self.check_request_origin() + if self.application.settings.get("check_allowed_origin"): + self.check_allowed_origin() result = self.prepare() if result is not None: From d9cdc65a8ff0d6d2ff0562e4bb6041167953dbc0 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Wed, 6 May 2026 10:49:04 +0100 Subject: [PATCH 4/4] Add check_allowed_origin method to docs --- docs/web.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/web.rst b/docs/web.rst index 00066ccdf..3b8153b30 100644 --- a/docs/web.rst +++ b/docs/web.rst @@ -151,6 +151,7 @@ The `Application` object serving this request + .. automethod:: RequestHandler.check_allowed_origin .. automethod:: RequestHandler.check_etag_header .. automethod:: RequestHandler.check_xsrf_cookie .. automethod:: RequestHandler.compute_etag