Skip to content

Commit 7da6a26

Browse files
committed
Verify user,group/org images upload type, enforce extension
1 parent ab65fd3 commit 7da6a26

File tree

6 files changed

+146
-24
lines changed

6 files changed

+146
-24
lines changed

CHANGELOG.rst

+15
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ Changelog
99

1010
.. towncrier release notes start
1111
12+
v.2.12.0 (Not yet released)
13+
===========================
14+
15+
Migration notes
16+
---------------
17+
18+
* Going forward, if both ``ckan.upload.[type].mimetypes`` and
19+
``ckan.upload.[type].types`` are empty, no uploads will be allowed
20+
for this object type (e.g. ``user`` or ``group``). It previoulsy
21+
meant that all file types were allowed. To keep the old behaviour use
22+
the string ``*`` as value in both options (this is dangerous and
23+
**not** recommended).
24+
25+
26+
1227
v.2.11.1 2024-12-11
1328
===================
1429

ckan/config/config_declaration.yaml

+17-6
Original file line numberDiff line numberDiff line change
@@ -1328,25 +1328,36 @@ groups:
13281328
type: list
13291329
default: image
13301330
example: image text
1331-
description: File types allowed to upload as user's avatar. No restrictions applied when empty
1331+
description: File types allowed to upload as user's avatar. If empty and :ref:`ckan.upload.user.mimetypes`
1332+
is also empty, no uploads are allowed. To allow any kind of file upload, use the ``*`` string in both
1333+
options (this is dangerous and **not** recommended). Note also that ``text/svg`` can contain embedded
1334+
javascript code so it only should be used in trusted environments.
13321335

13331336
- key: ckan.upload.user.mimetypes
13341337
type: list
13351338
default: image/png image/gif image/jpeg
1336-
example: image/png text/svg
1337-
description: File MIMETypes allowed to upload as user's avatar. No restrictions applied when empty
1339+
example: image/png
1340+
description: File MIMETypes allowed to upload as user's avatar. If empty and :ref:`ckan.upload.user.types`
1341+
is also empty, no uploads are allowed. To allow any kind of file upload, use the ``*`` string in both
1342+
options (this is dangerous and **not** recommended).
13381343

13391344
- key: ckan.upload.group.types
13401345
type: list
13411346
default: image
13421347
example: image text
1343-
description: File types allowed to upload as group image. No restrictions applied when empty
1348+
description: File types allowed to upload as group or organization image. If empty
1349+
and :ref:`ckan.upload.group.mimetypes` is also empty, no uploads are allowed. To allow any kind of
1350+
file upload, use the ``*`` string in both options (this is dangerous and **not** recommended).
13441351

13451352
- key: ckan.upload.group.mimetypes
13461353
type: list
13471354
default: image/png image/gif image/jpeg
1348-
example: image/png text/svg
1349-
description: File MIMETypes allowed to upload as group image. No restrictions applied when empty
1355+
example: image/png
1356+
description: File MIMEtypes allowed to upload as group or organization image. If empty
1357+
and :ref:`ckan.upload.group.types` is also empty, no uploads are allowed. To allow any kind of
1358+
file upload, use the ``*`` string in both options (this is dangerous and **not** recommended).
1359+
Note also that ``text/svg`` can contain embedded javascript code so it only should be used in
1360+
trusted environments.
13501361

13511362
- annotation: Webassets Settings
13521363
options:

ckan/lib/uploader.py

+54-13
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import magic
99
import mimetypes
10+
from pathlib import Path
1011
from typing import Any, IO, Optional, Union
1112
from urllib.parse import urlparse
1213

@@ -159,10 +160,14 @@ def update_data_dict(self, data_dict: dict[str, Any], url_field: str,
159160
self.filename = str(datetime.datetime.utcnow()) + self.filename
160161
self.filename = munge.munge_filename_legacy(self.filename)
161162
self.filepath = os.path.join(self.storage_path, self.filename)
162-
data_dict[url_field] = self.filename
163163
self.upload_file = _get_underlying_file(
164164
self.upload_field_storage)
165165
self.tmp_filepath = self.filepath + '~'
166+
167+
self.verify_type()
168+
169+
data_dict[url_field] = self.filename
170+
166171
# keep the file if there has been no change
167172
elif self.old_filename and not self.old_filename.startswith('http'):
168173
if not self.clear:
@@ -177,7 +182,6 @@ def upload(self, max_size: int = 2) -> None:
177182
anything unless the request is actually good.
178183
max_size is size in MB maximum of the file'''
179184

180-
self.verify_type()
181185

182186
if self.filename:
183187
assert self.upload_file and self.filepath
@@ -202,29 +206,66 @@ def upload(self, max_size: int = 2) -> None:
202206
pass
203207

204208
def verify_type(self):
205-
if not self.filename or not self.upload_file:
209+
210+
if not self.upload_file:
206211
return
207212

208-
mimetypes = config.get(
213+
allowed_mimetypes = config.get(
209214
f"ckan.upload.{self.object_type}.mimetypes")
210-
types = config.get(f"ckan.upload.{self.object_type}.types")
211-
if not mimetypes and not types:
212-
return
215+
allowed_types = config.get(f"ckan.upload.{self.object_type}.types")
216+
if not allowed_mimetypes and not allowed_types:
217+
raise logic.ValidationError(
218+
{
219+
self.file_field: [f"No uploads allowed for object type {self.object_type}"]
220+
}
221+
)
222+
223+
# Check that the declared types in the request are supported
224+
declared_mimetype_from_filename = mimetypes.guess_type(
225+
self.upload_field_storage.filename
226+
)[0]
227+
declared_content_type = self.upload_field_storage.content_type
228+
for declared_mimetype in (
229+
declared_mimetype_from_filename,
230+
declared_content_type,
231+
):
232+
if (
233+
declared_mimetype
234+
and allowed_mimetypes
235+
and allowed_mimetypes[0] != "*"
236+
and declared_mimetype not in allowed_mimetypes
237+
):
238+
raise logic.ValidationError(
239+
{
240+
self.file_field: [
241+
f"Unsupported upload type: {declared_mimetype}"
242+
]
243+
}
244+
)
245+
246+
# Check that the actual type guessed from the contents is supported
247+
# (2KB required for detecting xlsx mimetype)
248+
content = self.upload_file.read(2048)
249+
guessed_mimetype = magic.from_buffer(content, mime=True)
213250

214-
# 2KB required for detecting xlsx mimetype
215-
actual = magic.from_buffer(self.upload_file.read(2048), mime=True)
216251
self.upload_file.seek(0, os.SEEK_SET)
252+
217253
err: ErrorDict = {
218-
self.file_field: [f"Unsupported upload type: {actual}"]
254+
self.file_field: [f"Unsupported upload type: {guessed_mimetype}"]
219255
}
220256

221-
if mimetypes and actual not in mimetypes:
257+
if allowed_mimetypes and allowed_mimetypes[0] != "*" and guessed_mimetype not in allowed_mimetypes:
222258
raise logic.ValidationError(err)
223259

224-
type_ = actual.split("/")[0]
225-
if types and type_ not in types:
260+
type_ = guessed_mimetype.split("/")[0]
261+
if allowed_types and allowed_types[0] != "*" and type_ not in allowed_types:
226262
raise logic.ValidationError(err)
227263

264+
preferred_extension = mimetypes.guess_extension(guessed_mimetype)
265+
if preferred_extension:
266+
self.filename = str(Path(self.filename).with_suffix(preferred_extension))
267+
self.filepath = str(Path(self.filepath).with_suffix(preferred_extension))
268+
228269

229270
class ResourceUpload(object):
230271
mimetype: Optional[str]

ckan/tests/cli/test_clean.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ def test_output_if_there_are_not_invalid_users(self, cli):
1111
result = cli.invoke(ckan, ["clean", "users"])
1212
assert "No users were found with invalid images." in result.output
1313

14-
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "")
15-
@pytest.mark.ckan_config("ckan.upload.user.types", "")
14+
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "*")
15+
@pytest.mark.ckan_config("ckan.upload.user.types", "*")
1616
def test_confirm_dialog_if_no_force(
1717
self, cli, monkeypatch, create_with_upload, faker, ckan_config
1818
):
@@ -54,8 +54,8 @@ def test_confirm_dialog_if_no_force(
5454
users = call_action("user_list")
5555
assert len(users) == 2
5656

57-
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "")
58-
@pytest.mark.ckan_config("ckan.upload.user.types", "")
57+
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "*")
58+
@pytest.mark.ckan_config("ckan.upload.user.types", "*")
5959
def test_correct_users_are_deleted(
6060
self, cli, monkeypatch, create_with_upload, faker, ckan_config
6161
):

ckan/tests/lib/test_uploader.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def test_group_upload(self, monkeypatch, tmpdir, make_app, ckan_config, faker):
9090
u'upload': FileStorage(
9191
BytesIO(faker.image()),
9292
filename=u'logo.png',
93-
content_type=u'PNG'
93+
content_type=u'image/png'
9494
),
9595
u'name': u'test-group-upload'}
9696
group_upload = Upload(u'group')

ckan/tests/logic/action/test_create.py

+55
Original file line numberDiff line numberDiff line change
@@ -2226,6 +2226,47 @@ def test_upload_non_picture_with_png_extension(
22262226
logic.ValidationError, match="Unsupported upload type"):
22272227
create_with_upload("hello world", "file.png", **params)
22282228

2229+
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "")
2230+
@pytest.mark.ckan_config("ckan.upload.user.types", "")
2231+
def test_uploads_not_allowed_when_empty_mimetypes_and_types(
2232+
self, create_with_upload, faker):
2233+
params = {
2234+
"name": faker.user_name(),
2235+
"email": faker.email(),
2236+
"password": "12345678",
2237+
"action": "user_create",
2238+
"upload_field_name": "image_upload",
2239+
}
2240+
with pytest.raises(
2241+
logic.ValidationError, match="No uploads allowed for object type"):
2242+
create_with_upload("hello world", "file.png", **params)
2243+
2244+
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "*")
2245+
@pytest.mark.ckan_config("ckan.upload.user.types", "image")
2246+
def test_upload_all_types_allowed_needs_both_options(self, create_with_upload, faker):
2247+
params = {
2248+
"name": faker.user_name(),
2249+
"email": faker.email(),
2250+
"password": "12345678",
2251+
"action": "user_create",
2252+
"upload_field_name": "image_upload",
2253+
}
2254+
with pytest.raises(
2255+
logic.ValidationError, match="Unsupported upload type"):
2256+
assert create_with_upload(faker.json(), "file.json", **params)
2257+
2258+
@pytest.mark.ckan_config("ckan.upload.user.mimetypes", "*")
2259+
@pytest.mark.ckan_config("ckan.upload.user.types", "*")
2260+
def test_upload_all_types_allowed(self, create_with_upload, faker):
2261+
params = {
2262+
"name": faker.user_name(),
2263+
"email": faker.email(),
2264+
"password": "12345678",
2265+
"action": "user_create",
2266+
"upload_field_name": "image_upload",
2267+
}
2268+
assert create_with_upload(faker.json(), "file.json", **params)
2269+
22292270
@pytest.mark.ckan_config("ckan.upload.user.types", "image")
22302271
def test_upload_picture(self, create_with_upload, faker):
22312272
params = {
@@ -2237,6 +2278,20 @@ def test_upload_picture(self, create_with_upload, faker):
22372278
}
22382279
assert create_with_upload(faker.image(), "file.png", **params)
22392280

2281+
@pytest.mark.ckan_config("ckan.upload.user.types", "image")
2282+
def test_upload_picture_extension_enforced(self, create_with_upload, faker):
2283+
params = {
2284+
"name": faker.user_name(),
2285+
"email": faker.email(),
2286+
"password": "12345678",
2287+
"action": "user_create",
2288+
"upload_field_name": "image_upload",
2289+
}
2290+
user = create_with_upload(faker.image(image_format="jpeg"), "file.png", **params)
2291+
2292+
assert user["image_url"].endswith(".jpg")
2293+
assert user["image_display_url"].endswith(".jpg")
2294+
22402295

22412296
class TestVocabularyCreate(object):
22422297
@pytest.mark.usefixtures("non_clean_db")

0 commit comments

Comments
 (0)