snapshot: preserve upload hardening phase

This commit is contained in:
Md Bayazid Bostame
2026-03-27 12:44:53 +01:00
parent b9441f2503
commit eb0fb811e4
3 changed files with 302 additions and 47 deletions

View File

@@ -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):

View 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)

View 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."),
)