diff options
author | Marcel Hellkamp <marc@gsites.de> | 2016-07-05 13:40:14 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2016-07-05 13:40:14 +0200 |
commit | d573675243ace953730b0255600533c1347c4f31 (patch) | |
tree | 26c36b054a180cc46eef4bcdd2e0e2fb78b752ea | |
parent | fbf641da04bc2c7abec08792c2a3b73bd31d87d7 (diff) | |
parent | d7a3ca9bec7f937670929702416edb6cf91adc9b (diff) | |
download | bottle-d573675243ace953730b0255600533c1347c4f31.tar.gz |
Merge pull request #861 from bottlepy/feature-etag
Added ETag support to static_file()
-rw-r--r-- | bottle.py | 66 | ||||
-rwxr-xr-x | test/test_sendfile.py | 24 |
2 files changed, 70 insertions, 20 deletions
@@ -2649,26 +2649,39 @@ def _file_iter_range(fp, offset, bytes, maxread=1024 * 1024): def static_file(filename, root, - mimetype='auto', + mimetype=True, download=False, - charset='UTF-8'): - """ Open a file in a safe way and return :exc:`HTTPResponse` with status - code 200, 305, 403 or 404. The ``Content-Type``, ``Content-Encoding``, - ``Content-Length`` and ``Last-Modified`` headers are set if possible. - Special support for ``If-Modified-Since``, ``Range`` and ``HEAD`` - requests. - - :param filename: Name or path of the file to send. + charset='UTF-8', + etag=None): + """ Open a file in a safe way and return an instance of :exc:`HTTPResponse` + that can be sent back to the client. + + :param filename: Name or path of the file to send, relative to ``root``. :param root: Root path for file lookups. Should be an absolute directory path. - :param mimetype: Defines the content-type header (default: guess from + :param mimetype: Provide the content-type header (default: guess from file extension) :param download: If True, ask the browser to open a `Save as...` dialog instead of opening the file with the associated program. You can specify a custom filename as a string. If not specified, the original filename is used (default: False). - :param charset: The charset to use for files with a ``text/*`` - mime-type. (default: UTF-8) + :param charset: The charset for files with a ``text/*`` mime-type. + (default: UTF-8) + :param etag: Provide a pre-computed ETag header. If set to ``False``, + ETag handling is disabled. (default: auto-generate ETag header) + + While checking user input is always a good idea, this function provides + additional protection against malicious ``filename`` parameters from + breaking out of the ``root`` directory and leaking sensitive information + to an attacker. + + Read-protected files or files outside of the ``root`` directory are + answered with ``403 Access Denied``. Missing files result in a + ``404 Not Found`` response. Conditional requests (``If-Modified-Since``, + ``If-None-Match``) are answered with ``304 Not Modified`` whenever + possible. ``HEAD`` and ``Range`` requests (used by download managers to + check or continue partial downloads) are also handled automatically. + """ root = os.path.join(os.path.abspath(root), '') @@ -2682,7 +2695,7 @@ def static_file(filename, root, if not os.access(filename, os.R_OK): return HTTPError(403, "You do not have permission to access this file.") - if mimetype == 'auto': + if mimetype is True: if download and download != True: mimetype, encoding = mimetypes.guess_type(download) else: @@ -2690,7 +2703,8 @@ def static_file(filename, root, if encoding: headers['Content-Encoding'] = encoding if mimetype: - if (mimetype[:5] == 'text/' or mimetype == 'application/javascript') and charset and 'charset' not in mimetype: + if (mimetype[:5] == 'text/' or mimetype == 'application/javascript')\ + and charset and 'charset' not in mimetype: mimetype += '; charset=%s' % charset headers['Content-Type'] = mimetype @@ -2702,21 +2716,33 @@ def static_file(filename, root, headers['Content-Length'] = clen = stats.st_size lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime)) headers['Last-Modified'] = lm + headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()) + + getenv = request.environ.get + + if etag is None: + etag = '%d:%d:%d:%d:%s' % (stats.st_dev, stats.st_ino, stats.st_mtime, + clen, filename) + etag = hashlib.sha1(tob(etag)).hexdigest() + + if etag: + headers['ETag'] = etag + check = getenv('HTTP_IF_NONE_MATCH') + if check and check == etag: + return HTTPResponse(status=304, **headers) - ims = request.environ.get('HTTP_IF_MODIFIED_SINCE') + ims = getenv('HTTP_IF_MODIFIED_SINCE') if ims: ims = parse_date(ims.split(";")[0].strip()) if ims is not None and ims >= int(stats.st_mtime): - headers['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", - time.gmtime()) return HTTPResponse(status=304, **headers) body = '' if request.method == 'HEAD' else open(filename, 'rb') headers["Accept-Ranges"] = "bytes" - ranges = request.environ.get('HTTP_RANGE') - if 'HTTP_RANGE' in request.environ: - ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen)) + range_header = getenv('HTTP_RANGE') + if range_header: + ranges = list(parse_range_header(range_header, clen)) if not ranges: return HTTPError(416, "Requested Range Not Satisfiable") offset, end = ranges[0] diff --git a/test/test_sendfile.py b/test/test_sendfile.py index fad5a57..3de6c50 100755 --- a/test/test_sendfile.py +++ b/test/test_sendfile.py @@ -1,5 +1,6 @@ import unittest from bottle import static_file, request, response, parse_date, parse_range_header, Bottle, tob +import bottle import wsgiref.util import os import tempfile @@ -8,6 +9,9 @@ import time basename = os.path.basename(__file__) root = os.path.dirname(__file__) +basename2 = os.path.basename(bottle.__file__) +root2 = os.path.dirname(bottle.__file__) + class TestDateParser(unittest.TestCase): def test_rfc1123(self): @@ -80,6 +84,26 @@ class TestSendFile(unittest.TestCase): request.environ['HTTP_IF_MODIFIED_SINCE'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(100)) self.assertEqual(open(__file__,'rb').read(), static_file(basename, root=root).body.read()) + def test_etag(self): + """ SendFile: If-Modified-Since""" + res = static_file(basename, root=root) + self.assertTrue('ETag' in res.headers) + self.assertEqual(200, res.status_code) + etag = res.headers['ETag'] + + request.environ['HTTP_IF_NONE_MATCH'] = etag + res = static_file(basename, root=root) + self.assertTrue('ETag' in res.headers) + self.assertEqual(etag, res.headers['ETag']) + self.assertEqual(304, res.status_code) + + request.environ['HTTP_IF_NONE_MATCH'] = etag + res = static_file(basename2, root=root2) + self.assertTrue('ETag' in res.headers) + self.assertNotEqual(etag, res.headers['ETag']) + self.assertEqual(200, res.status_code) + + def test_download(self): """ SendFile: Download as attachment """ f = static_file(basename, root=root, download="foo.mp3") |