Separated exceptions, and added a JavaScript monitoring for SVG files.

This commit is contained in:
Pouria Hadjibagheri 2017-03-12 20:29:15 +00:00
parent f24628e237
commit 38649c964a
4 changed files with 254 additions and 78 deletions

50
markdownx/exceptions.py Normal file
View file

@ -0,0 +1,50 @@
from django.utils.translation import ugettext_lazy as _
from django.forms import ValidationError
class MarkdownxImageUploadError(ValidationError):
"""
"""
@staticmethod
def not_uploaded():
"""
No file is available to upload.
:return:
:rtype:
"""
return MarkdownxImageUploadError(_('No files have been uploaded.'))
@staticmethod
def unsupported_format():
"""
The file is of a format not defined in `settings.py`
or if default, in `markdownx/settings.py`.
:return:
:rtype:
"""
return MarkdownxImageUploadError(_('File type is not supported.'))
@staticmethod
def invalid_size(current, expected):
"""
The file is larger in size that the maximum allow in `settings.py` (or the default).
:param current:
:type current:
:param expected:
:type expected:
:return:
:rtype:
"""
from django.template.defaultfilters import filesizeformat
return MarkdownxImageUploadError(
_('Please keep file size under {max}. Current file size {current}').format(
max=filesizeformat(expected),
current=filesizeformat(current)
)
)

View file

@ -7,59 +7,47 @@ from collections import namedtuple
from django import forms
from django.utils.six import BytesIO
from django.core.files.storage import default_storage
from django.utils.translation import ugettext_lazy as _
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.template.defaultfilters import filesizeformat
# Internal.
from .utils import scale_and_crop
from .utils import scale_and_crop, has_javascript
from .exceptions import MarkdownxImageUploadError
from .settings import (
MARKDOWNX_IMAGE_MAX_SIZE,
MARKDOWNX_MEDIA_PATH,
MARKDOWNX_UPLOAD_CONTENT_TYPES,
MARKDOWNX_UPLOAD_MAX_SIZE,
MARKDOWNX_SVG_JAVASCRIPT_PROTECTION
)
class ImageForm(forms.Form):
"""
Used for the handling of images uploaded using the editor through AJAX.
"""
image = forms.FileField()
# Separately defined as it needs to be processed a text file rather than image.
# Separately defined as it needs to be
# processed a text file rather than image.
_SVG_TYPE = 'image/svg+xml'
_error_templates = {
# No file is available to upload.
'not_uploaded':
forms.ValidationError(_('No files have been uploaded.')),
# The file is of a format not defined in "settings.py"
# or if default, in "markdownx/settings.py".
'unsupported_format':
forms.ValidationError(_('File type is not supported.')),
# The file is larger in size that the maximum allow in "settings.py" (or the default).
'invalid_size':
lambda current: forms.ValidationError(
_('Please keep file size under {max}. Current file size {current}').format(
max=filesizeformat(MARKDOWNX_UPLOAD_MAX_SIZE),
current=filesizeformat(current)
)
)
}
def save(self, commit=True):
"""
Saves the uploaded image in the designated location.
If image type is not SVG, a byteIO of image content_type is created and
subsequently save; otherwise, the SVG is saved in its existing `charset`
as an `image/svg+xml`.
subsequently save; otherwise, the SVG is saved in its existing ``charset``
as an ``image/svg+xml``.
The dimension of image files (excluding SVG) are set using `PIL`.
*Note*: The dimension of image files (excluding SVG) are set using ``PIL``.
:param commit: If `True`, the file is saved to the disk; otherwise, it is held in the memory.
:param commit: If ``True``, the file is saved to the disk;
otherwise, it is held in the memory.
:type commit: bool
:return: An instance of saved image if `commit is True`, else `namedtuple(path, image)`.
:return: An instance of saved image if ``commit is True``,
else ``namedtuple(path, image)``.
:rtype: bool, namedtuple
"""
image = self.files.get('image')
@ -69,7 +57,10 @@ class ImageForm(forms.Form):
image_size = getattr(image, '_size')
if content_type.lower() != self._SVG_TYPE:
# Processing the raster graphic image:
# Processing the raster graphic image.
# Note that vector graphics in SVG format
# do not require additional processing and
# may be stored as uploaded.
image = self._process_raster(image, image_extension)
image_size = image.tell()
@ -88,7 +79,18 @@ class ImageForm(forms.Form):
def _save(self, image, file_name, commit):
"""
Final saving process, called internally after the image had processed.
Final saving process, called internally after processing tasks are complete.
:param image: Prepared image
:type image: django.core.files.uploadedfile.InMemoryUploadedFile
:param file_name: Name of the file using which the image is to be saved.
:type file_name: str
:param commit: If ``True``, the image is saved onto the disk.
:type commit: bool
:return: URL of the uploaded image ``commit=True``, otherwise a namedtuple of ``(path, image)`` where
``path`` is the absolute path generated for saving the file, and ``image`` is the prepared
image.
:rtype: str, namedtuple
"""
# Defining a universally unique name for the file
# to be saved on the disk.
@ -106,10 +108,22 @@ class ImageForm(forms.Form):
@staticmethod
def _process_raster(image, extension):
"""
Processing of raster graphic image.
Processing of raster graphic image using Python Imaging Library (PIL).
This is where raster graphics are processed to the specifications
as defined in ``settings.py``.
*Note*: The file needs to be uploaded and saved temporarily in the
memory to enable processing tasks using Python Imaging Library (PIL)
to take place and subsequently retained until written onto the disk.
:param image: Non-SVG image as processed by Django.
:type image: django.forms.BaseForm.file
:param extension: Image extension (e.g.: png, jpg, gif)
:type extension: str
:return: The image object ready to be written into a file.
:rtype: BytesIO
"""
# File needs to be uploaded and saved temporarily in
# the memory for additional processing using PIL.
thumb_io = BytesIO()
preped_image = scale_and_crop(image, **MARKDOWNX_IMAGE_MAX_SIZE)
preped_image.save(thumb_io, extension)
@ -119,16 +133,19 @@ class ImageForm(forms.Form):
@staticmethod
def get_unique_file_name(file_name):
"""
Generates a universally unique ID using Python `UUID` and attaches the extension of file name to it.
Generates a universally unique ID using Python ``UUID`` and attaches the extension of file name to it.
:param file_name: Name of the uploaded file, including the extension.
:type file_name: str
:return: Universally unique ID, ending with the extension extracted from `file_name`.
:return: Universally unique ID, ending with the extension extracted from ``file_name``.
:rtype: str
"""
extension = 1
extension_dot_index = 1
file_name = "{unique_name}.{extension}".format(
unique_name=uuid4(),
extension=path.splitext(file_name)[1][1:] # [1] is the extension, [1:] discards the dot.
extension=path.splitext(file_name)[extension][extension_dot_index:]
)
return file_name
@ -145,14 +162,28 @@ class ImageForm(forms.Form):
# additional information on each error.
# -----------------------------------------------
if not upload:
raise self._error_templates['not_uploaded']
raise MarkdownxImageUploadError.not_uploaded()
content_type = upload.content_type
file_size = getattr(upload, '_size')
if content_type not in MARKDOWNX_UPLOAD_CONTENT_TYPES:
raise self._error_templates['unsupported_format']
raise MarkdownxImageUploadError.unsupported_format()
elif file_size > MARKDOWNX_UPLOAD_MAX_SIZE:
raise self._error_templates['invalid_size'](file_size)
raise MarkdownxImageUploadError.invalid_size(
current=file_size,
expected=MARKDOWNX_UPLOAD_MAX_SIZE
)
elif (content_type.lower() != self._SVG_TYPE
and MARKDOWNX_SVG_JAVASCRIPT_PROTECTION
and has_javascript(upload.read())):
raise MarkdownxImageUploadError(
'Failed security monitoring: SVG file contains JavaScript.'
)
return upload

View file

@ -1,66 +1,76 @@
# Django library.
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ImproperlyConfigured
# Constants
# ------------------------------------------------------------------
__FIFTY_MEGABYTES = 50 * 1024 * 1024
__VALID_CONTENT_TYPES = 'image/jpeg', 'image/png', 'image/svg+xml'
FIFTY_MEGABYTES = 50 * 1024 * 1024
VALID_CONTENT_TYPES = 'image/jpeg', 'image/png', 'image/svg+xml'
NINETY_DPI = 90
IM_WIDTH = 500
IM_HEIGHT = 500
# ------------------------------------------------------------------
def _from_settings(var, default):
def _mdx(var, default):
"""
Adds "MARXDOWX_" to the requested variable and retrieves its value
from settings or returns the default.
from settings or returns the default.
:param var: Variable to be retrieved.
:type var: str
:param default: Default value if the variable is not defined.
:return: Value corresponding to 'var'.
"""
return getattr(settings, 'MARKDOWNX_' + var, default)
try:
return getattr(settings, 'MARKDOWNX_' + var, default)
except ImproperlyConfigured:
# To handle the auto-generation of documentations.
return default
# Markdownify
# ------------
MARKDOWNX_MARKDOWNIFY_FUNCTION = _from_settings('MARKDOWNIFY_FUNCTION', 'markdownx.utils.markdownify')
# --------------------
MARKDOWNX_MARKDOWNIFY_FUNCTION = _mdx('MARKDOWNIFY_FUNCTION', 'markdownx.utils.markdownify')
# Markdown extensions
# --------------------
MARKDOWNX_MARKDOWN_EXTENSIONS = _from_settings('MARKDOWN_EXTENSIONS', list())
MARKDOWNX_MARKDOWN_EXTENSIONS = _mdx('MARKDOWN_EXTENSIONS', list())
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = _from_settings('MARKDOWN_EXTENSION_CONFIGS', dict())
MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = _mdx('MARKDOWN_EXTENSION_CONFIGS', dict())
# Markdown urls
# --------------
MARKDOWNX_URLS_PATH = _from_settings('URLS_PATH', '/markdownx/markdownify/')
# --------------------
MARKDOWNX_URLS_PATH = _mdx('URLS_PATH', '/markdownx/markdownify/')
MARKDOWNX_UPLOAD_URLS_PATH = _from_settings('UPLOAD_URLS_PATH', '/markdownx/upload/')
MARKDOWNX_UPLOAD_URLS_PATH = _mdx('UPLOAD_URLS_PATH', '/markdownx/upload/')
# Media path
# -----------
MARKDOWNX_MEDIA_PATH = _from_settings('MEDIA_PATH', 'markdownx/')
# --------------------
MARKDOWNX_MEDIA_PATH = _mdx('MEDIA_PATH', 'markdownx/')
# Image
# ------
MARKDOWNX_UPLOAD_MAX_SIZE = _from_settings('UPLOAD_MAX_SIZE', __FIFTY_MEGABYTES)
# --------------------
MARKDOWNX_UPLOAD_MAX_SIZE = _mdx('UPLOAD_MAX_SIZE', FIFTY_MEGABYTES)
MARKDOWNX_UPLOAD_CONTENT_TYPES = _from_settings('UPLOAD_CONTENT_TYPES', __VALID_CONTENT_TYPES)
MARKDOWNX_UPLOAD_CONTENT_TYPES = _mdx('UPLOAD_CONTENT_TYPES', VALID_CONTENT_TYPES)
MARKDOWNX_IMAGE_MAX_SIZE = _from_settings('IMAGE_MAX_SIZE', dict(size=(500, 500), quality=90))
MARKDOWNX_IMAGE_MAX_SIZE = _mdx('IMAGE_MAX_SIZE', dict(size=(IM_WIDTH, IM_HEIGHT), quality=NINETY_DPI))
MARKDOWNX_SVG_JAVASCRIPT_PROTECTION = True
# Editor
# -------
MARKDOWNX_EDITOR_RESIZABLE = _from_settings('EDITOR_RESIZABLE', True)
# --------------------
MARKDOWNX_EDITOR_RESIZABLE = _mdx('EDITOR_RESIZABLE', True)
# ------------------------------------------------
@ -68,11 +78,15 @@ MARKDOWNX_EDITOR_RESIZABLE = _from_settings('EDITOR_RESIZABLE', True)
# ------------------------------------------------
# This is not called using `_from_settings` as
# it does not need "_MARKDOWNX" prefix.
LANGUAGES = getattr(
settings,
'LANGUAGES',
(
('en', _('English')),
('pl', _('Polish')),
try:
LANGUAGES = getattr(
settings,
'LANGUAGES',
(
('en', _('English')),
('pl', _('Polish')),
)
)
)
except ImproperlyConfigured:
# To handle the auto-generation of documentations.
pass

View file

@ -1,4 +1,4 @@
import markdown
from markdown import markdown
from PIL import Image
@ -6,13 +6,39 @@ from .settings import MARKDOWNX_MARKDOWN_EXTENSIONS, MARKDOWNX_MARKDOWN_EXTENSIO
def markdownify(content):
return markdown.markdown(
content,
extensions=MARKDOWNX_MARKDOWN_EXTENSIONS,
extension_configs=MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS
"""
:param content:
:type content:
:return:
:rtype:
"""
md = markdown(
text=content,
extensions=MARKDOWNX_MARKDOWN_EXTENSIONS,
extension_configs=MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS
)
return md
def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
"""
:param image:
:type image:
:param size:
:type size:
:param crop:
:type crop:
:param upscale:
:type upscale:
:param quality:
:type quality:
:return:
:rtype:
"""
# ToDo: Possible IO/Runtime exceptions need to handled, and `finally` the file needs to be closed.
# Open image and store format/metadata.
image.open()
im = Image.open(image)
@ -23,8 +49,8 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
# Force PIL to load image data.
im.load()
source_x, source_y = [float(v) for v in im.size]
target_x, target_y = [float(v) for v in size]
source_x, source_y = map(float, im.size)
target_x, target_y = map(float, size)
if crop or not target_x or not target_y:
scale = max(target_x / source_x, target_y / source_y)
@ -38,7 +64,10 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
target_y = source_y * scale
if scale < 1.0 or (scale > 1.0 and upscale):
im = im.resize((int(source_x * scale), int(source_y * scale)), resample=Image.ANTIALIAS)
im = im.resize(
(int(source_x * scale), int(source_y * scale)),
resample=Image.ANTIALIAS
)
if crop:
# Use integer values now.
@ -46,12 +75,17 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
# Difference between new image size and requested size.
diff_x = int(source_x - min(source_x, target_x))
diff_y = int(source_y - min(source_y, target_y))
if diff_x or diff_y:
# Center cropping (default).
halfdiff_x, halfdiff_y = diff_x // 2, diff_y // 2
box = [halfdiff_x, halfdiff_y,
min(source_x, int(target_x) + halfdiff_x),
min(source_y, int(target_y) + halfdiff_y)]
box = [
halfdiff_x,
halfdiff_y,
min(source_x, int(target_x) + halfdiff_x),
min(source_y, int(target_y) + halfdiff_y)
]
# Finally, crop the image!
im = im.crop(box)
@ -59,3 +93,50 @@ def scale_and_crop(image, size, crop=False, upscale=False, quality=None):
im.format, im.info = im_format, im_info
image.close()
return im
def has_javascript(data):
"""
:param data: Contents to be monitored for JavaScript injection.
:type data: str
:return: ``True`` if **data** contains JavaScript tag(s), otherwise ``False``.
:rtype: bool
"""
from re import search, IGNORECASE, MULTILINE
from xml.etree.ElementTree import fromstring
# ------------------------------------------------
# Handles JavaScript nodes and stringified nodes.
# ------------------------------------------------
pattern = (
r'(<\s*\bscript\b.*>.*)|'
r'(.*\bif\b\s*\(.?={2,3}.*\))|'
r'(.*\bfor\b\s*\(.*\))'
)
found = search(
pattern=pattern,
string=data,
flags=IGNORECASE | MULTILINE
)
if found is not None:
return True
# ------------------------------------------------
# Handles JavaScript injection into attributes
# for element creation.
# ------------------------------------------------
parsed_xml = (
(attribute, value)
for elm in fromstring(data).iter()
for attribute, value in elm.attrib.items()
)
for key, val in parsed_xml:
if '"' in val or "'" in val:
return True
# It is (hopefully) safe.
return False