Skip to content

Commit 409c6d8

Browse files
committed
Add check for same-origin requests for unsafe methods
1 parent 3dfc965 commit 409c6d8

File tree

2 files changed

+63
-6
lines changed

2 files changed

+63
-6
lines changed

tornado/test/web_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3130,6 +3130,45 @@ def test_xsrf_httponly(self):
31303130
self.assertTrue(abs((expires - header_expires).total_seconds()) < 10)
31313131

31323132

3133+
class CheckSameOriginTest(SimpleHandlerTestCase):
3134+
class Handler(RequestHandler):
3135+
def post(self):
3136+
self.write("ok")
3137+
3138+
def get_app_kwargs(self):
3139+
return dict(check_same_origin=True)
3140+
3141+
def _post(self, headers):
3142+
return self.fetch("/", method="POST", body="x=1", headers=headers)
3143+
3144+
def test_sec_fetch_site_success(self):
3145+
response = self._post({"Sec-Fetch-Site": "same-origin"})
3146+
self.assertEqual(response.code, 200)
3147+
3148+
def test_sec_fetch_site_fail(self):
3149+
with ExpectLog(gen_log, ".*Cross-origin request"):
3150+
response = self._post({"Sec-Fetch-Site": "cross-site"})
3151+
self.assertEqual(response.code, 403)
3152+
3153+
def test_fallback_success(self):
3154+
response = self._post({"Origin": self.get_url("")})
3155+
self.assertEqual(response.code, 200)
3156+
3157+
def test_fallback_referrer_success(self):
3158+
response = self._post({"Referrer": self.get_url("/foo/bar")})
3159+
self.assertEqual(response.code, 200)
3160+
3161+
def test_fallback_fail(self):
3162+
with ExpectLog(gen_log, ".*Cross-origin request"):
3163+
response = self._post({"Origin": "https://evil.example.com/"})
3164+
self.assertEqual(response.code, 403)
3165+
3166+
def test_fallback_no_origin(self):
3167+
with ExpectLog(gen_log, ".*No Origin/Referrer"):
3168+
response = self._post({})
3169+
self.assertEqual(response.code, 403)
3170+
3171+
31333172
class FinishExceptionTest(SimpleHandlerTestCase):
31343173
class Handler(RequestHandler):
31353174
def get(self):

tornado/web.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,6 +1690,25 @@ def xsrf_form_html(self) -> str:
16901690
+ '"/>'
16911691
)
16921692

1693+
def check_same_origin(self) -> None:
1694+
"""Verify that non-safe methods come from a same-origin request"""
1695+
headers = self.request.headers
1696+
if (sfs := headers.get("Sec-Fetch-Site")) is not None:
1697+
# All major browsers send the Sec-Fetch-Site header since ~2023
1698+
# for 'potentially trustworthy' URLs (roughly, HTTPS or localhost)
1699+
if sfs not in ('same-origin', 'none'):
1700+
raise HTTPError(403, "Cross-origin request with unsafe method")
1701+
1702+
else:
1703+
# Fallback: The Origin or Referrer header gives the domain
1704+
# the request came from, Host should tell us where we're running.
1705+
src_origin = headers.get("Origin") or headers.get("Referrer")
1706+
if src_origin is None:
1707+
raise HTTPError(403, "No Origin/Referrer header with unsafe method")
1708+
src_scheme, src_netloc = urllib.parse.urlsplit(src_origin)[:2]
1709+
if src_scheme != self.request.protocol or src_netloc != self.request.host:
1710+
raise HTTPError(403, "Cross-origin request with unsafe method")
1711+
16931712
def static_url(
16941713
self, path: str, include_host: Optional[bool] = None, **kwargs: Any
16951714
) -> str:
@@ -1826,12 +1845,11 @@ async def _execute(
18261845
}
18271846
# If XSRF cookies are turned on, reject form submissions without
18281847
# the proper cookie
1829-
if self.request.method not in (
1830-
"GET",
1831-
"HEAD",
1832-
"OPTIONS",
1833-
) and self.application.settings.get("xsrf_cookies"):
1834-
self.check_xsrf_cookie()
1848+
if self.request.method not in ("GET", "HEAD", "OPTIONS"):
1849+
if self.application.settings.get("xsrf_cookies"):
1850+
self.check_xsrf_cookie()
1851+
if self.application.settings.get("check_same_origin"):
1852+
self.check_same_origin()
18351853

18361854
result = self.prepare()
18371855
if result is not None:

0 commit comments

Comments
 (0)