diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 4bf0d1f..cd5a126 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -1,5 +1,4 @@ from django import forms -from pathlib import Path from datetime import timedelta from django.contrib.auth import authenticate, get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm @@ -12,6 +11,13 @@ from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token +from .upload_validation import ( + validate_avatar_upload, + validate_favicon_upload, + validate_logo_upload, + validate_pdf_upload, + validate_signature_upload, +) YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -233,10 +239,7 @@ class AccountAvatarForm(forms.ModelForm): def clean_avatar_image(self): avatar = self.cleaned_data.get('avatar_image') - if not avatar: - return avatar - if getattr(avatar, 'size', 0) > 5 * 1024 * 1024: - raise forms.ValidationError(_('Das Profilbild darf maximal 5 MB groß sein.')) + validate_avatar_upload(avatar) return avatar @@ -565,26 +568,17 @@ class PortalBrandingForm(forms.ModelForm): def clean_logo_image(self): logo = self.cleaned_data.get('logo_image') - if not logo: - return logo - if getattr(logo, 'size', 0) > 5 * 1024 * 1024: - raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.')) + validate_logo_upload(logo) return logo def clean_pdf_letterhead(self): letterhead = self.cleaned_data.get('pdf_letterhead') - if not letterhead: - return letterhead - if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024: - raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.')) + validate_pdf_upload(letterhead) return letterhead def clean_favicon_image(self): favicon = self.cleaned_data.get('favicon_image') - if not favicon: - return favicon - if getattr(favicon, 'size', 0) > 2 * 1024 * 1024: - raise forms.ValidationError(_('Das Favicon darf maximal 2 MB groß sein.')) + validate_favicon_upload(favicon) return favicon @@ -832,36 +826,7 @@ class OnboardingRequestForm(forms.ModelForm): def clean_signature_image(self): image = self.cleaned_data.get('signature_image') - if not image: - return image - max_size = 4 * 1024 * 1024 # 4 MB - if image.size > max_size: - raise forms.ValidationError('Die Signatur-Datei ist zu groß (max. 4 MB).') - content_type = (getattr(image, 'content_type', '') or '').lower().strip() - extension = Path(getattr(image, 'name', '')).suffix.lower() - allowed_content_types = { - 'image/png', - 'image/x-png', - 'image/jpeg', - 'image/jpg', - 'image/pjpeg', - } - allowed_extensions = {'.png', '.jpg', '.jpeg'} - if content_type and not content_type.startswith('image/'): - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - if content_type and content_type not in allowed_content_types and extension not in allowed_extensions: - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - if not content_type and extension not in allowed_extensions: - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - try: - header = image.read(16) - image.seek(0) - except Exception: - raise forms.ValidationError('Die Signatur-Datei konnte nicht gelesen werden.') - is_png = header.startswith(b'\x89PNG\r\n\x1a\n') - is_jpeg = header.startswith(b'\xff\xd8\xff') - if not (is_png or is_jpeg): - raise forms.ValidationError('Die Signatur-Datei ist kein gültiges PNG/JPG-Bild.') + validate_signature_upload(image) return image def clean(self): diff --git a/backend/workflows/tests/test_upload_validation.py b/backend/workflows/tests/test_upload_validation.py new file mode 100644 index 0000000..7d41117 --- /dev/null +++ b/backend/workflows/tests/test_upload_validation.py @@ -0,0 +1,143 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from io import BytesIO +from PIL import Image + +from workflows.forms import AccountAvatarForm, OnboardingRequestForm, PortalBrandingForm + + +class UploadValidationTests(TestCase): + def test_avatar_rejects_mismatched_extension_and_signature(self): + form = AccountAvatarForm( + data={}, + files={ + 'avatar_image': SimpleUploadedFile( + 'avatar.png', + b'not-a-real-png', + content_type='image/png', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('avatar_image', form.errors) + + def test_logo_accepts_valid_svg(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'logo_image': SimpleUploadedFile( + 'logo.svg', + b'', + content_type='image/svg+xml', + ) + }, + ) + + self.assertTrue(form.is_valid(), form.errors) + + def test_favicon_rejects_wrong_signature(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'favicon_image': SimpleUploadedFile( + 'favicon.ico', + b'not-an-ico', + content_type='image/x-icon', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('favicon_image', form.errors) + + def test_pdf_letterhead_rejects_non_pdf_content(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'pdf_letterhead': SimpleUploadedFile( + 'letterhead.pdf', + b'not-a-pdf', + content_type='application/pdf', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('pdf_letterhead', form.errors) + + def test_signature_accepts_valid_png(self): + buffer = BytesIO() + Image.new('RGBA', (2, 2), (0, 0, 0, 255)).save(buffer, format='PNG') + png_bytes = buffer.getvalue() + form = OnboardingRequestForm( + data={ + 'first_name': 'Max', + 'last_name': 'Mustermann', + 'gender': 'herr', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': 'max.mustermann@workdock.de', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'agreement_confirm': 'on', + }, + files={ + 'signature_image': SimpleUploadedFile( + 'signature.png', + png_bytes, + content_type='image/png', + ) + }, + requester_email='requester@workdock.de', + ) + + self.assertTrue(form.is_valid(), form.errors) diff --git a/backend/workflows/upload_validation.py b/backend/workflows/upload_validation.py new file mode 100644 index 0000000..9e17a86 --- /dev/null +++ b/backend/workflows/upload_validation.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from pathlib import Path + +from django import forms +from django.utils.translation import gettext as _ + + +def _header_matches(extension: str, header: bytes) -> bool: + extension = extension.lower().lstrip(".") + if extension == "png": + return header.startswith(b"\x89PNG\r\n\x1a\n") + if extension in {"jpg", "jpeg"}: + return header.startswith(b"\xff\xd8\xff") + if extension == "webp": + return header.startswith(b"RIFF") and header[8:12] == b"WEBP" + if extension == "pdf": + return header.startswith(b"%PDF") + if extension == "ico": + return header.startswith(b"\x00\x00\x01\x00") + if extension == "svg": + text = header.decode("utf-8", errors="ignore").lower() + return " None: + if not uploaded_file: + return + + if getattr(uploaded_file, "size", 0) > max_size_bytes: + raise forms.ValidationError(size_message) + + extension = Path(getattr(uploaded_file, "name", "")).suffix.lower().lstrip(".") + if extension not in allowed_extensions: + raise forms.ValidationError(invalid_type_message) + + content_type = (getattr(uploaded_file, "content_type", "") or "").lower().strip() + if allowed_content_types and content_type and content_type not in allowed_content_types: + raise forms.ValidationError(invalid_type_message) + + try: + header = uploaded_file.read(512) + uploaded_file.seek(0) + except Exception as exc: + raise forms.ValidationError(unreadable_message) from exc + + if not _header_matches(extension, header): + raise forms.ValidationError(invalid_type_message) + + +def validate_avatar_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg", "webp", "svg"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein PNG-, JPG-, WEBP- oder SVG-Bild hochladen."), + size_message=_("Das Profilbild darf maximal 5 MB groß sein."), + unreadable_message=_("Die Bilddatei konnte nicht gelesen werden."), + ) + + +def validate_logo_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"svg", "png", "jpg", "jpeg", "webp"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein SVG-, PNG-, JPG- oder WEBP-Bild hochladen."), + size_message=_("Das Logo darf maximal 5 MB groß sein."), + unreadable_message=_("Die Logo-Datei konnte nicht gelesen werden."), + ) + + +def validate_favicon_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"ico", "png", "svg", "webp"}, + max_size_bytes=2 * 1024 * 1024, + allowed_content_types={ + "image/x-icon", + "image/vnd.microsoft.icon", + "image/png", + "image/x-png", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte eine ICO-, PNG-, SVG- oder WEBP-Datei hochladen."), + size_message=_("Das Favicon darf maximal 2 MB groß sein."), + unreadable_message=_("Die Favicon-Datei konnte nicht gelesen werden."), + ) + + +def validate_pdf_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"pdf"}, + max_size_bytes=10 * 1024 * 1024, + allowed_content_types={"application/pdf"}, + invalid_type_message=_("Bitte eine gültige PDF-Datei hochladen."), + size_message=_("Der PDF-Briefkopf darf maximal 10 MB groß sein."), + unreadable_message=_("Die PDF-Datei konnte nicht gelesen werden."), + ) + + +def validate_signature_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg"}, + max_size_bytes=4 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + }, + invalid_type_message=_("Bitte eine PNG- oder JPG-Datei hochladen."), + size_message=_("Die Signatur-Datei ist zu groß (max. 4 MB)."), + unreadable_message=_("Die Signatur-Datei konnte nicht gelesen werden."), + )