snapshot: preserve upload hardening phase
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from pathlib import Path
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.contrib.auth import authenticate, get_user_model, password_validation
|
from django.contrib.auth import authenticate, get_user_model, password_validation
|
||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
|
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 .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 .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 .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')]
|
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
|
||||||
@@ -233,10 +239,7 @@ class AccountAvatarForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_avatar_image(self):
|
def clean_avatar_image(self):
|
||||||
avatar = self.cleaned_data.get('avatar_image')
|
avatar = self.cleaned_data.get('avatar_image')
|
||||||
if not avatar:
|
validate_avatar_upload(avatar)
|
||||||
return avatar
|
|
||||||
if getattr(avatar, 'size', 0) > 5 * 1024 * 1024:
|
|
||||||
raise forms.ValidationError(_('Das Profilbild darf maximal 5 MB groß sein.'))
|
|
||||||
return avatar
|
return avatar
|
||||||
|
|
||||||
|
|
||||||
@@ -565,26 +568,17 @@ class PortalBrandingForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_logo_image(self):
|
def clean_logo_image(self):
|
||||||
logo = self.cleaned_data.get('logo_image')
|
logo = self.cleaned_data.get('logo_image')
|
||||||
if not logo:
|
validate_logo_upload(logo)
|
||||||
return logo
|
|
||||||
if getattr(logo, 'size', 0) > 5 * 1024 * 1024:
|
|
||||||
raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.'))
|
|
||||||
return logo
|
return logo
|
||||||
|
|
||||||
def clean_pdf_letterhead(self):
|
def clean_pdf_letterhead(self):
|
||||||
letterhead = self.cleaned_data.get('pdf_letterhead')
|
letterhead = self.cleaned_data.get('pdf_letterhead')
|
||||||
if not letterhead:
|
validate_pdf_upload(letterhead)
|
||||||
return letterhead
|
|
||||||
if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024:
|
|
||||||
raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.'))
|
|
||||||
return letterhead
|
return letterhead
|
||||||
|
|
||||||
def clean_favicon_image(self):
|
def clean_favicon_image(self):
|
||||||
favicon = self.cleaned_data.get('favicon_image')
|
favicon = self.cleaned_data.get('favicon_image')
|
||||||
if not favicon:
|
validate_favicon_upload(favicon)
|
||||||
return favicon
|
|
||||||
if getattr(favicon, 'size', 0) > 2 * 1024 * 1024:
|
|
||||||
raise forms.ValidationError(_('Das Favicon darf maximal 2 MB groß sein.'))
|
|
||||||
return favicon
|
return favicon
|
||||||
|
|
||||||
|
|
||||||
@@ -832,36 +826,7 @@ class OnboardingRequestForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_signature_image(self):
|
def clean_signature_image(self):
|
||||||
image = self.cleaned_data.get('signature_image')
|
image = self.cleaned_data.get('signature_image')
|
||||||
if not image:
|
validate_signature_upload(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.')
|
|
||||||
return image
|
return image
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|||||||
143
backend/workflows/tests/test_upload_validation.py
Normal file
143
backend/workflows/tests/test_upload_validation.py
Normal file
@@ -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'<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\"></svg>',
|
||||||
|
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)
|
||||||
147
backend/workflows/upload_validation.py
Normal file
147
backend/workflows/upload_validation.py
Normal file
@@ -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 "<svg" in text
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uploaded_file(
|
||||||
|
uploaded_file,
|
||||||
|
*,
|
||||||
|
allowed_extensions: set[str],
|
||||||
|
max_size_bytes: int,
|
||||||
|
allowed_content_types: set[str] | None = None,
|
||||||
|
invalid_type_message: str,
|
||||||
|
size_message: str,
|
||||||
|
unreadable_message: str,
|
||||||
|
) -> 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."),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user