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 "