From faa06b9f828a72f5f7c609f34abe2ac572c1d7f6 Mon Sep 17 00:00:00 2001 From: lokses Date: Sun, 31 May 2026 18:05:54 +0300 Subject: [PATCH] Security hardening: fix open redirect, API auth, MIME validation, PII exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/views.py: fix open redirect in login — validate next_url with url_has_allowed_host_and_scheme before redirecting - visit_manager/api/views.py: add explicit permission_classes/ authentication_classes on all 3 API views; add MIME magic-byte validation + Pillow verify for photo uploads; validate employee_id/ location_id/date query params; restrict photo access to is_staff, own record, or direct manager - employees/serializers.py: split into EmployeeSerializer (public fields only) and EmployeeDetailSerializer (PII fields, for HR/admin) - mattermost_service.py: sanitize sensitive fields (password, token) before logging; log only error message not full response body - settings/base.py: add SESSION/CSRF cookie HttpOnly + SameSite flags; set file upload size limits (15MB); remove BasicAuthentication from DRF (sends credentials in plaintext) --- acore/settings/base.py | 14 +++- apps/employees/serializers.py | 27 +++++-- .../mattermost_service.py | 29 ++++--- apps/visit_manager/api/views.py | 77 ++++++++++++++++--- core/views.py | 4 + 5 files changed, 121 insertions(+), 30 deletions(-) diff --git a/acore/settings/base.py b/acore/settings/base.py index 94249ea..7998215 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -112,6 +112,18 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Required for OSM tile usage policy: send origin as Referer on cross-origin requests. SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' +# --- Security --- +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SAMESITE = 'Lax' +X_FRAME_OPTIONS = 'DENY' + +# File upload limits +DATA_UPLOAD_MAX_MEMORY_SIZE = 15 * 1024 * 1024 # 15 MB +FILE_UPLOAD_MAX_MEMORY_SIZE = 15 * 1024 * 1024 # 15 MB +DATA_UPLOAD_MAX_NUMBER_FIELDS = 500 + UNFOLD = { "SITE_TITLE": "A-CORE", @@ -342,7 +354,7 @@ LOCATION_FIELD = { REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', + # BasicAuthentication убран: передаёт credentials в открытом виде ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', diff --git a/apps/employees/serializers.py b/apps/employees/serializers.py index 5fd9198..ee61542 100644 --- a/apps/employees/serializers.py +++ b/apps/employees/serializers.py @@ -3,16 +3,29 @@ from apps.employees.models import Employee class EmployeeSerializer(serializers.ModelSerializer): + """Базовый сериализатор — только публичные поля.""" groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name') class Meta: model = Employee fields = [ - 'id', 'username', 'first_name', 'last_name', 'middle_name', 'email', - 'groups', 'phone', 'personal_phone', 'personal_email', 'inn_code', 'birth_date', - 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', - 'user_location_type', 'user_organization', 'photo', - 'mattermost_username', 'mattermost_sync_enabled', - 'is_staff', 'is_active', 'date_joined' + 'id', 'username', 'first_name', 'last_name', 'middle_name', + 'email', 'groups', 'photo', 'post', 'department', + 'user_location_type', 'user_organization', 'user_pharmacy', + 'is_active', 'date_joined', + ] + read_only_fields = ['id', 'date_joined'] + + +class EmployeeDetailSerializer(EmployeeSerializer): + """Детальный сериализатор с PII — только для HR/администраторов.""" + + class Meta(EmployeeSerializer.Meta): + fields = EmployeeSerializer.Meta.fields + [ + 'phone', 'personal_phone', 'personal_email', + 'birth_date', 'inn_code', + 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', + 'mattermost_username', 'mattermost_sync_enabled', + 'ad_sync_enabled', 'hire_date', 'termination_date', + 'is_staff', ] - read_only_fields = ['id', 'date_joined'] \ No newline at end of file diff --git a/apps/mattermost_connector/mattermost_service.py b/apps/mattermost_connector/mattermost_service.py index 05e7409..497fa9b 100755 --- a/apps/mattermost_connector/mattermost_service.py +++ b/apps/mattermost_connector/mattermost_service.py @@ -35,15 +35,20 @@ class MattermostService: 'Content-Type': 'application/json' } + _SENSITIVE_FIELDS = {'password', 'new_password', 'token', 'auth_data'} + + def _sanitize_log_data(self, data: Dict) -> Dict: + return {k: '***' if k in self._SENSITIVE_FIELDS else v for k, v in data.items()} + def _make_request(self, method: str, endpoint: str, data: Dict = None) -> Dict: """Базовый метод для выполнения HTTP запросов""" url = f"{self.base_url}/api/v4/{endpoint}" - + try: - logger.info(f"Mattermost API request: {method} {url}") + logger.info(f"Mattermost API: {method} {endpoint}") if data: - logger.info(f"Request data: {data}") - + logger.debug(f"Request data: {self._sanitize_log_data(data)}") + response = requests.request( method=method, url=url, @@ -51,18 +56,18 @@ class MattermostService: json=data, timeout=30 ) - - # Логируем ответ при ошибке + if not response.ok: - logger.error(f"Mattermost API error response: {response.status_code}") - logger.error(f"Response body: {response.text}") - + logger.error(f"Mattermost API error: {response.status_code} {endpoint}") + try: + logger.error(f"Error: {response.json().get('message', 'unknown')}") + except Exception: + pass + response.raise_for_status() return response.json() if response.content else {} except requests.RequestException as e: - logger.error(f"Mattermost API error: {e}") - if hasattr(e, 'response') and e.response is not None: - logger.error(f"Error response text: {e.response.text}") + logger.error(f"Mattermost API request failed: {endpoint} — {type(e).__name__}") raise def create_user(self, user_data: Dict) -> Dict: diff --git a/apps/visit_manager/api/views.py b/apps/visit_manager/api/views.py index 043dbd1..d7270f7 100644 --- a/apps/visit_manager/api/views.py +++ b/apps/visit_manager/api/views.py @@ -2,30 +2,63 @@ REST API для регистрации посещений с PPOS/терминалов. """ import base64 +import io +from datetime import datetime + from django.http import HttpResponseRedirect, Http404 +from PIL import Image from rest_framework import serializers, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from ..services import register_attendance from ..models import AttendanceLog +_ALLOWED_MIME_SIGNATURES = { + b'\xff\xd8\xff': 'image/jpeg', + b'\x89PNG': 'image/png', +} +_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 MB + class AttendanceCreateSerializer(serializers.Serializer): - employee_id = serializers.IntegerField() - location_id = serializers.IntegerField() + employee_id = serializers.IntegerField(min_value=1) + location_id = serializers.IntegerField(min_value=1) direction = serializers.ChoiceField(choices=['in', 'out']) photo_b64 = serializers.CharField(write_only=True) - device_id = serializers.CharField(required=False, default='') + device_id = serializers.CharField(required=False, default='', max_length=100) def validate_photo_b64(self, value: str) -> bytes: try: if ',' in value: value = value.split(',', 1)[1] - return base64.b64decode(value) + photo_bytes = base64.b64decode(value) except Exception: raise serializers.ValidationError('Невалидный base64') + if len(photo_bytes) > _MAX_PHOTO_BYTES: + raise serializers.ValidationError('Фото превышает допустимый размер (10 МБ)') + + # Проверяем сигнатуру файла (magic bytes) + mime = None + for sig, mime_type in _ALLOWED_MIME_SIGNATURES.items(): + if photo_bytes[:len(sig)] == sig: + mime = mime_type + break + if mime is None: + raise serializers.ValidationError('Допускаются только JPEG и PNG изображения') + + # Проверяем что файл действительно является изображением + try: + img = Image.open(io.BytesIO(photo_bytes)) + img.verify() + except Exception: + raise serializers.ValidationError('Файл повреждён или не является изображением') + + return photo_bytes + class AttendanceLogSerializer(serializers.ModelSerializer): photo_thumb = serializers.SerializerMethodField() @@ -46,6 +79,8 @@ class AttendanceRegisterView(APIView): POST /api/attendance/register/ Принимает фото в base64, создаёт запись посещения. """ + authentication_classes = [SessionAuthentication] + permission_classes = [IsAuthenticated] def post(self, request): serializer = AttendanceCreateSerializer(data=request.data) @@ -68,16 +103,30 @@ class AttendanceListView(APIView): GET /api/attendance/?employee_id=&date=YYYY-MM-DD&location_id= Список записей с превью (без оригиналов фото). """ + authentication_classes = [SessionAuthentication] + permission_classes = [IsAuthenticated] def get(self, request): qs = AttendanceLog.objects.select_related('employee', 'location') if emp := request.query_params.get('employee_id'): - qs = qs.filter(employee_id=emp) + try: + qs = qs.filter(employee_id=int(emp)) + except (ValueError, TypeError): + return Response({'error': 'employee_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST) + if loc := request.query_params.get('location_id'): - qs = qs.filter(location_id=loc) - if date := request.query_params.get('date'): - qs = qs.filter(timestamp__date=date) + try: + qs = qs.filter(location_id=int(loc)) + except (ValueError, TypeError): + return Response({'error': 'location_id должен быть числом'}, status=status.HTTP_400_BAD_REQUEST) + + if date_str := request.query_params.get('date'): + try: + datetime.strptime(date_str, '%Y-%m-%d') + qs = qs.filter(timestamp__date=date_str) + except ValueError: + return Response({'error': 'Формат даты: YYYY-MM-DD'}, status=status.HTTP_400_BAD_REQUEST) qs = qs.order_by('-timestamp')[:200] return Response(AttendanceLogSerializer(qs, many=True).data) @@ -86,14 +135,22 @@ class AttendanceListView(APIView): class AttendancePhotoView(APIView): """ GET /api/attendance//photo/ - Redirect к оригиналу фото. + Redirect к оригиналу фото — только для is_staff или руководителя сотрудника. """ + authentication_classes = [SessionAuthentication] + permission_classes = [IsAuthenticated] def get(self, request, pk): try: - log = AttendanceLog.objects.get(pk=pk) + log = AttendanceLog.objects.select_related('employee').get(pk=pk) except AttendanceLog.DoesNotExist: raise Http404 + if not log.photo: raise Http404 + + user = request.user + if not (user.is_staff or user.pk == log.employee_id or user.pk == log.employee.manager_id): + raise Http404 + return HttpResponseRedirect(log.photo.url) diff --git a/core/views.py b/core/views.py index b93c86a..bf3296f 100644 --- a/core/views.py +++ b/core/views.py @@ -1,6 +1,8 @@ from django.shortcuts import render, redirect from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required +from django.utils.http import url_has_allowed_host_and_scheme +from django.conf import settings from apps.employees.models.base import Employee from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory @@ -182,6 +184,8 @@ def login_view(request): if user is not None: login(request, user) next_url = request.GET.get('next') or request.POST.get('next') or '/' + if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=settings.ALLOWED_HOSTS): + next_url = '/' return redirect(next_url) return render(request, 'login.html', {'login_error': 'Неверный логин или пароль'}) return render(request, 'login.html')