Repository: falconry/falcon
Pull request #1348 (2018-08-28 21:19:28)

fix(routing): assign Content-Type when serving static fallback files
Co-authored-by: snobu <foo@snobu.org>
Co-authored-by: Ben Demaree <bendemaree@gmail.com>
Co-authored-by: Nick Zaccardi <nicholas.zaccardi@gmail.com>

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=h1) Report
> Merging [#1348](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/3e5d759ab8976b893cb87dc7e8fe2695d60c3bfd?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1348/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1348   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2537    2539    +2     
  Branches      388     389    +1     
======================================
+ Hits         2537    2539    +2
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/routing/static.py](https://codecov.io/gh/falconry/falcon/pull/1348/diff?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvc3RhdGljLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=footer). Last update [3e5d759...ef9a251](https://codecov.io/gh/falconry/falcon/pull/1348?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
index d659f99..f2bb1c6 100644
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -25,8 +25,15 @@ class StaticRoute(object):
             Content-Disposition header in the response. The "filename"
             directive is simply set to the name of the requested file.
         fallback_filename (str): Fallback filename used when the requested file
-                is not found. Can be a relative path inside the prefix folder or any valid
-                absolute path.
+            is not found. Can be a relative path inside the prefix folder or
+            any valid absolute path.
+
+            Note:
+                If the fallback file is served instead of the requested file,
+                the response Content-Type header, as well as the
+                Content-Disposition header (provided it was requested with the
+                `downloadable` parameter described above), are derived from the
+                fallback filename, as opposed to the requested filename.
     """
 
     # NOTE(kgriffs): Don't allow control characters and reserved chars
@@ -94,7 +101,7 @@ class StaticRoute(object):
         # should never succeed, but this should guard against us having
         # overlooked something.
         if '..' in file_path or not file_path.startswith(self._directory):
-            raise falcon.HTTPNotFound()  # pragma: nocover
+            raise falcon.HTTPNotFound()
 
         try:
             resp.stream = io.open(file_path, 'rb')
@@ -103,6 +110,7 @@ class StaticRoute(object):
                 raise falcon.HTTPNotFound()
             try:
                 resp.stream = io.open(self._fallback_filename, 'rb')
+                file_path = self._fallback_filename
             except IOError:
                 raise falcon.HTTPNotFound()
 


Test patch:
diff --git a/tests/test_static.py b/tests/test_static.py
index b92d978..d04f521 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -217,13 +217,17 @@ def test_downloadable_not_found(client):
     assert response.status == falcon.HTTP_404
 
 
-@pytest.mark.parametrize('uri, default, expected', [
-    ('', 'default', 'default'),
-    ('other', 'default', 'default'),
-    ('index2', 'index', 'index2'),
-    ('absolute', '/foo/bar/index', '/foo/bar/index'),
+@pytest.mark.parametrize('uri, default, expected, content_type', [
+    ('', 'default', 'default', 'application/octet-stream'),
+    ('other', 'default.html', 'default.html', 'text/html'),
+    ('index2', 'index', 'index2', 'application/octet-stream'),
+    ('absolute', '/foo/bar/index', '/foo/bar/index', 'application/octet-stream'),
+    ('docs/notes/test.txt', 'index.html', 'index.html', 'text/html'),
+    ('index.html_files/test.txt', 'index.html', 'index.html_files/test.txt', 'text/plain'),
 ])
-def test_fallback_filename(uri, default, expected, monkeypatch):
+@pytest.mark.parametrize('downloadable', [True, False])
+def test_fallback_filename(uri, default, expected, content_type, downloadable,
+                           monkeypatch):
 
     def mockOpen(path, mode):
         if default in path:
@@ -233,7 +237,8 @@ def test_fallback_filename(uri, default, expected, monkeypatch):
     monkeypatch.setattr(io, 'open', mockOpen)
     monkeypatch.setattr('os.path.isfile', lambda file: default in file)
 
-    sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
+    sr = StaticRoute('/static', '/var/www/statics', downloadable=downloadable,
+                     fallback_filename=default)
 
     req_path = '/static/' + uri
 
@@ -247,6 +252,12 @@ def test_fallback_filename(uri, default, expected, monkeypatch):
 
     assert sr.match(req.path)
     assert resp.stream == os.path.join('/var/www/statics', expected)
+    assert resp.content_type == content_type
+
+    if downloadable:
+        assert os.path.basename(expected) in resp.downloadable_as
+    else:
+        assert resp.downloadable_as is None
 
 
 @pytest.mark.parametrize('strip_slash', [True, False])
@@ -300,3 +311,14 @@ def test_match(default, path, expected, monkeypatch):
     sr = StaticRoute('/static', '/var/www/statics', fallback_filename=default)
 
     assert sr.match(path) == expected
+
+
+def test_filesystem_traversal_fuse(client, monkeypatch):
+
+    def suspicious_normpath(path):
+        return 'assets/../../../../' + path
+
+    monkeypatch.setattr('os.path.normpath', suspicious_normpath)
+    client.app.add_static_route('/static', '/etc/nginx/includes/static-data')
+    response = client.simulate_request(path='/static/shadow')
+    assert response.status == falcon.HTTP_404