Repository: tornadoweb/tornado

Issue:
StaticFileHandler breaks with negative range
When passing a negative range (example: `bytes=-30720`) in the Range header, but the requested file is smaller than the range requested, StaticFileHandler will try to seek to a negative position in the file and get an error from the operating system (linux in my case).

```
Traceback (most recent call last):
  File "/home/venzano/venvs/zoe-2018.08/lib/python3.5/site-packages/tornado/web.py", line 1592, in _execute
    result = yield result
  File "/home/venzano/venvs/zoe-2018.08/lib/python3.5/site-packages/tornado/gen.py", line 1133, in run
    value = future.result()
  File "/usr/lib/python3.5/asyncio/futures.py", line 274, in result
    raise self._exception
  File "/home/venzano/venvs/zoe-2018.08/lib/python3.5/site-packages/tornado/gen.py", line 326, in wrapper
    yielded = next(result)
  File "/home/venzano/venvs/zoe-2018.08/lib/python3.5/site-packages/tornado/web.py", line 2525, in get
    for chunk in content:
  File "/home/venzano/venvs/zoe-2018.08/lib/python3.5/site-packages/tornado/web.py", line 2680, in get_content
    file.seek(start)
OSError: [Errno 22] Invalid argument
```

The code (file web.py around line 2494 in Tornado 5.1.1):
```
            if start is not None and start < 0:
                start += size
```
is not sufficient, it should check if size is smaller than start and in that case just set start=size.


Discussion:

Resolving patch:
diff --git a/tornado/web.py b/tornado/web.py
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -2600,16 +2600,24 @@ async def get(self, path: str, include_body: bool = True) -> None:
         size = self.get_content_size()
         if request_range:
             start, end = request_range
-            if (start is not None and start >= size) or end == 0:
+            if start is not None and start < 0:
+                start += size
+                if start < 0:
+                    start = 0
+            if (
+                start is not None
+                and (start >= size or (end is not None and start >= end))
+            ) or end == 0:
                 # As per RFC 2616 14.35.1, a range is not satisfiable only: if
                 # the first requested byte is equal to or greater than the
-                # content, or when a suffix with length 0 is specified
+                # content, or when a suffix with length 0 is specified.
+                # https://tools.ietf.org/html/rfc7233#section-2.1
+                # A byte-range-spec is invalid if the last-byte-pos value is present
+                # and less than the first-byte-pos.
                 self.set_status(416)  # Range Not Satisfiable
                 self.set_header("Content-Type", "text/plain")
                 self.set_header("Content-Range", "bytes */%s" % (size,))
                 return
-            if start is not None and start < 0:
-                start += size
             if end is not None and end > size:
                 # Clients sometimes blindly use a large range to limit their
                 # download size; cap the endpoint at the actual file size.


Test patch:
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
@@ -1280,6 +1280,17 @@ def test_static_with_range_neg_end(self):
         self.assertEqual(response.headers.get("Content-Length"), "4")
         self.assertEqual(response.headers.get("Content-Range"), "bytes 22-25/26")
 
+    def test_static_with_range_neg_past_start(self):
+        response = self.get_and_head(
+            "/static/robots.txt", headers={"Range": "bytes=-1000000"}
+        )
+        self.assertEqual(response.code, 200)
+        robots_file_path = os.path.join(self.static_dir, "robots.txt")
+        with open(robots_file_path) as f:
+            self.assertEqual(response.body, utf8(f.read()))
+        self.assertEqual(response.headers.get("Content-Length"), "26")
+        self.assertEqual(response.headers.get("Content-Range"), None)
+
     def test_static_invalid_range(self):
         response = self.get_and_head("/static/robots.txt", headers={"Range": "asdf"})
         self.assertEqual(response.code, 200)
@@ -1298,6 +1309,13 @@ def test_static_unsatisfiable_range_invalid_start(self):
         self.assertEqual(response.code, 416)
         self.assertEqual(response.headers.get("Content-Range"), "bytes */26")
 
+    def test_static_unsatisfiable_range_end_less_than_start(self):
+        response = self.get_and_head(
+            "/static/robots.txt", headers={"Range": "bytes=10-3"}
+        )
+        self.assertEqual(response.code, 416)
+        self.assertEqual(response.headers.get("Content-Range"), "bytes */26")
+
     def test_static_head(self):
         response = self.fetch("/static/robots.txt", method="HEAD")
         self.assertEqual(response.code, 200)