Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions website/models/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os # for file handling
import website.utils.fileutils as ml_fileutils # for custom file handling
from sortedm2m.fields import SortedManyToManyField
from website.utils.upload_validators import validate_pdf_upload, validate_raw_file_upload

# This retrieves a Python logging instance (or creates it)
_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -34,9 +35,9 @@ class Artifact(models.Model):
location.help_text = "The geographic location of where this artifact was presented"

# The artifacts themselves
pdf_file = models.FileField(upload_to=get_upload_dir, null=True, default=None, max_length=255)
pdf_file = models.FileField(upload_to=get_upload_dir, null=True, default=None, max_length=255, validators=[validate_pdf_upload])
pdf_file.help_text = "The rendered PDF of the artifact"
raw_file = models.FileField(upload_to=get_upload_dir, blank=True, null=True, default=None, max_length=255)
raw_file = models.FileField(upload_to=get_upload_dir, blank=True, null=True, default=None, max_length=255, validators=[validate_raw_file_upload])
raw_file.help_text = "The raw file (e.g., pptx, keynote) for the artifact. While not required, this is "\
"<b>highly</b> recommended as it creates a better archive of the work"
thumbnail = models.ImageField(upload_to=get_upload_thumbnail_dir, editable=False, null=True, max_length=255)
Expand Down
5 changes: 3 additions & 2 deletions website/models/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.db.models.signals import pre_delete, post_save, m2m_changed, post_delete

from website.utils.fileutils import UniquePathAndRename
from website.utils.upload_validators import validate_image_upload, validate_video_upload
from image_cropping import ImageRatioField

from .project import Project
Expand All @@ -17,13 +18,13 @@ class Banner(models.Model):
landing_page = models.BooleanField(default=False)
landing_page.help_text = 'Check this box if this banner should appear on the landing page.'

image = models.ImageField(blank=True, upload_to=UniquePathAndRename(UPLOAD_DIR, True), max_length=255)
image = models.ImageField(blank=True, upload_to=UniquePathAndRename(UPLOAD_DIR, True), max_length=255, validators=[validate_image_upload])
cropping = ImageRatioField('image', '1600x500', free_crop=False)
image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.\
Please note that since we are using a responsive design with fixed height banners, your selected image may appear\
differently on various screens.'

video = models.FileField(upload_to=UniquePathAndRename(VIDEO_UPLOAD_DIR, True), blank=True, null=True)
video = models.FileField(upload_to=UniquePathAndRename(VIDEO_UPLOAD_DIR, True), blank=True, null=True, validators=[validate_video_upload])
video.help_text = "Add in a background video. Ideally, video should be 10MB or less. If both a video and image are specified, the video is prioritized. The image is fallback."

alt_text = models.CharField(max_length=1024, blank=True, null=True)
Expand Down
3 changes: 2 additions & 1 deletion website/models/news.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ckeditor_uploader.fields import RichTextUploadingField
from website.utils.fileutils import UniquePathAndRename
from website.utils.upload_validators import validate_image_upload
from image_cropping import ImageRatioField

from django.utils.text import slugify
Expand Down Expand Up @@ -44,7 +45,7 @@ def get_thumbnail_size_as_str():
content = RichTextUploadingField(config_name='default')

# Following the scheme of above thumbnails in other models
image = models.ImageField(blank=True, upload_to=UniquePathAndRename("news", True), max_length=255)
image = models.ImageField(blank=True, upload_to=UniquePathAndRename("news", True), max_length=255, validators=[validate_image_upload])
image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.'

# We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping
Expand Down
5 changes: 3 additions & 2 deletions website/models/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from website.models.project_role import ProjectRole
from django.core.files import File
import website.utils.fileutils as ml_fileutils
from website.utils.upload_validators import validate_image_upload

from django.db.models.functions import Coalesce
from django.conf import settings
Expand Down Expand Up @@ -135,7 +136,7 @@ def get_thumbnail_size_as_str():
# Note: the ImageField requires the pillow library
# We use the get_unique_path function because otherwise if two people use the same
# filename (something generic like picture.jpg), one will overwrite the other.
image = models.ImageField(blank=True, upload_to=get_upload_to_for_person, max_length=255)
image = models.ImageField(blank=True, upload_to=get_upload_to_for_person, max_length=255, validators=[validate_image_upload])
image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.'

# We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping
Expand All @@ -145,7 +146,7 @@ def get_thumbnail_size_as_str():
cropping = ImageRatioField('image', get_thumbnail_size_as_str(), size_warning=True)

# This is the hover image (aka easter egg)
easter_egg = models.ImageField(blank=True, null=True, upload_to=get_upload_to_for_person_easter_egg, max_length=255)
easter_egg = models.ImageField(blank=True, null=True, upload_to=get_upload_to_for_person_easter_egg, max_length=255, validators=[validate_image_upload])
easter_egg.help_text = mark_safe("You do not have to set this field. It defaults to a Star Wars\
Rebels LEGO character from <a href='https://github.com/makeabilitylab/makeabilitylabwebsite/tree/master/media/images/StarWarsFiguresFullSquare/Rebels'>here</a>\
but you can use whatever you want. This image is shown on mouseover on the people.html page.")
Expand Down
3 changes: 2 additions & 1 deletion website/models/photo.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.db import models
from django.utils.safestring import mark_safe
from image_cropping import ImageRatioField
from website.utils.upload_validators import validate_image_upload

from .project import Project

Expand All @@ -12,7 +13,7 @@ class Photo(models.Model):
def get_cropping_size_as_str():
return f"{DEFAULT_CROPPING_SIZE[0]}x{DEFAULT_CROPPING_SIZE[1]}"

picture = models.ImageField(upload_to='projects/images/', max_length=255)
picture = models.ImageField(upload_to='projects/images/', max_length=255, validators=[validate_image_upload])

# TODO: force both caption and alt_text to be non-null and non-blank
# This requires a migration so need to talk with Matt/Jason in IT about it.
Expand Down
3 changes: 2 additions & 1 deletion website/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.db.models.functions import Coalesce

from image_cropping import ImageRatioField
from website.utils.upload_validators import validate_image_upload

from datetime import date, datetime, timedelta
from django.utils import timezone
Expand Down Expand Up @@ -82,7 +83,7 @@ def get_thumbnail_size_as_str():

# pis = models.ManyToOneField(Person, blank=True, null=True)
# TODO: consider switching gallery_image var name to thumbnail
gallery_image = models.ImageField(upload_to=IMAGE_DIR, blank=True, null=True, max_length=255)
gallery_image = models.ImageField(upload_to=IMAGE_DIR, blank=True, null=True, max_length=255, validators=[validate_image_upload])
gallery_image.help_text = "This is the image which will show up on the project gallery page.\
It is not displayed anywhere else. After choosing an image, crop it right here\
using the cropper below — no need to save first."
Expand Down
3 changes: 2 additions & 1 deletion website/models/sponsor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models
from image_cropping import ImageRatioField
from website.utils.upload_validators import validate_image_upload
import os

SPONSOR_THUMBNAIL_SIZE = (245, 245)
Expand All @@ -18,7 +19,7 @@ def get_thumbnail_size_as_str():
short_name = models.CharField(max_length=255, null=True)
short_name.help_text = "Short name for the sponsor (e.g., NSF)"

icon = models.ImageField(upload_to=ICON_DIR, blank=True, null=True, max_length=255)
icon = models.ImageField(upload_to=ICON_DIR, blank=True, null=True, max_length=255, validators=[validate_image_upload])
icon.help_text = "Icon for the sponsor (e.g., NSF logo)"

alt_text = models.CharField(max_length=1024, blank=True, null=True)
Expand Down
162 changes: 162 additions & 0 deletions website/tests/test_upload_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""
Unit tests for website.utils.upload_validators (issue #6).

Pure-logic tests: each validator is a function over an uploaded file, so these
use SimpleTestCase + SimpleUploadedFile with crafted bytes — no DB, runs in ms.

Each category covers four cases:
* accept a file whose extension AND bytes are valid,
* reject a disallowed extension,
* reject a renamed payload (allowed extension, wrong/dangerous bytes),
* plus category-specific cases (HEIC guidance, .fig/.sketch for raw_file).
"""

from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase

from website.utils.upload_validators import (
validate_image_upload,
validate_pdf_upload,
validate_raw_file_upload,
validate_video_upload,
)


# --- Sample file headers ---------------------------------------------------

PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00" * 16
JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 16
GIF = b"GIF89a\x01\x00\x01\x00\x80\x00\x00" + b"\x00" * 16
WEBP = b"RIFF\x24\x00\x00\x00WEBPVP8 " + b"\x00" * 16
PDF = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + b"\x00" * 16
MP4 = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00" + b"\x00" * 16
WEBM = b"\x1aE\xdf\xa3\x01\x00\x00\x00" + b"\x00" * 16
ZIP = b"PK\x03\x04\x14\x00\x00\x00\x08\x00" + b"\x00" * 16 # pptx/docx/key/sketch/zip
OLE = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 16 # legacy ppt/doc
FIG = b"fig-kiwi\x0f\x00\x00\x00\x01\x02\x03" + b"\x00" * 16 # proprietary binary
HTML = b"<!DOCTYPE html>\n<html><body>hi</body></html>"
SVG = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'


def _upload(name, content):
return SimpleUploadedFile(name, content)


class _CommittedFile:
"""
Minimal stand-in for an already-stored FieldFile (``_committed = True``),
i.e. an existing file on an unchanged record. Validators should skip it.
"""

def __init__(self, name):
self.name = name
self._committed = True

def seek(self, *a):
raise AssertionError("a committed file should not be read by validators")

def read(self, *a):
raise AssertionError("a committed file should not be read by validators")


class ImageValidatorTests(SimpleTestCase):
def test_accepts_valid_images(self):
for name, content in [
("a.png", PNG), ("a.jpg", JPEG), ("a.jpeg", JPEG),
("a.gif", GIF), ("a.webp", WEBP),
]:
with self.subTest(name=name):
validate_image_upload(_upload(name, content)) # no raise

def test_rejects_disallowed_extension(self):
with self.assertRaises(ValidationError):
validate_image_upload(_upload("a.svg", SVG))
with self.assertRaises(ValidationError):
validate_image_upload(_upload("a.html", HTML))

def test_rejects_renamed_payload(self):
# Allowed extension, but the bytes are HTML, not an image.
with self.assertRaises(ValidationError) as ctx:
validate_image_upload(_upload("evil.png", HTML))
self.assertEqual(ctx.exception.code, "invalid_image_content")

def test_heic_gets_guiding_message(self):
for name in ("photo.heic", "photo.HEIC", "photo.heif"):
with self.subTest(name=name):
with self.assertRaises(ValidationError) as ctx:
validate_image_upload(_upload(name, PNG))
self.assertEqual(ctx.exception.code, "heic_not_supported")


class PdfValidatorTests(SimpleTestCase):
def test_accepts_valid_pdf(self):
validate_pdf_upload(_upload("paper.pdf", PDF))

def test_rejects_disallowed_extension(self):
with self.assertRaises(ValidationError):
validate_pdf_upload(_upload("paper.exe", PDF))

def test_rejects_renamed_payload(self):
with self.assertRaises(ValidationError) as ctx:
validate_pdf_upload(_upload("evil.pdf", HTML))
self.assertEqual(ctx.exception.code, "invalid_pdf_content")


class VideoValidatorTests(SimpleTestCase):
def test_accepts_valid_videos(self):
validate_video_upload(_upload("clip.mp4", MP4))
validate_video_upload(_upload("clip.mov", MP4))
validate_video_upload(_upload("clip.webm", WEBM))

def test_rejects_disallowed_extension(self):
with self.assertRaises(ValidationError):
validate_video_upload(_upload("clip.avi", MP4))

def test_rejects_renamed_payload(self):
with self.assertRaises(ValidationError) as ctx:
validate_video_upload(_upload("evil.mp4", HTML))
self.assertEqual(ctx.exception.code, "invalid_video_content")


class RawFileValidatorTests(SimpleTestCase):
def test_accepts_known_source_formats(self):
for name, content in [
("talk.pptx", ZIP), ("talk.key", ZIP), ("doc.docx", ZIP),
("src.zip", ZIP), ("legacy.ppt", OLE), ("paper.pdf", PDF),
]:
with self.subTest(name=name):
validate_raw_file_upload(_upload(name, content)) # no raise

def test_accepts_proprietary_design_files(self):
# .fig / .sketch are accepted by extension; the denylist content check
# passes any non-web-executable bytes, so we don't need their signatures.
validate_raw_file_upload(_upload("poster.fig", FIG))
validate_raw_file_upload(_upload("poster.sketch", ZIP))

def test_rejects_disallowed_extension(self):
with self.assertRaises(ValidationError):
validate_raw_file_upload(_upload("page.html", HTML))
with self.assertRaises(ValidationError):
validate_raw_file_upload(_upload("image.svg", SVG))

def test_rejects_web_executable_content_via_rename(self):
# Allowed extension (.fig) but HTML bytes -> caught by the denylist.
with self.assertRaises(ValidationError) as ctx:
validate_raw_file_upload(_upload("evil.fig", HTML))
self.assertEqual(ctx.exception.code, "invalid_raw_content")


class ExistingFileGateTests(SimpleTestCase):
"""An already-stored file (unchanged record edit) is skipped, even if its
extension/content would fail today's rules. Re-validating it adds no
security and would break editing legacy records."""

def test_committed_files_are_not_validated(self):
# Each of these would fail if validated as a new upload; the gate
# short-circuits before the extension/content checks (and before any
# read of the file, which _CommittedFile asserts against).
validate_image_upload(_CommittedFile("legacy.bmp"))
validate_pdf_upload(_CommittedFile("legacy.txt"))
validate_video_upload(_CommittedFile("legacy.avi"))
validate_raw_file_upload(_CommittedFile("legacy.tex"))
Loading
Loading