3131from baserow .core .utils import random_string , sha256_hash , stream_size , truncate_middle
3232
3333from .exceptions import (
34+ ActiveContentBlockedUserFileError ,
3435 FileSizeTooLargeError ,
3536 FileURLCouldNotBeReached ,
3637 InvalidFileStreamError ,
4344 from PIL import Image
4445
4546MIME_TYPE_UNKNOWN = "application/octet-stream"
47+ ACTIVE_CONTENT_EXTENSIONS = {"html" , "htm" , "xhtml" , "xml" , "svg" , "svgz" }
48+ ACTIVE_CONTENT_MIME_TYPES = {
49+ "application/xhtml+xml" ,
50+ "application/xml" ,
51+ "image/svg+xml" ,
52+ "text/html" ,
53+ "text/xml" ,
54+ }
4655
4756
4857class UserFileHandler :
58+ def _is_active_content_extension (self , extension : str ) -> bool :
59+ return extension .lower () in ACTIVE_CONTENT_EXTENSIONS
60+
61+ def _is_active_content_mime_type (self , mime_type : str ) -> bool :
62+ return mime_type .lower () in ACTIVE_CONTENT_MIME_TYPES
63+
64+ def _resolve_mime_type_and_active_content (
65+ self , file_name : str , extension : str , stream
66+ ) -> tuple [str , bool ]:
67+ """
68+ Resolves the MIME type for an uploaded file and decides whether it should be
69+ treated as active content.
70+
71+ Both the filename-derived MIME type and the client-supplied `content_type` are
72+ checked independently against the active-content blocklist so that a malicious
73+ client cannot bypass the gate by pairing an innocuous extension with a dangerous
74+ Content-Type header (or vice versa).
75+
76+ :param file_name: The name of the uploaded file.
77+ :param extension: The file extension of the uploaded file.
78+ :param stream: The file content stream of the uploaded file, which may have a
79+ `content_type` attribute.
80+ :return: A tuple of the resolved MIME type and whether the file is considered
81+ active content.
82+ """
83+
84+ guessed_mime_type = mimetypes .guess_type (file_name )[0 ]
85+ uploaded_mime_type = getattr (stream , "content_type" , None )
86+ mime_type = guessed_mime_type or uploaded_mime_type or MIME_TYPE_UNKNOWN
87+ is_active_content = (
88+ self ._is_active_content_extension (extension )
89+ or (
90+ guessed_mime_type is not None
91+ and self ._is_active_content_mime_type (guessed_mime_type )
92+ )
93+ or (
94+ uploaded_mime_type is not None
95+ and self ._is_active_content_mime_type (uploaded_mime_type )
96+ )
97+ )
98+ return mime_type , is_active_content
99+
100+ def _neutralize_active_content (self , user_file : UserFile ) -> UserFile :
101+ user_file .mime_type = MIME_TYPE_UNKNOWN
102+ user_file .is_image = False
103+ user_file .image_width = None
104+ user_file .image_height = None
105+ return user_file
106+
49107 def is_user_file_name (self , user_file_name : str ) -> bool :
50108 """
51109 Checks if the given name is a user file name.
@@ -266,6 +324,15 @@ def upload_user_file(self, user, file_name, stream, storage=None):
266324 storage = storage or get_default_storage ()
267325 stream_hash = sha256_hash (stream )
268326 file_name = truncate_middle (file_name , 64 )
327+ extension = pathlib .Path (file_name ).suffix [1 :].lower ()
328+ mime_type , is_active_content = self ._resolve_mime_type_and_active_content (
329+ file_name , extension , stream
330+ )
331+
332+ if is_active_content and settings .FILE_UPLOAD_ACTIVE_CONTENT_POLICY == "block" :
333+ raise ActiveContentBlockedUserFileError (
334+ "The provided file type is not allowed."
335+ )
269336
270337 existing_user_file = UserFile .objects .filter (
271338 original_name = file_name ,
@@ -274,14 +341,18 @@ def upload_user_file(self, user, file_name, stream, storage=None):
274341 ).first ()
275342
276343 if existing_user_file :
344+ if is_active_content :
345+ self ._neutralize_active_content (existing_user_file )
346+ existing_user_file .save (
347+ update_fields = [
348+ "mime_type" ,
349+ "is_image" ,
350+ "image_width" ,
351+ "image_height" ,
352+ ]
353+ )
277354 return existing_user_file
278355
279- extension = pathlib .Path (file_name ).suffix [1 :].lower ()
280- mime_type = (
281- mimetypes .guess_type (file_name )[0 ]
282- or getattr (stream , "content_type" , None )
283- or MIME_TYPE_UNKNOWN
284- )
285356 unique = self .generate_unique (stream_hash , extension )
286357 user_file = UserFile (
287358 original_name = file_name ,
@@ -293,26 +364,30 @@ def upload_user_file(self, user, file_name, stream, storage=None):
293364 sha256_hash = stream_hash ,
294365 )
295366
296- image = None
297- try :
298- image = Image .open (stream )
299- user_file .mime_type = f"image/{ image .format } " .lower ()
300- self .generate_and_save_image_thumbnails (
301- image , user_file .name , storage = storage
302- )
303- # Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
304- user_file .is_image = True
305- user_file .image_width = image .width
306- user_file .image_height = image .height
307- except IOError :
308- pass # Not an image
309- except Exception as exc :
310- logger .warning (
311- f"Failed to generate thumbnails for user file of type { mime_type } : { exc } "
312- )
313- finally :
314- if image is not None :
315- del image
367+ if is_active_content :
368+ self ._neutralize_active_content (user_file )
369+ else :
370+ image = None
371+ try :
372+ image = Image .open (stream )
373+ user_file .mime_type = f"image/{ image .format } " .lower ()
374+ self .generate_and_save_image_thumbnails (
375+ image , user_file .name , storage = storage
376+ )
377+ # Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
378+ user_file .is_image = True
379+ user_file .image_width = image .width
380+ user_file .image_height = image .height
381+ except IOError :
382+ pass # Not an image
383+ except Exception as exc :
384+ logger .warning (
385+ f"Failed to generate thumbnails for user file of type "
386+ f"{ mime_type } : { exc } "
387+ )
388+ finally :
389+ if image is not None :
390+ del image
316391
317392 user_file .save ()
318393
0 commit comments