Security hardening: fix open redirect, API auth, MIME validation, PII exposure

- 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)
This commit is contained in:
2026-05-31 18:05:54 +03:00
parent 24c2f28e43
commit faa06b9f82
5 changed files with 121 additions and 30 deletions
+13 -1
View File
@@ -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',
+20 -7
View File
@@ -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']
+17 -12
View File
@@ -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:
+67 -10
View File
@@ -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/<uuid>/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)
+4
View File
@@ -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')