Harden uploads and enforce language-prefixed routes

This commit is contained in:
Victor Andersson
2025-11-09 10:03:23 +01:00
parent 3835be3c17
commit 79f5cb8ff3
10 changed files with 263 additions and 66 deletions

View File

@@ -1,11 +1,10 @@
from django.conf import settings
from django.contrib import admin
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from .models import Claim, ClaimLog, Project, SystemSetting
from .models import Claim, ClaimLog, Project
class ClaimLogInline(admin.TabularInline):
@@ -20,7 +19,7 @@ class ClaimAdmin(admin.ModelAdmin):
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project", "paid_at")
search_fields = ("full_name", "email", "description")
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "internal_payments_enabled", "reset_paid_button")
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "reset_paid_button")
readonly_fields = base_readonly
inlines = [ClaimLogInline]
actions = ("mark_as_paid", "mark_as_unpaid")
@@ -29,10 +28,6 @@ class ClaimAdmin(admin.ModelAdmin):
def paid(self, obj):
return obj.is_paid
@admin.display(description="Intern betalningshantering på?")
def internal_payments_enabled(self, obj):
return SystemSetting.internal_payments_active()
@admin.display(description="Återställ betalning")
def reset_paid_button(self, obj):
if not obj.is_paid:
@@ -115,17 +110,6 @@ class ClaimAdmin(admin.ModelAdmin):
return self.base_readonly
@admin.register(SystemSetting)
class SystemSettingAdmin(admin.ModelAdmin):
list_display = ("internal_payments_enabled", "updated_at")
list_display_links = ("updated_at",)
list_editable = ("internal_payments_enabled",)
actions = None
def has_add_permission(self, request):
return not SystemSetting.objects.exists()
@admin.register(ClaimLog)
class ClaimLogAdmin(admin.ModelAdmin):
list_display = ("claim", "action", "from_status", "to_status", "performed_by", "created_at")

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2025-11-09 02:06
import claims.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0006_systemsetting'),
]
operations = [
migrations.DeleteModel(
name='SystemSetting',
),
migrations.AlterField(
model_name='claim',
name='receipt',
field=models.FileField(blank=True, null=True, upload_to='receipts/', validators=[claims.validators.validate_receipt_file]),
),
]

View File

@@ -5,6 +5,8 @@ from django.core.files.storage import default_storage
from django.db import models
from django.utils.translation import gettext_lazy as _
from .validators import validate_receipt_file
class Project(models.Model):
name = models.CharField(max_length=255)
@@ -51,7 +53,12 @@ class Claim(models.Model):
)
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
account_number = models.CharField(max_length=50)
receipt = models.FileField(upload_to="receipts/", blank=True, null=True)
receipt = models.FileField(
upload_to="receipts/",
blank=True,
null=True,
validators=[validate_receipt_file],
)
project = models.ForeignKey(
Project,
null=True,
@@ -141,23 +148,3 @@ class ClaimLog(models.Model):
def __str__(self):
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
class SystemSetting(models.Model):
internal_payments_enabled = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Systeminställning"
verbose_name_plural = "Systeminställningar"
def __str__(self):
return "Systeminställningar"
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
@classmethod
def internal_payments_active(cls):
return cls.get_solo().internal_payments_enabled

View File

@@ -1,3 +1,67 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
# Create your tests here.
from .validators import validate_receipt_file
from .views import SubmitClaimView
class ReceiptValidatorTests(TestCase):
def test_accepts_valid_pdf(self):
file_obj = SimpleUploadedFile(
"receipt.pdf",
b"%PDF-1.4\nsample",
content_type="application/pdf",
)
try:
validate_receipt_file(file_obj)
except ValidationError as exc: # pragma: no cover - explicit failure message
self.fail(f"Valid PDF rejected: {exc}")
def test_rejects_disallowed_extension(self):
file_obj = SimpleUploadedFile(
"script.exe",
b"MZ fake exe",
content_type="application/octet-stream",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
@override_settings(CLAIMS_MAX_RECEIPT_BYTES=1024)
def test_rejects_too_large_file(self):
big_payload = b"%PDF-1.4\n" + b"a" * 2048
file_obj = SimpleUploadedFile(
"large.pdf",
big_payload,
content_type="application/pdf",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
def test_rejects_signature_mismatch(self):
file_obj = SimpleUploadedFile(
"fake.pdf",
b"\x89PNG\r\n\x1a\nnot a pdf",
content_type="application/pdf",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
class ClaimFormsetLimitTests(TestCase):
def test_default_formset_has_single_row(self):
view = SubmitClaimView()
formset = view.build_formset(extra=1)
self.assertEqual(formset.total_form_count(), 1)
def test_cannot_submit_more_than_max_forms(self):
view = SubmitClaimView()
data = {
"claim_lines-TOTAL_FORMS": "6",
"claim_lines-INITIAL_FORMS": "0",
"claim_lines-MIN_NUM_FORMS": "1",
"claim_lines-MAX_NUM_FORMS": "5",
}
formset = view.build_formset(data=data)
self.assertFalse(formset.is_valid())
self.assertTrue(formset.non_form_errors())

115
claims/validators.py Normal file
View File

@@ -0,0 +1,115 @@
import io
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
DEFAULT_ALLOWED_EXTENSIONS = ("pdf", "png", "jpg", "jpeg")
DEFAULT_ALLOWED_CONTENT_TYPES = ("application/pdf", "image/png", "image/jpeg")
PDF_SIGNATURE = b"%PDF-"
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
JPEG_PREFIX = b"\xff\xd8"
def _peek(file_obj, length=8):
"""Read the first bytes of a file without consuming the stream."""
stream = getattr(file_obj, "file", file_obj)
if isinstance(stream, io.BytesIO):
pos = stream.tell()
stream.seek(0)
data = stream.read(length)
stream.seek(pos)
return data
try:
pos = stream.tell()
except (AttributeError, OSError):
pos = None
try:
stream.seek(0)
data = stream.read(length)
finally:
if pos is not None:
stream.seek(pos)
else:
try:
stream.seek(0)
except (AttributeError, OSError):
pass
return data
def _allowed_extensions():
exts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", DEFAULT_ALLOWED_EXTENSIONS)
normalized = {ext.lower() for ext in exts}
return normalized or set(DEFAULT_ALLOWED_EXTENSIONS)
def _allowed_content_types():
cts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", DEFAULT_ALLOWED_CONTENT_TYPES)
normalized = {ct.lower() for ct in cts}
return normalized or set(DEFAULT_ALLOWED_CONTENT_TYPES)
def _max_file_size():
return int(getattr(settings, "CLAIMS_MAX_RECEIPT_BYTES", 10 * 1024 * 1024))
def _extension(validated_file):
name = validated_file.name or ""
if "." not in name:
return ""
return name.rsplit(".", 1)[-1].lower()
def _signature_matches(ext, header):
if not header:
return False
if ext == "pdf":
return header.startswith(PDF_SIGNATURE)
if ext == "png":
return header.startswith(PNG_SIGNATURE)
if ext in {"jpg", "jpeg"}:
return header.startswith(JPEG_PREFIX)
return False
def validate_receipt_file(uploaded_file):
"""Ensure uploaded receipts comply with size/format requirements."""
if not uploaded_file:
return
max_bytes = _max_file_size()
if uploaded_file.size > max_bytes:
max_mb = round(max_bytes / (1024 * 1024), 2)
raise ValidationError(
_("Kvitton får vara max %(size)s MB."),
code="file_too_large",
params={"size": max_mb},
)
ext = _extension(uploaded_file)
extensions = _allowed_extensions()
if ext not in extensions:
raise ValidationError(
_("Otillåtet filformat. Tillåtna format är %(formats)s."),
code="invalid_extension",
params={"formats": ", ".join(sorted(extensions))},
)
content_type = getattr(uploaded_file, "content_type", "")
allowed_types = _allowed_content_types()
if content_type and content_type.lower() not in allowed_types:
raise ValidationError(
_("Otillåten MIME-typ: %(type)s."),
code="invalid_mime",
params={"type": content_type},
)
header = _peek(uploaded_file, length=8)
if not _signature_matches(ext, header):
raise ValidationError(
_("Filens innehåll matchar inte förväntat format."),
code="invalid_signature",
)

View File

@@ -20,7 +20,7 @@ from .forms import (
UserPermissionForm,
)
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog, SystemSetting
from .models import Claim, ClaimLog
User = get_user_model()
@@ -31,17 +31,21 @@ class SubmitClaimView(View):
def get_extra_forms(self):
try:
count = int(self.request.GET.get("forms", 2))
count = int(self.request.GET.get("forms", 1))
except (TypeError, ValueError):
count = 2
count = 1
return max(1, min(count, self.max_extra_forms))
def build_formset(self, *, data=None, files=None, extra=0):
extra_forms = max(0, extra - 1)
FormSet = formset_factory(
ClaimLineForm,
extra=extra,
extra=extra_forms,
min_num=1,
max_num=self.max_extra_forms,
absolute_max=self.max_extra_forms,
validate_min=True,
validate_max=True,
)
return FormSet(data=data, files=files, prefix="claim_lines")
@@ -154,7 +158,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
context["payments_enabled"] = SystemSetting.internal_payments_active()
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
return context
def post(self, request, *args, **kwargs):
@@ -202,7 +206,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
return redirect(request.get_full_path())
def _handle_payment(self, request):
if not SystemSetting.internal_payments_active():
if not getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False):
messages.error(request, _("Betalningshantering är inte aktiverad."))
return redirect(request.get_full_path())
if not request.user.has_perm("claims.change_claim"):