CVE-2014-9720: Observable Discrepancy (CWE-203)
value: 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.
CWE 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.
CVSS v3 base score: 6.5
Published: 2020-01-24T18:15Z
Repository: https://github.com/tornadoweb/tornado
Language: Python
Files changed: tornado/test/web_test.py, tornado/test/websocket_test.py, tornado/util.py, tornado/web.py, tornado/websocket.py
Commit date: 2014-05-26 20:44:07 -0400
Commit message: Change the xsrf cookie format to be masked with a random salt.

This protects against the BREACH attack.

Diff: diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index 7e4fa31..2c85bfb 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -2002,18 +2002,20 @@ class XSRFTest(SimpleHandlerTestCase):
                     headers=self.cookie_headers(cookie_token))
             self.assertEqual(response.code, 403)
 
     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)
diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py
index 01fee72..3233e59 100644
--- a/tornado/test/websocket_test.py
+++ b/tornado/test/websocket_test.py
@@ -9,19 +9,20 @@ from tornado.testing import AsyncHTTPTestCase, gen_test, bind_unused_port, Expec
 from tornado.test.util import unittest, skipOnTravis
 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
     # error).  If we get an ImportError here (which could happen due to
     # TORNADO_EXTENSION=1), print some extra information before failing.
     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
 except ImportError:
     speedups = None
diff --git a/tornado/util.py b/tornado/util.py
index a2fba77..05ac4e5 100644
--- a/tornado/util.py
+++ b/tornado/util.py
@@ -10,15 +10,23 @@ interface of its subclasses, including `.AsyncHTTPClient`, `.IOLoop`,
 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.
     """
     def __getattr__(self, name):
         try:
@@ -263,8 +271,43 @@ class ArgReplacer(object):
             old_value = kwargs.get(self.name)
             kwargs[self.name] = new_value
         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()
diff --git a/tornado/web.py b/tornado/web.py
index 1b618a7..ed8148e 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -70,21 +70,20 @@ import sys
 import threading
 import time
 import tornado
 import traceback
 import types
-import uuid
 
 from tornado.concurrent import Future
 from tornado import escape
 from tornado import httputil
 from tornado import locale
 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
 except ImportError:
     from cStringIO import StringIO as BytesIO  # python 2
@@ -1069,18 +1068,60 @@ class RequestHandler(object):
         as a potential forgery.
 
         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.
 
         To prevent cross-site request forgery, we set an ``_xsrf``
         cookie and include the same value as a non-cookie
@@ -1103,11 +1144,13 @@ class RequestHandler(object):
         token = (self.get_argument("_xsrf", None) or
                  self.request.headers.get("X-Xsrftoken") or
                  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):
         """An HTML ``<input/>`` element to be included with all POST forms.
 
diff --git a/tornado/websocket.py b/tornado/websocket.py
index fda231d..ff78552 100644
--- a/tornado/websocket.py
+++ b/tornado/websocket.py
@@ -18,11 +18,10 @@ 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
 import hashlib
 import os
@@ -37,16 +36,11 @@ from tornado import httpclient, httputil
 from tornado.ioloop import IOLoop
 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):
     pass
 
@@ -888,40 +882,5 @@ def websocket_connect(url, io_loop=None, callback=None, connect_timeout=None):
         request, httpclient.HTTPRequest._DEFAULTS)
     conn = WebSocketClientConnection(io_loop, request)
     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

Vulnerable code:             # Implementation detail: the same token is returned each time
            self.assertEqual(token, self.xsrf_token)
from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError, _websocket_mask_python
import uuid
from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type
            token = self.get_cookie("_xsrf")
            if not token:
                token = binascii.b2a_hex(os.urandom(16))
                self.set_cookie("_xsrf", token, expires_days=expires_days)
            self._xsrf_token = token
        if not _time_independent_equals(utf8(self.xsrf_token), utf8(token)):
import array
from tornado.util import bytes_type, unicode_type

try:
    xrange  # py2
except NameError:
    xrange = range  # py3


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
Fixed code:         tokens_seen = set([token])
            # Tokens are encoded uniquely each time
            tokens_seen.add(token)
        self.assertEqual(len(tokens_seen), 6)
    from tornado.util import _websocket_mask_python
from tornado.websocket import WebSocketHandler, websocket_connect, WebSocketError
import array
import os
try:
    xrange  # py2
except NameError:
    xrange = range  # py3


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


from tornado.util import bytes_type, import_object, ObjectDict, raise_exc_info, unicode_type, _websocket_mask
            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:
                self.set_cookie("_xsrf", self._xsrf_token,
                                expires_days=expires_days)
    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

        _, token, _ = self._decode_xsrf_token(token)
        _, expected_token, _ = self._get_raw_xsrf_token()
        if not _time_independent_equals(utf8(token), utf8(expected_token)):
from tornado.util import bytes_type, unicode_type, _websocket_mask