Skip to content

Commit 10ec9d9

Browse files
committed
Address all review remarks
1 parent 68ccd2c commit 10ec9d9

File tree

8 files changed

+93
-28
lines changed

8 files changed

+93
-28
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,16 @@ license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubu
2929
sequenceDiagram
3030
autonumber
3131
actor Browser
32+
participant S3
33+
participant Middleware
34+
Browser->>Django: GET form view
35+
activate Django
36+
Django->>Browser: RESPONSE w/ presigned POST URL & signed middleware key
37+
deactivate Django
3238
Browser->>S3: POST large file
3339
activate S3
3440
S3->>Browser: RESPONSE AWS S3 key
35-
Browser->>Middleware: POST AWS S3 key
41+
Browser->>Middleware: POST AWS S3 key (signed)
3642
activate Middleware
3743
Middleware->>S3: GET AWS S3 key
3844
S3->>Middleware: RESPONSE large file promise

SECURITY.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Security Policy
2+
3+
## Security Considerations
4+
5+
The wake of CVE-2022-24840 revealed the importance to document security considerations.
6+
The following attack vectors have been considered during development. Should there be
7+
a possible vector or consideration missing, please contact the maintainers, as described
8+
below.
9+
10+
We use [pre-signed POST URLs](s3-pre-signed-url) to upload files to AWS S3.
11+
[Django's internal signer](django-signing) is used to sign the upload path and validate
12+
it before fetching files from S3.
13+
14+
Please note, that Django's signer uses the `SECRET_KEY`, rotating the key will void all
15+
signatures.
16+
17+
[s3-pre-signed-url]: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/s3-presigned-urls.html
18+
[django-signing]: https://docs.djangoproject.com/en/stable/topics/signing/
19+
20+
### Upload of malicious files
21+
22+
AWS S3 supports MIME type detection and content-type enforcement.
23+
You can limit the upload of malicious files via the MIME type [accept][accept].
24+
However, this is not a security measure, and you should always validate files before
25+
processing them.
26+
27+
[accept]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept
28+
29+
### Request file injection
30+
31+
Tho files can always be included in a request, CVE-2022-24840 revealed that we need
32+
to consider people injecting any files that reside on your S3 bucket. However, we do
33+
presign the upload location and validate it before fetching files from S3.
34+
35+
### Path traversal & timing attacks
36+
37+
We fetch files from your S3 bucket. This behavior could be used to brute force valid
38+
file names. We mitigate this by signing the allowed upload path and validating it.
39+
The upload path is unique for each file input and request. Therefore, an attacker can
40+
not escape and access any files but the one uploaded by the attacker.
41+
42+
## Reporting a Vulnerability
43+
44+
NEVER open an issue or discussion to report a vulnerability. Please contact one of the
45+
maintainers of the project either via email or Telegram:
46+
47+
48+
* Telegram: [@codingjoe](https://t.me/codingjoe)

s3file/forms.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import uuid
55

66
from django.conf import settings
7-
from django.core import signing
87
from django.utils.functional import cached_property
98
from storages.utils import safe_join
109

10+
from s3file.middleware import S3FileMiddleware
1111
from s3file.storages import storage
1212

1313
logger = logging.getLogger("s3file")
@@ -50,11 +50,10 @@ def build_attrs(self, *args, **kwargs):
5050
"data-fields-%s" % key: value for key, value in response["fields"].items()
5151
}
5252
defaults["data-url"] = response["url"]
53-
signer = signing.Signer(
54-
salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
53+
# we sign upload location, and will only accept files within the same folder
54+
defaults["data-s3f-signature"] = S3FileMiddleware.sign_s3_key_prefix(
55+
self.upload_folder
5556
)
56-
print(self.upload_folder)
57-
defaults["data-s3f-signature"] = signer.signature(self.upload_folder)
5857
defaults.update(attrs)
5958

6059
try:

s3file/middleware.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from django.utils.crypto import constant_time_compare
77

88
from . import views
9-
from .forms import S3FileInputMixin
109
from .storages import local_dev, storage
1110

1211
logger = logging.getLogger("s3file")
@@ -38,21 +37,18 @@ def __call__(self, request):
3837

3938
return self.get_response(request)
4039

41-
@staticmethod
42-
def get_files_from_storage(paths, signature):
40+
@classmethod
41+
def get_files_from_storage(cls, paths, signature):
4342
"""Return S3 file where the name does not include the path."""
4443
try:
4544
location = storage.aws_location
4645
except AttributeError:
4746
location = storage.location
48-
signer = signing.Signer(
49-
salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
50-
)
5147
for path in paths:
5248
path = pathlib.PurePosixPath(path)
53-
print(path)
54-
print(signer.signature(path.parent), signature)
55-
if not constant_time_compare(signer.signature(path.parent), signature):
49+
if not constant_time_compare(
50+
cls.sign_s3_key_prefix(path.parent), signature
51+
):
5652
raise PermissionDenied("Illegal signature!")
5753
try:
5854
relative_path = str(path.relative_to(location))
@@ -67,3 +63,12 @@ def get_files_from_storage(paths, signature):
6763
yield f
6864
except (OSError, ValueError):
6965
logger.exception("File not found: %s", path)
66+
67+
@classmethod
68+
def sign_s3_key_prefix(cls, path):
69+
"""
70+
Signature to validate the S3 keys passed the middleware before fetching files.
71+
72+
Return a base64-encoded HMAC-SHA256 of the upload folder aka the S3 key-prefix.
73+
"""
74+
return signing.Signer(salt="s3file.middleware.S3FileMiddleware").signature(path)

s3file/views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import hashlib
33
import hmac
44
import logging
5-
from pathlib import Path
65

76
from django import http
87
from django.conf import settings

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def driver():
2525

2626
@pytest.fixture
2727
def freeze_upload_folder(monkeypatch):
28-
"""Freeze datetime and UUID."""
28+
"""Freeze the upload folder which by default contains a random UUID v4."""
2929
upload_folder = Path(storage.aws_location) / "tmp" / "s3file"
3030
monkeypatch.setattr(
3131
"s3file.forms.S3FileInputMixin.upload_folder",

tests/test_forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_value_from_datadict(self, freeze_upload_folder, client, upload_file):
3838
reverse("upload"),
3939
{
4040
"file": f"custom/location/{uploaded_file}",
41-
"file-s3f-signature": "m94qBxBsnMIuIICiY133kX18KkllSPMVbhGAdAwNn1A",
41+
"file-s3f-signature": "FxQXie3wnVnCUFqGzFZ8DCFKAXFA3bnQ8tE96U11o80",
4242
"s3file": "file",
4343
},
4444
)
@@ -88,7 +88,7 @@ def test_build_attr(self, freeze_upload_folder):
8888
}
8989
assert (
9090
ClearableFileInput().build_attrs({})["data-s3f-signature"]
91-
== "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc"
91+
== "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI"
9292
)
9393
assert ClearableFileInput().build_attrs({})["class"] == "s3file"
9494
assert (

tests/test_middleware.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22

33
import pytest
4-
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
4+
from django.core.exceptions import PermissionDenied
55
from django.core.files.base import ContentFile
66
from django.core.files.uploadedfile import SimpleUploadedFile
77

@@ -17,7 +17,7 @@ def test_get_files_from_storage(self, freeze_upload_folder):
1717
)
1818
files = S3FileMiddleware.get_files_from_storage(
1919
[os.path.join(storage.aws_location, name)],
20-
"tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
20+
"VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
2121
)
2222
file = next(files)
2323
assert file.read() == content
@@ -35,7 +35,7 @@ def test_process_request(self, freeze_upload_folder, rf):
3535
data={
3636
"file": "custom/location/tmp/s3file/s3_file.txt",
3737
"s3file": "file",
38-
"file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
38+
"file-s3f-signature": "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
3939
},
4040
)
4141
S3FileMiddleware(lambda x: None)(request)
@@ -49,7 +49,7 @@ def test_process_request__location_escape(self, freeze_upload_folder, rf):
4949
data={
5050
"file": "custom/location/secrets/passwords.txt",
5151
"s3file": "file",
52-
"file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
52+
"file-s3f-signature": "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
5353
},
5454
)
5555
with pytest.raises(PermissionDenied) as e:
@@ -66,8 +66,8 @@ def test_process_request__multiple_files(self, freeze_upload_folder, rf):
6666
"custom/location/tmp/s3file/s3_file.txt",
6767
"custom/location/tmp/s3file/s3_other_file.txt",
6868
],
69-
"file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
70-
"other_file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
69+
"file-s3f-signature": "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
70+
"other_file-s3f-signature": "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
7171
"s3file": ["file", "other_file"],
7272
},
7373
)
@@ -88,9 +88,9 @@ def test_process_request__no_location(self, freeze_upload_folder, rf, settings):
8888
request = rf.post(
8989
"/",
9090
data={
91-
"file": f"tmp/s3file/s3_file.txt",
91+
"file": "tmp/s3file/s3_file.txt",
9292
"s3file": "file",
93-
"file-s3f-signature": "scjzm3N8njBQIVSGEhOchtM0TkGyb2U6OXGLVlRUZhY",
93+
"file-s3f-signature": "pJYaM4x7RzLDLVXWuphK2dMqqc0oLr_jZFasfGU7BhU",
9494
},
9595
)
9696
S3FileMiddleware(lambda x: None)(request)
@@ -103,7 +103,7 @@ def test_process_request__no_file(self, freeze_upload_folder, rf, caplog):
103103
data={
104104
"file": "custom/location/tmp/s3file/does_not_exist.txt",
105105
"s3file": "file",
106-
"file-s3f-signature": "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc",
106+
"file-s3f-signature": "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI",
107107
},
108108
)
109109
S3FileMiddleware(lambda x: None)(request)
@@ -119,6 +119,7 @@ def test_process_request__no_signature(self, rf, caplog):
119119
)
120120
with pytest.raises(PermissionDenied) as e:
121121
S3FileMiddleware(lambda x: None)(request)
122+
assert "No signature provided." in str(e.value)
122123

123124
def test_process_request__wrong_signature(self, rf, caplog):
124125
request = rf.post(
@@ -131,3 +132,10 @@ def test_process_request__wrong_signature(self, rf, caplog):
131132
)
132133
with pytest.raises(PermissionDenied) as e:
133134
S3FileMiddleware(lambda x: None)(request)
135+
assert "Illegal signature!" in str(e.value)
136+
137+
def test_sign_s3_key_prefix(self, rf):
138+
assert (
139+
S3FileMiddleware.sign_s3_key_prefix("test/test")
140+
== "a8KINhIf1IpSD5sgdXE4wEQodZorq_8CmwkqZ5V6nr4"
141+
)

0 commit comments

Comments
 (0)