diff --git a/demos/blog/blog.py b/demos/blog/blog.py index bd0c5b3f18..2a0a4ed9d6 100755 --- a/demos/blog/blog.py +++ b/demos/blog/blog.py @@ -68,7 +68,6 @@ def __init__(self, db): template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), ui_modules={"Entry": EntryModule}, - xsrf_cookies=True, cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", login_url="/auth/login", debug=True, @@ -242,7 +241,7 @@ async def post(self): self.get_argument("name"), tornado.escape.to_unicode(hashed_password), ) - self.set_signed_cookie("blogdemo_user", str(author.id)) + self.set_signed_cookie("blogdemo_user", str(author.id), samesite="lax") self.redirect(self.get_argument("next", "/")) @@ -269,7 +268,7 @@ async def post(self): tornado.escape.utf8(author.hashed_password), ) if password_equal: - self.set_signed_cookie("blogdemo_user", str(author.id)) + self.set_signed_cookie("blogdemo_user", str(author.id), samesite="lax") self.redirect(self.get_argument("next", "/")) else: self.render("login.html", error="incorrect password") diff --git a/demos/blog/templates/compose.html b/demos/blog/templates/compose.html index fb8a462315..05cb58dcc3 100644 --- a/demos/blog/templates/compose.html +++ b/demos/blog/templates/compose.html @@ -12,7 +12,6 @@ {% if entry %} {% end %} - {% module xsrf_form_html() %} {% end %} diff --git a/demos/blog/templates/create_author.html b/demos/blog/templates/create_author.html index acb0df695f..dfe9494ba2 100644 --- a/demos/blog/templates/create_author.html +++ b/demos/blog/templates/create_author.html @@ -5,7 +5,6 @@ Email:
Name:
Password:
- {% module xsrf_form_html() %} {% end %} diff --git a/demos/blog/templates/login.html b/demos/blog/templates/login.html index 66995f91cb..374263d826 100644 --- a/demos/blog/templates/login.html +++ b/demos/blog/templates/login.html @@ -8,7 +8,6 @@
Email:
Password:
- {% module xsrf_form_html() %}
{% end %} diff --git a/demos/chat/chatdemo.py b/demos/chat/chatdemo.py index 28c12108f2..2ca7f49dd6 100755 --- a/demos/chat/chatdemo.py +++ b/demos/chat/chatdemo.py @@ -115,7 +115,6 @@ async def main(): cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), - xsrf_cookies=True, debug=options.debug, ) app.listen(options.port) diff --git a/demos/chat/static/chat.js b/demos/chat/static/chat.js index 48a63c4137..ab5eefc8df 100644 --- a/demos/chat/static/chat.js +++ b/demos/chat/static/chat.js @@ -52,7 +52,6 @@ function getCookie(name) { } jQuery.postJSON = function(url, args, callback) { - args._xsrf = getCookie("_xsrf"); $.ajax({url: url, data: $.param(args), dataType: "text", type: "POST", success: function(response) { if (callback) callback(eval("(" + response + ")")); @@ -90,7 +89,6 @@ var updater = { cursor: null, poll: function() { - var args = {"_xsrf": getCookie("_xsrf")}; if (updater.cursor) args.cursor = updater.cursor; $.ajax({url: "/a/message/updates", type: "POST", dataType: "text", data: $.param(args), success: updater.onSuccess, diff --git a/demos/chat/templates/index.html b/demos/chat/templates/index.html index 58433b446d..bfa686ec33 100644 --- a/demos/chat/templates/index.html +++ b/demos/chat/templates/index.html @@ -20,7 +20,6 @@ - {% module xsrf_form_html() %} diff --git a/demos/facebook/facebook.py b/demos/facebook/facebook.py index 9b608aaf0d..6194145fc9 100755 --- a/demos/facebook/facebook.py +++ b/demos/facebook/facebook.py @@ -37,7 +37,6 @@ def __init__(self): login_url="/auth/login", template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), - xsrf_cookies=True, facebook_api_key=options.facebook_api_key, facebook_secret=options.facebook_secret, ui_modules={"Post": PostModule}, @@ -84,7 +83,9 @@ async def get(self): client_secret=self.settings["facebook_secret"], code=self.get_argument("code"), ) - self.set_signed_cookie("fbdemo_user", tornado.escape.json_encode(user)) + self.set_signed_cookie( + "fbdemo_user", tornado.escape.json_encode(user), samesite="lax" + ) self.redirect(self.get_argument("next", "/")) return self.authorize_redirect( diff --git a/demos/websocket/chatdemo.py b/demos/websocket/chatdemo.py index 05781c757e..13ffbd288d 100755 --- a/demos/websocket/chatdemo.py +++ b/demos/websocket/chatdemo.py @@ -36,7 +36,6 @@ def __init__(self): cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", template_path=os.path.join(os.path.dirname(__file__), "templates"), static_path=os.path.join(os.path.dirname(__file__), "static"), - xsrf_cookies=True, ) super().__init__(handlers, **settings) diff --git a/demos/websocket/templates/index.html b/demos/websocket/templates/index.html index d022ee750d..05713dc986 100644 --- a/demos/websocket/templates/index.html +++ b/demos/websocket/templates/index.html @@ -20,7 +20,6 @@ - {% module xsrf_form_html() %} diff --git a/docs/guide/running.rst b/docs/guide/running.rst index 99d18275a3..4f60759745 100644 --- a/docs/guide/running.rst +++ b/docs/guide/running.rst @@ -167,7 +167,6 @@ You can serve static files from Tornado by specifying the "static_path": os.path.join(os.path.dirname(__file__), "static"), "cookie_secret": "__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__", "login_url": "/login", - "xsrf_cookies": True, } application = tornado.web.Application([ (r"/", MainHandler), diff --git a/docs/guide/security.rst b/docs/guide/security.rst index ee33141ee0..0bd75871e4 100644 --- a/docs/guide/security.rst +++ b/docs/guide/security.rst @@ -207,23 +207,63 @@ the Google credentials in a cookie for later access: See the `tornado.auth` module documentation for more details. -.. _xsrf: - Cross-site request forgery protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ `Cross-site request forgery `_, or -XSRF, is a common problem for personalized web applications. - -The generally accepted solution to prevent XSRF is to cookie every user -with an unpredictable value and include that value as an additional -argument with every form submission on your site. If the cookie and the -value in the form submission do not match, then the request is likely -forged. - -Tornado comes with built-in XSRF protection. To include it in your site, -include the application setting ``xsrf_cookies``: +XSRF or CSRF (Tornado uses the acronym XSRF for historical reasons, although +CSRF is generally more commonly used today), is a common problem for +personalized web applications. + +The simplest solution to this problem is to set the ``samesite`` attribute to +either ``lax`` or ``strict`` on all cookies used to authenticate the user. +(``lax`` is generally fine for this purpose; ``strict`` would be appropriate +if your application may use the HTTP ``GET`` method for requests that +have side effects). Note that as of January 2023, ``lax`` mode is the default +for some browsers, but not all. + +In the web security model, a "site" is broader than an "origin" or "host" +(``example.com`` is a site; ``a.example.com`` and ``b.example.com`` are +hosts within that site). While not technically "cross-site", attacks from +one host to another within a site are relevant in many contexts. Such attacks +may be called +`"same-site request forgery" `_ +or "related-domain attacks". Protection against same-site attacks requires +something stronger than a ``samesite`` cookie, such as the +`synchronizer token pattern `_. +This pattern is somewhat invasive and cannot be implemented within Tornado +as it is a fairly low-level framework, but it may be worth considering at +the application level. + +.. _legacy-xsrf: + +Legacy XSRF protection +^^^^^^^^^^^^^^^^^^^^^^ + +Prior to the introduction of the ``samesite`` cookie attribute, Tornado +included an implementation of the +`double-submit cookie `_ +pattern, called ``xsrf_cookies``. This feature provided protection against +XSRF attacks that is equivalent to that provided by the ``samesite`` cookie +attribute, but required modifications to the application to ensure that the +XSRF token was passed whenever it was needed. Since the ``samesite`` cookie +attribute provides equivalent protection with less work, the ``xsrf_cookies`` +feature is deprecated. + +.. note:: + + By default, the ``xsrf_cookies`` feature does not protect against + same-site attacks. However, when combined with the use of the + `host-only cookie prefix `_ + it becomes stronger and may be better than relying on ``samesite="lax"``. + To try this, use the application setting + ``xsrf_cookie_kwargs={"name": "__Host-xsrf"}``. This usage should be + considered experimental, but if it works the ``xsrf_cookies`` feature may + become un-deprecated. + +To enable the ``xsrf_cookies`` feature, use the application setting +``xsrf_cookies=True``: .. testcode:: @@ -288,6 +328,37 @@ However, if you support both cookie and non-cookie-based authentication, it is important that XSRF protection be used whenever the current request is authenticated with a cookie. +.. _xsrf-deprecation: + +Migrating from legacy XSRF protection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``samesite="lax"`` cookie attribute, when applied to *all* cookies used +for authentication, provides protection against XSRF attacks that is +equivalent to Tornado's ``xsrf_cookies`` feature, so that feature is now +deprecated. + +You may wish to continue using ``xsrf_cookies`` in some situations: + +* If your application may perform side effects in response to HTTP GET + requests, but cannot use ``samesite="strict"``. +* If your authentication is based on something other than cookies, such + as TLS certificates or network addresses. + +If you have an application that uses Tornado's ``xsrf_cookies`` feature +and you want to migrate to the ``samesite`` cookie attribute, follow these +steps: + +1. Pass ``samesite="lax"`` (or ``samesite="strict"``) to `.set_signed_cookie` + (or its deprecated alias ``set_secure_cookie``) every time you set a cookie + to be used for user authentication. +2. Deploy your application. Wait until all cookies that might have been set + without the ``samesite`` attribute have expired. +3. Remove the ``xsrf_cookies=True`` application setting from your code, + and all instances of ``xsrf_form_html`` from your templates and code. If + you have JavaScript code that touches the ``_xsrf`` cookie or sets an + ``_xsrf`` query parameter, it can be removed as well. + .. _dnsrebinding: DNS Rebinding diff --git a/docs/guide/templates.rst b/docs/guide/templates.rst index 7c5a8f4192..23573d7f0a 100644 --- a/docs/guide/templates.rst +++ b/docs/guide/templates.rst @@ -177,7 +177,6 @@ Here is a properly internationalized template::
{{ _("Username") }}
{{ _("Password") }}
- {% module xsrf_form_html() %} diff --git a/docs/web.rst b/docs/web.rst index 3d6a7ba18e..235aeef077 100644 --- a/docs/web.rst +++ b/docs/web.rst @@ -243,16 +243,18 @@ * ``login_url``: The `authenticated` decorator will redirect to this url if the user is not logged in. Can be further customized by overriding `RequestHandler.get_login_url` - * ``xsrf_cookies``: If ``True``, :ref:`xsrf` will be enabled. + * ``xsrf_cookies``: If ``True``, :ref:`legacy-xsrf` will be enabled. + This functionality is deprecated as of Tornado 6.3; see + :ref:`xsrf-deprecation` for more. * ``xsrf_cookie_version``: Controls the version of new XSRF cookies produced by this server. Should generally be left at the default (which will always be the highest supported version), but may be set to a lower value temporarily during version transitions. New in Tornado 3.2.2, which - introduced XSRF cookie version 2. + introduced XSRF cookie version 2. Deprecated since Tornado 6.3. * ``xsrf_cookie_kwargs``: May be set to a dictionary of additional arguments to be passed to `.RequestHandler.set_cookie` - for the XSRF cookie. + for the XSRF cookie. Deprecated since Tornado 6.3. * ``twitter_consumer_key``, ``twitter_consumer_secret``, ``friendfeed_consumer_key``, ``friendfeed_consumer_secret``, ``google_consumer_key``, ``google_consumer_secret``, diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index f1760b9a8f..89f3fe03ab 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -17,6 +17,7 @@ from tornado.simple_httpclient import SimpleAsyncHTTPClient from tornado.template import DictLoader from tornado.testing import AsyncHTTPTestCase, AsyncTestCase, ExpectLog, gen_test +from tornado.test.util import ignore_deprecation from tornado.util import ObjectDict, unicode_type from tornado.web import ( Application, @@ -1721,6 +1722,11 @@ def get_handlers(self): # explicitly defined error handler and an implicit 404. return [("/error", ErrorHandler, dict(status_code=417))] + def get_app(self): + # xsrf_cookies is deprecated + with ignore_deprecation(): + return super().get_app() + def get_app_kwargs(self): return dict(xsrf_cookies=True) @@ -2728,6 +2734,11 @@ def get(self): def post(self): self.write("ok") + def get_app(self): + # xsrf_cookies is deprecated + with ignore_deprecation(): + return super().get_app() + def get_app_kwargs(self): return dict(xsrf_cookies=True) @@ -2924,6 +2935,11 @@ class Handler(RequestHandler): def get(self): self.write(self.xsrf_token) + def get_app(self): + # xsrf_cookies is deprecated + with ignore_deprecation(): + return super().get_app() + def get_app_kwargs(self): return dict( xsrf_cookies=True, xsrf_cookie_kwargs=dict(httponly=True, expires_days=2) diff --git a/tornado/web.py b/tornado/web.py index c4f8836770..329c5068ac 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -84,6 +84,7 @@ async def main(): import types import urllib.parse from urllib.parse import urlencode +import warnings from tornado.concurrent import Future, future_set_result_unless_cancelled from tornado import escape @@ -744,6 +745,16 @@ def set_signed_cookie( Similar to `set_cookie`, the effect of this method will not be seen until the following request. + Consider setting additional attributes whenever you set a signed + cookie: + + * Use the ``samesite="lax"`` (or ``"strict"``) attribute on any + cookie used for authentication to protect against XSRF attacks. + * Use the ``secure=True`` attribute if your application is + only available over HTTPS. + * Use the ``httponly=True`` attribute unless you need this cookie + to be readable from javascript. + .. versionchanged:: 3.2.1 Added the ``version`` argument. Introduced cookie version 2 @@ -1434,7 +1445,7 @@ def get_template_path(self) -> Optional[str]: @property def xsrf_token(self) -> bytes: - """The XSRF-prevention token for the current user/session. + """The deprecated XSRF-prevention token for the current user/session. To prevent cross-site request forgery, we set an '_xsrf' cookie and include the same '_xsrf' value as an argument with all POST @@ -1464,11 +1475,16 @@ def xsrf_token(self) -> bytes: ``xsrf_cookie_kwargs=dict(httponly=True, secure=True)`` will set the ``secure`` and ``httponly`` flags on the ``_xsrf`` cookie. + + .. deprecated:: 6.3 + + See :ref:`xsrf-deprecation`. """ if not hasattr(self, "_xsrf_token"): version, token, timestamp = self._get_raw_xsrf_token() output_version = self.settings.get("xsrf_cookie_version", 2) cookie_kwargs = self.settings.get("xsrf_cookie_kwargs", {}) + cookie_name = cookie_kwargs.get("name", "_xsrf") if output_version == 1: self._xsrf_token = binascii.b2a_hex(token) elif output_version == 2: @@ -1486,7 +1502,7 @@ def xsrf_token(self) -> bytes: if version is None: if self.current_user and "expires_days" not in cookie_kwargs: cookie_kwargs["expires_days"] = 30 - self.set_cookie("_xsrf", self._xsrf_token, **cookie_kwargs) + self.set_cookie(cookie_name, self._xsrf_token, **cookie_kwargs) return self._xsrf_token def _get_raw_xsrf_token(self) -> Tuple[Optional[int], bytes, float]: @@ -1501,7 +1517,9 @@ def _get_raw_xsrf_token(self) -> Tuple[Optional[int], bytes, float]: for version 1 cookies) """ if not hasattr(self, "_raw_xsrf_token"): - cookie = self.get_cookie("_xsrf") + cookie_kwargs = self.settings.get("xsrf_cookie_kwargs", {}) + cookie_name = cookie_kwargs.get("name", "_xsrf") + cookie = self.get_cookie(cookie_name) if cookie: version, token, timestamp = self._decode_xsrf_token(cookie) else: @@ -1568,6 +1586,10 @@ def check_xsrf_cookie(self) -> None: .. versionchanged:: 3.2.2 Added support for cookie version 2. Both versions 1 and 2 are supported. + + .. deprecated:: 6.3 + + See :ref:`xsrf-deprecation`. """ # Prior to release 1.1.1, this check was ignored if the HTTP header # ``X-Requested-With: XMLHTTPRequest`` was present. This exception @@ -1601,6 +1623,10 @@ def xsrf_form_html(self) -> str: xsrf_form_html() %}`` See `check_xsrf_cookie()` above for more information. + + .. deprecated:: 6.3 + + See :ref:`xsrf-deprecation` """ return ( ' None: + if settings.get("xsrf_cookies"): + warnings.warn("xsrf_cookies setting is deprecated", DeprecationWarning) if transforms is None: self.transforms = [] # type: List[Type[OutputTransform]] if settings.get("compress_response") or settings.get("gzip"):