Skip to content
This repository was archived by the owner on Mar 27, 2025. It is now read-only.

Commit f870c52

Browse files
moggers87gipi
andauthored
Merge pull request from GHSA-6r3c-8xf3-ggrr
* Fix directory traversal bug First try for the fix. Sanitization is done always on the url path. Removed _convert_file_to_url() * Restore use of _convert_file_to_url This is now done for all backends, assuming that SENDFILE_URL is set Also don't keep converting from Path object to str and back again * Fix example application so it works again * Update docs Co-authored-by: Gianluca Pacchiella <[email protected]>
1 parent 7223947 commit f870c52

File tree

14 files changed

+178
-71
lines changed

14 files changed

+178
-71
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
.tox
1414
/examples/protected_downloads/htmlcov
1515
/examples/protected_downloads/.coverage
16+
/examples/protected_downloads/protected

django_sendfile/backends/_internalredirect.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

django_sendfile/backends/mod_wsgi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from django.http import HttpResponse
44

5-
from ._internalredirect import _convert_file_to_url
5+
from ..utils import _convert_file_to_url
66

77

88
def sendfile(request, filename, **kwargs):

django_sendfile/backends/nginx.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
from django.http import HttpResponse
44

5-
from ._internalredirect import _convert_file_to_url
5+
from ..utils import _convert_file_to_url
66

77

88
def sendfile(request, filename, **kwargs):
99
response = HttpResponse()
10-
url = _convert_file_to_url(filename)
11-
response['X-Accel-Redirect'] = url.encode('utf-8')
10+
response['X-Accel-Redirect'] = _convert_file_to_url(filename)
1211

1312
return response

django_sendfile/backends/simple.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
11
from email.utils import mktime_tz, parsedate_tz
2-
import os
32
import re
4-
import stat
53

64
from django.core.files.base import File
75
from django.http import HttpResponse, HttpResponseNotModified
86
from django.utils.http import http_date
97

108

11-
def sendfile(request, filename, **kwargs):
12-
# Respect the If-Modified-Since header.
13-
statobj = os.stat(filename)
9+
def sendfile(request, filepath, **kwargs):
10+
'''Use the SENDFILE_ROOT value composed with the path arrived as argument
11+
to build an absolute path with which resolve and return the file contents.
12+
13+
If the path points to a file out of the root directory (should cover both
14+
situations with '..' and symlinks) then a 404 is raised.
15+
'''
16+
statobj = filepath.stat()
1417

18+
# Respect the If-Modified-Since header.
1519
if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'),
16-
statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]):
20+
statobj.st_mtime, statobj.st_size):
1721
return HttpResponseNotModified()
1822

19-
with File(open(filename, 'rb')) as f:
23+
with File(filepath.open('rb')) as f:
2024
response = HttpResponse(f.chunks())
2125

22-
response["Last-Modified"] = http_date(statobj[stat.ST_MTIME])
26+
response["Last-Modified"] = http_date(statobj.st_mtime)
2327
return response
2428

2529

django_sendfile/tests.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import shutil
77

88
from django.conf import settings
9+
from django.core.exceptions import ImproperlyConfigured
910
from django.http import Http404, HttpRequest, HttpResponse
1011
from django.test import TestCase
1112
from django.utils.encoding import smart_str
@@ -25,12 +26,22 @@ class TempFileTestCase(TestCase):
2526
def setUp(self):
2627
super(TempFileTestCase, self).setUp()
2728
self.TEMP_FILE_ROOT = mkdtemp()
29+
self.setSendfileRoot(self.TEMP_FILE_ROOT)
2830

2931
def tearDown(self):
3032
super(TempFileTestCase, self).tearDown()
3133
if os.path.exists(self.TEMP_FILE_ROOT):
3234
shutil.rmtree(self.TEMP_FILE_ROOT)
3335

36+
def setSendfileBackend(self, backend):
37+
'''set the backend clearing the cache'''
38+
settings.SENDFILE_BACKEND = backend
39+
_get_sendfile.cache_clear()
40+
41+
def setSendfileRoot(self, path):
42+
'''set the backend clearing the cache'''
43+
settings.SENDFILE_ROOT = path
44+
3445
def ensure_file(self, filename):
3546
path = os.path.join(self.TEMP_FILE_ROOT, filename)
3647
if not os.path.exists(path):
@@ -43,12 +54,21 @@ class TestSendfile(TempFileTestCase):
4354
def setUp(self):
4455
super(TestSendfile, self).setUp()
4556
# set ourselves to be the sendfile backend
46-
settings.SENDFILE_BACKEND = 'django_sendfile.tests'
47-
_get_sendfile.cache_clear()
57+
self.setSendfileBackend('django_sendfile.tests')
4858

4959
def _get_readme(self):
5060
return self.ensure_file('testfile.txt')
5161

62+
def test_backend_is_none(self):
63+
self.setSendfileBackend(None)
64+
with self.assertRaises(ImproperlyConfigured):
65+
real_sendfile(HttpRequest(), "notafile.txt")
66+
67+
def test_root_is_none(self):
68+
self.setSendfileRoot(None)
69+
with self.assertRaises(ImproperlyConfigured):
70+
real_sendfile(HttpRequest(), "notafile.txt")
71+
5272
def test_404(self):
5373
try:
5474
real_sendfile(HttpRequest(), 'fhdsjfhjk.txt')
@@ -102,12 +122,33 @@ def test_attachment_filename_unicode(self):
102122
response['Content-Disposition'])
103123

104124

125+
class TestSimpleSendfileBackend(TempFileTestCase):
126+
127+
def setUp(self):
128+
super().setUp()
129+
self.setSendfileBackend('django_sendfile.backends.simple')
130+
131+
def test_correct_file(self):
132+
filepath = self.ensure_file('readme.txt')
133+
response = real_sendfile(HttpRequest(), filepath)
134+
self.assertTrue(response is not None)
135+
136+
def test_containing_unicode(self):
137+
filepath = self.ensure_file(u'péter_là_gueule.txt')
138+
response = real_sendfile(HttpRequest(), filepath)
139+
self.assertTrue(response is not None)
140+
141+
def test_sensible_file_access_in_simplesendfile(self):
142+
filepath = self.ensure_file('../../../etc/passwd')
143+
with self.assertRaises(Http404):
144+
real_sendfile(HttpRequest(), filepath)
145+
146+
105147
class TestXSendfileBackend(TempFileTestCase):
106148

107149
def setUp(self):
108150
super(TestXSendfileBackend, self).setUp()
109-
settings.SENDFILE_BACKEND = 'django_sendfile.backends.xsendfile'
110-
_get_sendfile.cache_clear()
151+
self.setSendfileBackend('django_sendfile.backends.xsendfile')
111152

112153
def test_correct_file_in_xsendfile_header(self):
113154
filepath = self.ensure_file('readme.txt')
@@ -126,41 +167,59 @@ class TestNginxBackend(TempFileTestCase):
126167

127168
def setUp(self):
128169
super(TestNginxBackend, self).setUp()
129-
settings.SENDFILE_BACKEND = 'django_sendfile.backends.nginx'
130-
settings.SENDFILE_ROOT = self.TEMP_FILE_ROOT
170+
self.setSendfileBackend('django_sendfile.backends.nginx')
131171
settings.SENDFILE_URL = '/private'
132-
_get_sendfile.cache_clear()
172+
173+
def test_sendfile_url_not_set(self):
174+
settings.SENDFILE_URL = None
175+
filepath = self.ensure_file('readme.txt')
176+
response = real_sendfile(HttpRequest(), filepath)
177+
self.assertTrue(response is not None)
178+
self.assertEqual(response.content, b'')
179+
self.assertEqual(os.path.join(self.TEMP_FILE_ROOT, 'readme.txt'),
180+
response['X-Accel-Redirect'])
133181

134182
def test_correct_url_in_xaccelredirect_header(self):
135183
filepath = self.ensure_file('readme.txt')
136184
response = real_sendfile(HttpRequest(), filepath)
137185
self.assertTrue(response is not None)
186+
self.assertEqual(response.content, b'')
138187
self.assertEqual('/private/readme.txt', response['X-Accel-Redirect'])
139188

140189
def test_xaccelredirect_header_containing_unicode(self):
141190
filepath = self.ensure_file(u'péter_là_gueule.txt')
142191
response = real_sendfile(HttpRequest(), filepath)
143192
self.assertTrue(response is not None)
193+
self.assertEqual(response.content, b'')
144194
self.assertEqual('/private/péter_là_gueule.txt', unquote(response['X-Accel-Redirect']))
145195

146196

147197
class TestModWsgiBackend(TempFileTestCase):
148198

149199
def setUp(self):
150200
super(TestModWsgiBackend, self).setUp()
151-
settings.SENDFILE_BACKEND = 'django_sendfile.backends.mod_wsgi'
152-
settings.SENDFILE_ROOT = self.TEMP_FILE_ROOT
201+
self.setSendfileBackend('django_sendfile.backends.mod_wsgi')
153202
settings.SENDFILE_URL = '/private'
154-
_get_sendfile.cache_clear()
203+
204+
def test_sendfile_url_not_set(self):
205+
settings.SENDFILE_URL = None
206+
filepath = self.ensure_file('readme.txt')
207+
response = real_sendfile(HttpRequest(), filepath)
208+
self.assertTrue(response is not None)
209+
self.assertEqual(response.content, b'')
210+
self.assertEqual(os.path.join(self.TEMP_FILE_ROOT, 'readme.txt'),
211+
response['Location'])
155212

156213
def test_correct_url_in_location_header(self):
157214
filepath = self.ensure_file('readme.txt')
158215
response = real_sendfile(HttpRequest(), filepath)
159216
self.assertTrue(response is not None)
217+
self.assertEqual(response.content, b'')
160218
self.assertEqual('/private/readme.txt', response['Location'])
161219

162220
def test_location_header_containing_unicode(self):
163221
filepath = self.ensure_file(u'péter_là_gueule.txt')
164222
response = real_sendfile(HttpRequest(), filepath)
165223
self.assertTrue(response is not None)
224+
self.assertEqual(response.content, b'')
166225
self.assertEqual('/private/péter_là_gueule.txt', unquote(response['Location']))

django_sendfile/utils.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from functools import lru_cache
22
from importlib import import_module
33
from mimetypes import guess_type
4-
import os.path
4+
from pathlib import Path, PurePath
5+
from urllib.parse import quote
6+
import logging
57
import unicodedata
68

79
from django.conf import settings
@@ -10,6 +12,8 @@
1012
from django.utils.encoding import force_str
1113
from django.utils.http import urlquote
1214

15+
logger = logging.getLogger(__name__)
16+
1317

1418
@lru_cache(maxsize=None)
1519
def _get_sendfile():
@@ -20,6 +24,45 @@ def _get_sendfile():
2024
return module.sendfile
2125

2226

27+
def _convert_file_to_url(path):
28+
try:
29+
url_root = PurePath(getattr(settings, "SENDFILE_URL", None))
30+
except TypeError:
31+
return path
32+
33+
path_root = PurePath(settings.SENDFILE_ROOT)
34+
path_obj = PurePath(path)
35+
36+
relpath = path_obj.relative_to(path_root)
37+
# Python 3.5: Path.resolve() has no `strict` kwarg, so use pathmod from an
38+
# already instantiated Path object
39+
url = relpath._flavour.pathmod.normpath(str(url_root / relpath))
40+
41+
return quote(str(url))
42+
43+
44+
def _sanitize_path(filepath):
45+
try:
46+
path_root = Path(getattr(settings, 'SENDFILE_ROOT', None))
47+
except TypeError:
48+
raise ImproperlyConfigured('You must specify a value for SENDFILE_ROOT')
49+
50+
filepath_obj = Path(filepath)
51+
52+
# get absolute path
53+
# Python 3.5: Path.resolve() has no `strict` kwarg, so use pathmod from an
54+
# already instantiated Path object
55+
filepath_abs = Path(filepath_obj._flavour.pathmod.normpath(str(path_root / filepath_obj)))
56+
57+
# if filepath_abs is not relative to path_root, relative_to throws an error
58+
try:
59+
filepath_abs.relative_to(path_root)
60+
except ValueError:
61+
raise Http404('{} wrt {} is impossible'.format(filepath_abs, path_root))
62+
63+
return filepath_abs
64+
65+
2366
def sendfile(request, filename, attachment=False, attachment_filename=None,
2467
mimetype=None, encoding=None):
2568
"""
@@ -41,25 +84,28 @@ def sendfile(request, filename, attachment=False, attachment_filename=None,
4184
If neither ``mimetype`` or ``encoding`` are specified, then they will be guessed via the
4285
filename (using the standard Python mimetypes module)
4386
"""
87+
filepath_obj = _sanitize_path(filename)
88+
logger.debug('filename \'%s\' requested "\
89+
"-> filepath \'%s\' obtained', filename, filepath_obj)
4490
_sendfile = _get_sendfile()
4591

46-
if not os.path.exists(filename):
47-
raise Http404('"%s" does not exist' % filename)
92+
if not filepath_obj.exists():
93+
raise Http404('"%s" does not exist' % filepath_obj)
4894

49-
guessed_mimetype, guessed_encoding = guess_type(filename)
95+
guessed_mimetype, guessed_encoding = guess_type(str(filepath_obj))
5096
if mimetype is None:
5197
if guessed_mimetype:
5298
mimetype = guessed_mimetype
5399
else:
54100
mimetype = 'application/octet-stream'
55101

56-
response = _sendfile(request, filename, mimetype=mimetype)
102+
response = _sendfile(request, filepath_obj, mimetype=mimetype)
57103

58104
# Suggest to view (inline) or download (attachment) the file
59105
parts = ['attachment' if attachment else 'inline']
60106

61107
if attachment_filename is None:
62-
attachment_filename = os.path.basename(filename)
108+
attachment_filename = filepath_obj.name
63109

64110
if attachment_filename:
65111
attachment_filename = force_str(attachment_filename)
@@ -73,7 +119,7 @@ def sendfile(request, filename, attachment=False, attachment_filename=None,
73119

74120
response['Content-Disposition'] = '; '.join(parts)
75121

76-
response['Content-length'] = os.path.getsize(filename)
122+
response['Content-length'] = filepath_obj.stat().st_size
77123
response['Content-Type'] = mimetype
78124

79125
if not encoding:

docs/backends.rst

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,8 @@ embedded mode. It requires a bit more work to get it to do the same job as
4343
xsendfile though. However some may find it easier to setup, as they don't need
4444
to compile and install mod_xsendfile_.
4545

46-
Firstly there are two more Django settings:
46+
Firstly there one more Django setting that needs to be given:
4747

48-
* ``SENDFILE_ROOT`` - this is a directoy where all files that will be used with
49-
sendfile must be located
5048
* ``SENDFILE_URL`` - internal URL prefix for all files served via sendfile
5149

5250
These settings are needed as this backend makes mod_wsgi_ send an internal
@@ -93,10 +91,8 @@ Nginx backend
9391

9492
:py:mod:`django_sendfile.backends.nginx`
9593

96-
As with the mod_wsgi backend you need to set two extra settings:
94+
As with the mod_wsgi backend you need to set an extra settings:
9795

98-
* ``SENDFILE_ROOT`` - this is a directory where all files that will be used with
99-
sendfile must be located
10096
* ``SENDFILE_URL`` - internal URL prefix for all files served via sendfile
10197

10298
You then need to configure Nginx to only allow internal access to the files you
@@ -141,12 +137,6 @@ configure mod_xsendfile_, but that should be as simple as:
141137
142138
In your virtualhost file/conf file.
143139

144-
As with the mod_wsgi backend you need to set two extra settings:
145-
146-
* ``SENDFILE_ROOT`` - this is a directory where all files that will be used with
147-
sendfile must be located
148-
* ``SENDFILE_URL`` - internal URL prefix for all files served via sendfile
149-
150140

151141
.. _mod_xsendfile: https://tn123.org/mod_xsendfile/
152142
.. _Apache: http://httpd.apache.org/

0 commit comments

Comments
 (0)