CVE-2014-9720 (published 2020-01-24T18:15Z)

Tornado before 3.2.2 sends arbitrary responses that contain a fixed CSRF token and may be sent with HTTP compression, which makes it easier for remote attackers to conduct a BREACH attack and determine this token via a series of crafted requests.

Severity: MEDIUM
CVSS v2 base score: 4.3 (AV:N/AC:M/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-203
cwe_name: Observable Discrepancy
description: The product behaves differently or sends different responses under different circumstances in a way that is observable to an unauthorized actor, which exposes security-relevant information about the state of the product, such as whether a particular operation was successful or not.
extended_description: Discrepancies can take many forms, and variations may be detectable in timing, control flow, communications such as replies or requests, or general behavior. These discrepancies can reveal information about the product's operation or internal state to an unauthorized actor. In some cases, discrepancies can be used by attackers to form a side channel.
         

Fix information:
repo_url: https://github.com/tornadoweb/tornado
commit_details: repo_url: https://github.com/tornadoweb/tornado
author: Ben Darnell
author_date: 2014-05-26 15:44:58-04:00
msg: Change the xsrf cookie format to be masked with a random salt.

This protects against the BREACH attack.
repository: repo_url: https://github.com/tornadoweb/tornado
repo_name: tornadoweb/tornado
description: Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed.
repo_language: Python
owner: tornadoweb
file_changes: filename: web_test.py
change_type: ModificationType.MODIFY
diff: @@ -2004,16 +2004,18 @@ class XSRFTest(SimpleHandlerTestCase):
 
     def test_refresh_token(self):
         token = self.xsrf_token
+        tokens_seen = set([token])
         # A user's token is stable over time.  Refreshing the page in one tab
         # might update the cookie while an older tab still has the old cookie
         # in its DOM.  Simulate this scenario by passing a constant token
         # in the body and re-querying for the token.
         for i in range(5):
             token = self.get_token(token)
-            # Implementation detail: the same token is returned each time
-            self.assertEqual(token, self.xsrf_token)
+            # Tokens are encoded uniquely each time
+            tokens_seen.add(token)
             response = self.fetch(
                 "/", method="POST",
                 body=urllib_parse.urlencode(dict(_xsrf=self.xsrf_token)),
                 headers=self.cookie_headers(token))
             self.assertEqual(response.code, 200)
+        self.assertEqual(len(tokens_seen), 6)

programming_language: Python
filename: websocket_test.py
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ from tornado.web import Application, RequestHandler
 
 try:
     import tornado.websocket
+    from tornado.util import _websocket_mask_python
 except ImportError:
     # The unittest module presents misleading errors on ImportError
     # (it acts as if websocket_test could not be found, hiding the underlying
@@ -19,7 +20,7 @@ except ImportError:
     traceback.print_exc()
     raise
 
-from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError, _websocket_mask_python
+from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError
 
 try:
     from tornado import speedups

programming_language: Python
filename: util.py
change_type: ModificationType.MODIFY
diff: @@ -12,11 +12,19 @@ and `.Resolver`.
 
 from __future__ import absolute_import, division, print_function, with_statement
 
+import array
 import inspect
+import os
 import sys
 import zlib
 
 
+try:
+    xrange  # py2
+except NameError:
+    xrange = range  # py3
+
+
 class ObjectDict(dict):
     """Makes a dictionary behave like an object, with attribute-style access.
     """
@@ -265,6 +273,41 @@ class ArgReplacer(object):
         return old_value, args, kwargs
 
 
+def _websocket_mask_python(mask, data):
+    """Websocket masking function.
+
+    `mask` is a `bytes` object of length 4; `data` is a `bytes` object of any length.
+    Returns a `bytes` object of the same length as `data` with the mask applied
+    as specified in section 5.3 of RFC 6455.
+
+    This pure-python implementation may be replaced by an optimized version when available.
+    """
+    mask = array.array("B", mask)
+    unmasked = array.array("B", data)
+    for i in xrange(len(data)):
+        unmasked[i] = unmasked[i] ^ mask[i % 4]
+    if hasattr(unmasked, 'tobytes'):
+        # tostring was deprecated in py32.  It hasn't been removed,
+        # but since we turn on deprecation warnings in our tests
+        # we need to use the right one.
+        return unmasked.tobytes()
+    else:
+        return unmasked.tostring()
+
+if (os.environ.get('TORNADO_NO_EXTENSION') or
+    os.environ.get('TORNADO_EXTENSION') == '0'):
+    # These environment variables exist to make it easier to do performance
+    # comparisons; they are not guaranteed to remain supported in the future.
+    _websocket_mask = _websocket_mask_python
+else:
+    try:
+        from tornado.speedups import websocket_mask as _websocket_mask
+    except ImportError:
+        if os.environ.get('TORNADO_EXTENSION') == '1':
+            raise
+        _websocket_mask = _websocket_mask_python
+
+
 def doctests():
     import doctest
     return doctest.DocTestSuite()

programming_language: Python
filename: web.py
change_type: ModificationType.MODIFY
diff: @@ -72,7 +72,6 @@ import time
 import tornado
 import traceback
 import types
-import uuid
 
 from tornado.concurrent import Future
 from tornado import escape
@@ -82,7 +81,7 @@ from tornado.log import access_log, app_log, gen_log
 from tornado import stack_context
 from tornado import template
 from tornado.escape import utf8, _unicode
-from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type
+from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type, _websocket_mask
 
 try:
     from io import BytesIO  # python 3
@@ -1071,14 +1070,56 @@ class RequestHandler(object):
         See http://en.wikipedia.org/wiki/Cross-site_request_forgery
         """
         if not hasattr(self, "_xsrf_token"):
-            token = self.get_cookie("_xsrf")
-            if not token:
-                token = binascii.b2a_hex(os.urandom(16))
+            version, token, timestamp = self._get_raw_xsrf_token()
+            mask = os.urandom(4)
+            self._xsrf_token = b"|".join([
+                b"2",
+                binascii.b2a_hex(mask),
+                binascii.b2a_hex(_websocket_mask(mask, token)),
+                utf8(str(int(timestamp)))])
+            if version is None or version != 2:
                 expires_days = 30 if self.current_user else None
-                self.set_cookie("_xsrf", token, expires_days=expires_days)
-            self._xsrf_token = token
+                self.set_cookie("_xsrf", self._xsrf_token,
+                                expires_days=expires_days)
         return self._xsrf_token
 
+    def _get_raw_xsrf_token(self):
+        if not hasattr(self, '_raw_xsrf_token'):
+            cookie = self.get_cookie("_xsrf")
+            if cookie:
+                version, token, timestamp = self._decode_xsrf_token(cookie)
+            else:
+                version, token, timestamp = None, None, None
+            if token is None:
+                version = None
+                token = os.urandom(16)
+                timestamp = time.time()
+            self._raw_xsrf_token = (version, token, timestamp)
+        return self._raw_xsrf_token
+
+    def _decode_xsrf_token(self, cookie):
+        m = _signed_value_version_re.match(utf8(cookie))
+        if m:
+            version = int(m.group(1))
+            if version == 2:
+                _, mask, masked_token, timestamp = cookie.split("|")
+                mask = binascii.a2b_hex(utf8(mask))
+                token = _websocket_mask(
+                    mask, binascii.a2b_hex(utf8(masked_token)))
+                timestamp = int(timestamp)
+                return version, token, timestamp
+            else:
+                # Treat unknown versions as not present instead of failing.
+                return None, None, None
+        elif len(cookie) == 32:
+            version = 1
+            token = binascii.a2b_hex(cookie)
+            # We don't have a usable timestamp in older versions.
+            timestamp = int(time.time())
+            return (version, token, timestamp)
+        else:
+            return None, None, None
+
     def check_xsrf_cookie(self):
         """Verifies that the ``_xsrf`` cookie matches the ``_xsrf`` argument.
 
@@ -1105,7 +1146,9 @@ class RequestHandler(object):
                  self.request.headers.get("X-Csrftoken"))
         if not token:
             raise HTTPError(403, "'_xsrf' argument missing from POST")
-        if not _time_independent_equals(utf8(self.xsrf_token), utf8(token)):
+        _, token, _ = self._decode_xsrf_token(token)
+        _, expected_token, _ = self._get_raw_xsrf_token()
+        if not _time_independent_equals(utf8(token), utf8(expected_token)):
             raise HTTPError(403, "XSRF cookie does not match POST argument")
 
     def xsrf_form_html(self):

programming_language: Python
filename: websocket.py
change_type: ModificationType.MODIFY
diff: @@ -20,7 +20,6 @@ communication between the browser and server.
 from __future__ import absolute_import, division, print_function, with_statement
 # Author: Jacob Kristhammar, 2010
 
-import array
 import base64
 import collections
 import functools
@@ -39,12 +38,7 @@ from tornado.iostream import StreamClosedError
 from tornado.log import gen_log, app_log
 from tornado.netutil import Resolver
 from tornado import simple_httpclient
-from tornado.util import bytes_type, unicode_type
-
-try:
-    xrange  # py2
-except NameError:
-    xrange = range  # py3
+from tornado.util import bytes_type, unicode_type, _websocket_mask
 
 
 class WebSocketError(Exception):
@@ -890,38 +884,3 @@ def websocket_connect(url, io_loop=None, callback=None, connect_timeout=None):
     if callback is not None:
         io_loop.add_future(conn.connect_future, callback)
     return conn.connect_future
-
-
-def _websocket_mask_python(mask, data):
-    """Websocket masking function.
-
-    `mask` is a `bytes` object of length 4; `data` is a `bytes` object of any length.
-    Returns a `bytes` object of the same length as `data` with the mask applied
-    as specified in section 5.3 of RFC 6455.
-
-    This pure-python implementation may be replaced by an optimized version when available.
-    """
-    mask = array.array("B", mask)
-    unmasked = array.array("B", data)
-    for i in xrange(len(data)):
-        unmasked[i] = unmasked[i] ^ mask[i % 4]
-    if hasattr(unmasked, 'tobytes'):
-        # tostring was deprecated in py32.  It hasn't been removed,
-        # but since we turn on deprecation warnings in our tests
-        # we need to use the right one.
-        return unmasked.tobytes()
-    else:
-        return unmasked.tostring()
-
-if (os.environ.get('TORNADO_NO_EXTENSION') or
-    os.environ.get('TORNADO_EXTENSION') == '0'):
-    # These environment variables exist to make it easier to do performance
-    # comparisons; they are not guaranteed to remain supported in the future.
-    _websocket_mask = _websocket_mask_python
-else:
-    try:
-        from tornado.speedups import websocket_mask as _websocket_mask
-    except ImportError:
-        if os.environ.get('TORNADO_EXTENSION') == '1':
-            raise
-        _websocket_mask = _websocket_mask_python

programming_language: Python