Repository: tornadoweb/tornado

Issue:
Correctly parse If-None-Match header
We currently just look for the current Etag as a substring of If-None-Match, which mostly works for our hash-generated Etags (although it did mask a bug involving str() on a bytes object in python 3), but is not guaranteed to be correct for applications that override compute_etag.



Discussion:

Resolving patch:
diff --git a/tornado/web.py b/tornado/web.py
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -1310,9 +1310,27 @@ def check_etag_header(self):
         before completing the request.  The ``Etag`` header should be set
         (perhaps with `set_etag_header`) before calling this method.
         """
-        etag = self._headers.get("Etag")
-        inm = utf8(self.request.headers.get("If-None-Match", ""))
-        return bool(etag and inm and inm.find(etag) >= 0)
+        computed_etag = utf8(self._headers.get("Etag", ""))
+        # Find all weak and strong etag values from If-None-Match header
+        # because RFC 7232 allows multiple etag values in a single header.
+        etags = re.findall(
+            br'\*|(?:W/)?"[^"]*"',
+            utf8(self.request.headers.get("If-None-Match", ""))
+        )
+        if not computed_etag or not etags:
+            return False
+
+        match = False
+        if etags[0] == b'*':
+            match = True
+        else:
+            # Use a weak comparison when comparing entity-tags.
+            val = lambda x: x[2:] if x.startswith(b'W/') else x
+            for etag in etags:
+                if val(etag) == val(computed_etag):
+                    match = True
+                    break
+        return match
 
     def _stack_context_handle_exception(self, type, value, traceback):
         try:


Test patch:
diff --git a/maint/test/redbot/red_test.py b/maint/test/redbot/red_test.py
--- a/maint/test/redbot/red_test.py
+++ b/maint/test/redbot/red_test.py
@@ -34,6 +34,13 @@ def get(self):
         yield gen.Task(self.flush)
         self.finish()
 
+class CacheHandler(RequestHandler):
+    def get(self, computed_etag):
+        self.write(computed_etag)
+
+    def compute_etag(self):
+        return self._write_buffer[0]
+
 class TestMixin(object):
     def get_handlers(self):
         return [
@@ -41,6 +48,7 @@ def get_handlers(self):
             ('/redirect(/.*)', RedirectHandler),
             ('/post', PostHandler),
             ('/chunked', ChunkedHandler),
+            ('/cache/(.*)', CacheHandler),
             ]
 
     def get_app_kwargs(self):
@@ -70,7 +78,6 @@ def check_url(self, path, method='GET', body=None, headers=None,
                 raise red.response.http_error.res_error
             else:
                 raise Exception("unknown error; incomplete response")
-
         self.assertEqual(int(red.response.status_code), expected_status)
 
         allowed_warnings = (allowed_warnings or []) + self.get_allowed_warnings()
@@ -149,6 +156,79 @@ def test_post(self):
     def test_chunked(self):
         self.check_url('/chunked')
 
+    def test_strong_etag_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=304)
+
+    def test_multiple_strong_etag_match(self):
+        computed_etag = '"xyzzy1"'
+        etags = '"xyzzy1", "xyzzy2"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=304)
+
+    def test_strong_etag_not_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy1"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=200)
+
+    def test_multiple_strong_etag_not_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy1", "xyzzy2"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=200)
+
+    def test_wildcard_etag(self):
+        computed_etag = '"xyzzy"'
+        etags = '*'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=304,
+            allowed_warnings=[rs.MISSING_HDRS_304])
+
+    def test_weak_etag_match(self):
+        computed_etag = '"xyzzy1"'
+        etags = 'W/"xyzzy1"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=304)
+
+    def test_multiple_weak_etag_match(self):
+        computed_etag = '"xyzzy2"'
+        etags = 'W/"xyzzy1", W/"xyzzy2"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=304)
+
+    def test_weak_etag_not_match(self):
+        computed_etag = '"xyzzy2"'
+        etags = 'W/"xyzzy1"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=200)
+
+    def test_multiple_weak_etag_not_match(self):
+        computed_etag = '"xyzzy3"'
+        etags = 'W/"xyzzy1", W/"xyzzy2"'
+        self.check_url(
+            '/cache/' + computed_etag, method='GET',
+            headers=[('If-None-Match', etags)],
+            expected_status=200)
+
 class DefaultHTTPTest(AsyncHTTPTestCase, LogTrapTestCase, TestMixin):
     def get_app(self):
         return Application(self.get_handlers(), **self.get_app_kwargs())
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -2426,3 +2426,69 @@ def test_addslash(self):
         response = self.fetch("/addslash?foo=bar", follow_redirects=False)
         self.assertEqual(response.code, 301)
         self.assertEqual(response.headers['Location'], "/addslash/?foo=bar")
+
+
+class CacheTest(WebTestCase):
+    def get_handlers(self):
+        class EtagHandler(RequestHandler):
+            def get(self, computed_etag):
+                self.write(computed_etag)
+
+            def compute_etag(self):
+                return self._write_buffer[0]
+
+        return [
+            ('/etag/(.*)', EtagHandler)
+        ]
+
+    def test_wildcard_etag(self):
+        computed_etag = '"xyzzy"'
+        etags = '*'
+        self._test_etag(computed_etag, etags, 304)
+
+    def test_strong_etag_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy"'
+        self._test_etag(computed_etag, etags, 304)
+
+    def test_multiple_strong_etag_match(self):
+        computed_etag = '"xyzzy1"'
+        etags = '"xyzzy1", "xyzzy2"'
+        self._test_etag(computed_etag, etags, 304)
+
+    def test_strong_etag_not_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy1"'
+        self._test_etag(computed_etag, etags, 200)
+
+    def test_multiple_strong_etag_not_match(self):
+        computed_etag = '"xyzzy"'
+        etags = '"xyzzy1", "xyzzy2"'
+        self._test_etag(computed_etag, etags, 200)
+
+    def test_weak_etag_match(self):
+        computed_etag = '"xyzzy1"'
+        etags = 'W/"xyzzy1"'
+        self._test_etag(computed_etag, etags, 304)
+
+    def test_multiple_weak_etag_match(self):
+        computed_etag = '"xyzzy2"'
+        etags = 'W/"xyzzy1", W/"xyzzy2"'
+        self._test_etag(computed_etag, etags, 304)
+
+    def test_weak_etag_not_match(self):
+        computed_etag = '"xyzzy2"'
+        etags = 'W/"xyzzy1"'
+        self._test_etag(computed_etag, etags, 200)
+
+    def test_multiple_weak_etag_not_match(self):
+        computed_etag = '"xyzzy3"'
+        etags = 'W/"xyzzy1", W/"xyzzy2"'
+        self._test_etag(computed_etag, etags, 200)
+
+    def _test_etag(self, computed_etag, etags, status_code):
+        response = self.fetch(
+            '/etag/' + computed_etag,
+            headers={'If-None-Match': etags}
+        )
+        self.assertEqual(response.code, status_code)