Skip to content

Commit 68ccd2c

Browse files
committed
Fix CVE-XXXX-XXXX -- Fix Path Traversal security vulnerability
1 parent 03f6ff8 commit 68ccd2c

File tree

7 files changed

+187
-58
lines changed

7 files changed

+187
-58
lines changed

s3file/forms.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import uuid
55

66
from django.conf import settings
7+
from django.core import signing
78
from django.utils.functional import cached_property
89
from storages.utils import safe_join
910

@@ -16,10 +17,14 @@ class S3FileInputMixin:
1617
"""FileInput that uses JavaScript to directly upload to Amazon S3."""
1718

1819
needs_multipart_form = False
19-
upload_path = str(
20-
getattr(settings, "S3FILE_UPLOAD_PATH", pathlib.PurePosixPath("tmp", "s3file"))
20+
upload_path = safe_join(
21+
str(storage.aws_location),
22+
str(
23+
getattr(
24+
settings, "S3FILE_UPLOAD_PATH", pathlib.PurePosixPath("tmp", "s3file")
25+
)
26+
),
2127
)
22-
upload_path = safe_join(str(storage.location), upload_path)
2328
expires = settings.SESSION_COOKIE_AGE
2429

2530
@property
@@ -45,6 +50,11 @@ def build_attrs(self, *args, **kwargs):
4550
"data-fields-%s" % key: value for key, value in response["fields"].items()
4651
}
4752
defaults["data-url"] = response["url"]
53+
signer = signing.Signer(
54+
salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
55+
)
56+
print(self.upload_folder)
57+
defaults["data-s3f-signature"] = signer.signature(self.upload_folder)
4858
defaults.update(attrs)
4959

5060
try:

s3file/middleware.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import logging
22
import pathlib
33

4-
from s3file.storages import local_dev, storage
4+
from django.core import signing
5+
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
6+
from django.utils.crypto import constant_time_compare
57

68
from . import views
9+
from .forms import S3FileInputMixin
10+
from .storages import local_dev, storage
711

812
logger = logging.getLogger("s3file")
913

@@ -15,25 +19,50 @@ def __init__(self, get_response):
1519
def __call__(self, request):
1620
file_fields = request.POST.getlist("s3file")
1721
for field_name in file_fields:
22+
1823
paths = request.POST.getlist(field_name)
19-
request.FILES.setlist(field_name, list(self.get_files_from_storage(paths)))
24+
if paths:
25+
try:
26+
signature = request.POST[f"{field_name}-s3f-signature"]
27+
except KeyError:
28+
raise PermissionDenied("No signature provided.")
29+
try:
30+
request.FILES.setlist(
31+
field_name, list(self.get_files_from_storage(paths, signature))
32+
)
33+
except SuspiciousFileOperation as e:
34+
raise PermissionDenied("Illegal file name!") from e
2035

2136
if local_dev and request.path == "/__s3_mock__/":
2237
return views.S3MockView.as_view()(request)
2338

2439
return self.get_response(request)
2540

2641
@staticmethod
27-
def get_files_from_storage(paths):
42+
def get_files_from_storage(paths, signature):
2843
"""Return S3 file where the name does not include the path."""
44+
try:
45+
location = storage.aws_location
46+
except AttributeError:
47+
location = storage.location
48+
signer = signing.Signer(
49+
salt=f"{S3FileInputMixin.__module__}.{S3FileInputMixin.__name__}"
50+
)
2951
for path in paths:
3052
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):
56+
raise PermissionDenied("Illegal signature!")
3157
try:
32-
location = storage.aws_location
33-
except AttributeError:
34-
location = storage.location
58+
relative_path = str(path.relative_to(location))
59+
except ValueError as e:
60+
raise SuspiciousFileOperation(
61+
f"Path is not inside the designated upload location: {path}"
62+
) from e
63+
3564
try:
36-
f = storage.open(str(path.relative_to(location)))
65+
f = storage.open(relative_path)
3766
f.name = path.name
3867
yield f
3968
except (OSError, ValueError):

s3file/static/s3file/js/s3file.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@
9494
hiddenFileInput.name = name
9595
hiddenFileInput.value = parseURL(result)
9696
form.appendChild(hiddenFileInput)
97+
var hiddenSignatureInput = document.createElement('input')
98+
hiddenSignatureInput.type = 'hidden'
99+
hiddenSignatureInput.name = name + '-s3f-signature'
100+
console.log(fileInput.dataset.s3fSignature)
101+
hiddenSignatureInput.value = fileInput.dataset.s3fSignature
102+
form.appendChild(hiddenSignatureInput)
97103
})
98104
fileInput.name = ''
99105
window.uploading -= 1

s3file/views.py

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

67
from django import http
78
from django.conf import settings

tests/conftest.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import os
21
import tempfile
2+
from pathlib import Path
33

44
import pytest
55
from django.core.files.base import ContentFile
66
from django.utils.encoding import force_str
77
from selenium import webdriver
88
from selenium.common.exceptions import WebDriverException
99

10+
from s3file.storages import storage
11+
1012

1113
@pytest.fixture(scope="session")
1214
def driver():
@@ -22,30 +24,49 @@ def driver():
2224

2325

2426
@pytest.fixture
25-
def upload_file(request):
26-
path = tempfile.mkdtemp()
27-
file_name = os.path.join(path, "%s.txt" % request.node.name)
28-
with open(file_name, "w") as f:
27+
def freeze_upload_folder(monkeypatch):
28+
"""Freeze datetime and UUID."""
29+
upload_folder = Path(storage.aws_location) / "tmp" / "s3file"
30+
monkeypatch.setattr(
31+
"s3file.forms.S3FileInputMixin.upload_folder",
32+
str(upload_folder),
33+
)
34+
return upload_folder
35+
36+
37+
@pytest.fixture
38+
def upload_file(request, freeze_upload_folder):
39+
path = Path(tempfile.mkdtemp()) / freeze_upload_folder / f"{request.node.name}.txt"
40+
path.parent.mkdir(parents=True, exist_ok=True)
41+
with path.open("w") as f:
2942
f.write(request.node.name)
30-
return file_name
43+
return str(path.absolute())
3144

3245

3346
@pytest.fixture
34-
def another_upload_file(request):
35-
path = tempfile.mkdtemp()
36-
file_name = os.path.join(path, "another_%s.txt" % request.node.name)
37-
with open(file_name, "w") as f:
47+
def another_upload_file(request, freeze_upload_folder):
48+
path = (
49+
Path(tempfile.mkdtemp())
50+
/ freeze_upload_folder
51+
/ f"another_{request.node.name}.txt"
52+
)
53+
path.parent.mkdir(parents=True, exist_ok=True)
54+
with path.open("w") as f:
3855
f.write(request.node.name)
39-
return file_name
56+
return str(path.absolute())
4057

4158

4259
@pytest.fixture
43-
def yet_another_upload_file(request):
44-
path = tempfile.mkdtemp()
45-
file_name = os.path.join(path, "yet_another_%s.txt" % request.node.name)
46-
with open(file_name, "w") as f:
60+
def yet_another_upload_file(request, freeze_upload_folder):
61+
path = (
62+
Path(tempfile.mkdtemp())
63+
/ freeze_upload_folder
64+
/ f"yet_another_{request.node.name}.txt"
65+
)
66+
path.parent.mkdir(parents=True, exist_ok=True)
67+
with path.open("w") as f:
4768
f.write(request.node.name)
48-
return file_name
69+
return str(path.absolute())
4970

5071

5172
@pytest.fixture

tests/test_forms.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,15 @@ class TestS3FileInput:
3131
def url(self):
3232
return reverse("upload")
3333

34-
@pytest.fixture
35-
def freeze(self, monkeypatch):
36-
"""Freeze datetime and UUID."""
37-
monkeypatch.setattr(
38-
"s3file.forms.S3FileInputMixin.upload_folder",
39-
os.path.join(storage.aws_location, "tmp"),
40-
)
41-
42-
def test_value_from_datadict(self, client, upload_file):
43-
print(storage.location)
34+
def test_value_from_datadict(self, freeze_upload_folder, client, upload_file):
4435
with open(upload_file) as f:
45-
uploaded_file = storage.save("test.jpg", f)
36+
uploaded_file = storage.save(freeze_upload_folder / "test.jpg", f)
4637
response = client.post(
4738
reverse("upload"),
4839
{
49-
"file": json.dumps([uploaded_file]),
50-
"s3file": '["file"]',
40+
"file": f"custom/location/{uploaded_file}",
41+
"file-s3f-signature": "m94qBxBsnMIuIICiY133kX18KkllSPMVbhGAdAwNn1A",
42+
"s3file": "file",
5143
},
5244
)
5345

@@ -82,7 +74,7 @@ def test_clear(self, filemodel):
8274
assert form.is_valid()
8375
assert not form.cleaned_data["file"]
8476

85-
def test_build_attr(self):
77+
def test_build_attr(self, freeze_upload_folder):
8678
assert set(ClearableFileInput().build_attrs({}).keys()) == {
8779
"class",
8880
"data-url",
@@ -92,21 +84,26 @@ def test_build_attr(self):
9284
"data-fields-x-amz-credential",
9385
"data-fields-policy",
9486
"data-fields-key",
87+
"data-s3f-signature",
9588
}
89+
assert (
90+
ClearableFileInput().build_attrs({})["data-s3f-signature"]
91+
== "tFV9nGZlq9WX1I5Sotit18z1f4C_3lPnj33_zo4LZRc"
92+
)
9693
assert ClearableFileInput().build_attrs({})["class"] == "s3file"
9794
assert (
9895
ClearableFileInput().build_attrs({"class": "my-class"})["class"]
9996
== "my-class s3file"
10097
)
10198

102-
def test_get_conditions(self, freeze):
99+
def test_get_conditions(self, freeze_upload_folder):
103100
conditions = ClearableFileInput().get_conditions(None)
104101
assert all(
105102
condition in conditions
106103
for condition in [
107104
{"bucket": "test-bucket"},
108105
{"success_action_status": "201"},
109-
["starts-with", "$key", "custom/location/tmp"],
106+
["starts-with", "$key", "custom/location/tmp/s3file"],
110107
["starts-with", "$Content-Type", ""],
111108
]
112109
), conditions
@@ -145,20 +142,24 @@ def test_no_js_error(self, driver, live_server):
145142
error = driver.find_element(By.XPATH, "//body[@JSError]")
146143
pytest.fail(error.get_attribute("JSError"))
147144

148-
def test_file_insert(self, request, driver, live_server, upload_file, freeze):
145+
def test_file_insert(
146+
self, request, driver, live_server, upload_file, freeze_upload_folder
147+
):
149148
driver.get(live_server + self.url)
150149
file_input = driver.find_element(By.XPATH, "//input[@name='file']")
151150
file_input.send_keys(upload_file)
152151
assert file_input.get_attribute("name") == "file"
153152
with wait_for_page_load(driver, timeout=10):
154153
file_input.submit()
155-
assert storage.exists("tmp/%s.txt" % request.node.name)
154+
assert storage.exists("tmp/s3file/%s.txt" % request.node.name)
156155

157156
with pytest.raises(NoSuchElementException):
158157
error = driver.find_element(By.XPATH, "//body[@JSError]")
159158
pytest.fail(error.get_attribute("JSError"))
160159

161-
def test_file_insert_submit_value(self, driver, live_server, upload_file, freeze):
160+
def test_file_insert_submit_value(
161+
self, driver, live_server, upload_file, freeze_upload_folder
162+
):
162163
driver.get(live_server + self.url)
163164
file_input = driver.find_element(By.XPATH, "//input[@name='file']")
164165
file_input.send_keys(upload_file)
@@ -178,7 +179,7 @@ def test_file_insert_submit_value(self, driver, live_server, upload_file, freeze
178179
assert "save_continue" in driver.page_source
179180
assert "continue_value" in driver.page_source
180181

181-
def test_progress(self, driver, live_server, upload_file, freeze):
182+
def test_progress(self, driver, live_server, upload_file, freeze_upload_folder):
182183
driver.get(live_server + self.url)
183184
file_input = driver.find_element(By.XPATH, "//input[@name='file']")
184185
file_input.send_keys(upload_file)
@@ -202,16 +203,23 @@ def test_multi_file(
202203
self,
203204
driver,
204205
live_server,
205-
freeze,
206+
freeze_upload_folder,
206207
upload_file,
207208
another_upload_file,
208209
yet_another_upload_file,
209210
):
210211
driver.get(live_server + self.url)
211212
file_input = driver.find_element(By.XPATH, "//input[@name='file']")
212-
file_input.send_keys(" \n ".join([upload_file, another_upload_file]))
213+
file_input.send_keys(
214+
" \n ".join(
215+
[
216+
str(freeze_upload_folder / upload_file),
217+
str(freeze_upload_folder / another_upload_file),
218+
]
219+
)
220+
)
213221
file_input = driver.find_element(By.XPATH, "//input[@name='other_file']")
214-
file_input.send_keys(yet_another_upload_file)
222+
file_input.send_keys(str(freeze_upload_folder / yet_another_upload_file))
215223
save_button = driver.find_element(By.XPATH, "//input[@name='save']")
216224
with wait_for_page_load(driver, timeout=10):
217225
save_button.click()

0 commit comments

Comments
 (0)