-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add ability to protect media folder #1476
base: main
Are you sure you want to change the base?
Changes from all commits
716fdb9
11ca201
77ce1ea
84df8da
f132fdb
80ecaba
78f7e35
6d13d63
fa3a973
17eecaa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
# Protected media | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Considering this is a separate app, maybe it would be worth noting that it needs to be added to the |
||
|
||
This app provides the possibility to protect the media folder. To use this functionality in production, make sure to configure the PROTECTED_FILE_SYSTEM_STORAGE setting. | ||
|
||
Then specific the following uWSGI settings to protect the media folder: | ||
|
||
```bash | ||
uwsgi \ | ||
--master \ | ||
--http=0.0.0.0:8000 \ | ||
--module=signals.wsgi:application \ | ||
--static-map=/signals/static=./app/static \ | ||
--static-safe=./app/media \ | ||
--offload-threads=2 \ | ||
--collect-header="X-Sendfile X_SENDFILE" \ | ||
--response-route-if-not="empty:${X_SENDFILE} static:${X_SENDFILE}" \ | ||
--buffer-size=32768 \ | ||
--die-on-term \ | ||
--processes=4 \ | ||
--threads=2 | ||
``` | ||
|
||
The relevant settings are `plugins`, `offload-threads`, `collect-header` and `response-route-if-not`. For more information see the [X-Sendfile emulation snippet of the uWSGI documentation](https://uwsgi-docs.readthedocs.io/en/latest/Snippets.html#x-sendfile-emulation). |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. | ||
from django.apps import AppConfig | ||
|
||
|
||
class MediaConfig(AppConfig): | ||
default_auto_field = 'django.db.models.BigAutoField' | ||
name = 'media' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. | ||
from urllib.parse import urljoin | ||
|
||
from django.core import signing | ||
from django.core.files.storage import FileSystemStorage | ||
from django.utils.encoding import filepath_to_uri | ||
|
||
signer = signing.TimestampSigner() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In order to make this more secure, would it be a good idea to pass a salt to the signer? It doesn't seem like a huge deal considering the secret is also used, so as long as that is unique we should still receive acceptable signatures. But better safe than sorry I suppose. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SHA256 is used by default as the hashing algo here. We might want to explicitly specify it to prevent any issues of it changing in the future. That is if we deem SHA256 sufficient for this use case. |
||
|
||
|
||
class ProtectedFileSystemStorage(FileSystemStorage): | ||
def url(self, name): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please use type hints for the arguments and the return type? |
||
if self.base_url is None: | ||
raise ValueError('This file is not accessible via a URL.') | ||
|
||
url = filepath_to_uri(name) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we converting a file path or a file name here? If it's a path, please rename the argument. |
||
if url is not None: | ||
url = url.lstrip('/') | ||
|
||
signature = signer.sign(url).split(':') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please note that if the same file is requested multiple times within the same second the signature that is produced will be exactly the same. Not sure if that is acceptable? |
||
|
||
full_path = urljoin(self.base_url, url) | ||
return full_path + f'?t={signature[1]}&s={signature[2]}' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. | ||
from unittest.mock import patch | ||
|
||
from django.http import HttpResponse | ||
from django.test import TestCase, override_settings | ||
|
||
from signals.apps.media.storages import ProtectedFileSystemStorage | ||
|
||
|
||
@override_settings(PROTECTED_FILE_SYSTEM_STORAGE=True) | ||
class DownloadFileTestCase(TestCase): | ||
def setUp(self): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use type hints for the methods here. It should be as easy as adding |
||
self.storage = ProtectedFileSystemStorage(base_url='http://localhost:8000/signals/media/') | ||
|
||
def test_missing_signature(self): | ||
# Test with missing 't' or 's' parameter | ||
response = self.client.get('/signals/media/test.txt') | ||
self.assertEqual(response.status_code, 401) | ||
self.assertEqual(response.content, b'No signature provided') | ||
|
||
def test_bad_signature(self): | ||
# Test with an invalid signature | ||
response = self.client.get('/signals/media/test.txt?t=some_time&s=some_signature') | ||
self.assertEqual(response.status_code, 401) | ||
self.assertEqual(response.content, b'Bad signature') | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there no test case for expired signatures? |
||
@override_settings(DEBUG=True) | ||
def test_debug_mode_file_serving(self): | ||
# Test serving the file in DEBUG mode | ||
with patch('signals.apps.media.views.serve') as mock_serve: | ||
mock_serve.return_value = HttpResponse('File content') | ||
response = self.client.get(self.storage.url('test.txt')) | ||
self.assertEqual(response.status_code, 200) | ||
self.assertEqual(response.content, b'File content') | ||
mock_serve.assert_called_once() | ||
|
||
@override_settings(DEBUG=False) | ||
def test_production_mode_file_serving(self): | ||
# Test serving the file in production mode | ||
with patch('signals.apps.media.views.mimetypes.guess_type') as mock_mimetype: | ||
mock_mimetype.return_value = 'text/plain', None | ||
response = self.client.get(self.storage.url('test.txt')) | ||
self.assertEqual(response.status_code, 200) | ||
self.assertIn('test.txt', response['X-Sendfile']) | ||
self.assertEqual(response['Content-Type'], 'text/plain') |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. | ||
from django.urls import re_path | ||
|
||
from . import views | ||
|
||
urlpatterns = [ | ||
re_path(r'^(?P<path>.*)$', views.download_file, name='download_file'), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
# SPDX-License-Identifier: MPL-2.0 | ||
# Copyright (C) 2024 Delta10 B.V. | ||
import mimetypes | ||
import os | ||
from datetime import timedelta | ||
|
||
from django.conf import settings | ||
from django.core import signing | ||
from django.http import HttpResponse | ||
from django.views.static import serve | ||
|
||
signer = signing.TimestampSigner() | ||
|
||
|
||
def download_file(request, path): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use type hints for the arguments and return type here. |
||
t = request.GET.get('t') | ||
s = request.GET.get('s') | ||
|
||
if not t or not s: | ||
return HttpResponse('No signature provided', status=401) | ||
|
||
try: | ||
signer.unsign(f'{path}:{t}:{s}', max_age=timedelta(hours=1)) | ||
except signing.SignatureExpired: | ||
return HttpResponse('Signature expired', status=401) | ||
except signing.BadSignature: | ||
return HttpResponse('Bad signature', status=401) | ||
|
||
if settings.DEBUG: | ||
response = serve(request, path, document_root=settings.MEDIA_ROOT, show_indexes=False) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a little nitpicky, but this function is already using early returns, why not just return here as well instead of having the |
||
else: | ||
mimetype, encoding = mimetypes.guess_type(path) | ||
|
||
response = HttpResponse() | ||
|
||
if mimetype: | ||
response['Content-Type'] = mimetype | ||
if encoding: | ||
response['Content-Encoding'] = encoding | ||
|
||
response['X-Sendfile'] = os.path.join( | ||
settings.MEDIA_ROOT, path | ||
).encode('utf8') | ||
|
||
return response |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,10 @@ | |
path('signals/', BaseSignalsAPIRootView.as_view()), | ||
path('signals/', include('signals.apps.api.urls')), | ||
|
||
# The media folder is routed with X-Sendfile when DEBUG=False and | ||
# with the Django static helper when DEBUG=True | ||
path('signals/media/', include('signals.apps.media.urls')), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens here if the app is not configured to be loaded in the settings? |
||
|
||
# The Django admin | ||
path('signals/admin/', admin.site.urls), | ||
re_path(r'^signals/markdownx/', include('markdownx.urls')), | ||
|
@@ -27,12 +31,6 @@ | |
path('signals/sigmax/', include('signals.apps.sigmax.urls')), | ||
] | ||
|
||
if settings.DEBUG: | ||
from django.conf.urls.static import static | ||
|
||
media_root = static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) | ||
urlpatterns += media_root | ||
|
||
if settings.OIDC_RP_CLIENT_ID: | ||
urlpatterns += [ | ||
path('signals/oidc/login_failure/', TemplateView.as_view(template_name='admin/oidc/login_failure.html')), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deze packages zijn nodig om uwsgi met PCRE ondersteuning te bouwen. Dit is nodig voor de X-Sendfile emulation.