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:
+13
-1
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user