security(api): rate limiting, constant-time токены, без утечки ошибок
По аудиту маршрутов (OWASP API):
- DRF throttling: глобально UserRateThrottle 120/min; attendance-register —
ScopedRateThrottle 30/min, photo — UserRateThrottle. Анти-DoS/абьюз.
- Mattermost-вебхуки (slash + interactive): сверка токена через
hmac.compare_digest (constant-time) вместо '!='
- /api/attendance/register: PvmsClientError больше не уходит клиентом —
детали в лог, ответ общий ('Сервис учёта временно недоступен')
- /set-password/<token>: мягкий IP-троттл только на НЕвалидные токены
(легитимный валидный токен не блокируется)
Тесты: throttle attendance → 429; вебхук отклоняет неверный/пустой токен;
set-password троттлит невалидные, пропускает валидный. 801 тест, 0 провалов.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -721,6 +721,14 @@ REST_FRAMEWORK = {
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 20,
|
||||
# Антиабьюз/DoS для API. attendance — отметки посещаемости с терминалов.
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.UserRateThrottle',
|
||||
],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'user': '120/min',
|
||||
'attendance': '30/min',
|
||||
},
|
||||
}
|
||||
|
||||
# Без аргументов `manage.py test` должен покрывать core + apps.*, а не только
|
||||
|
||||
@@ -296,3 +296,37 @@ class HiringInviteFlowTests(TestCase):
|
||||
url = reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk})
|
||||
self.assertEqual(client.get(url).status_code, 200)
|
||||
self.assertEqual(client.get(url).status_code, 200)
|
||||
|
||||
|
||||
class SetupTokenThrottleTests(TestCase):
|
||||
"""Антиэнумерация токенов: невалидные по IP троттлятся, валидный — нет."""
|
||||
|
||||
def _make_employee(self):
|
||||
emp = Employee.objects.create(
|
||||
username='setpw.thr', email='setpw.thr@company.local',
|
||||
first_name='С', last_name='Т', hire_date=date(2026, 1, 1),
|
||||
user_location_type='office', mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
)
|
||||
emp.set_unusable_password()
|
||||
emp.save()
|
||||
return emp
|
||||
|
||||
def test_invalid_token_throttled(self):
|
||||
from django.core.cache import cache
|
||||
from apps.employees.views_password import SETUP_MAX_INVALID
|
||||
cache.clear()
|
||||
url = '/set-password/bad-token-xyz/'
|
||||
for _ in range(SETUP_MAX_INVALID):
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
self.assertEqual(self.client.get(url).status_code, 429) # лимит исчерпан
|
||||
|
||||
def test_valid_token_not_throttled(self):
|
||||
from django.core.cache import cache
|
||||
from apps.employees.views_password import SETUP_MAX_INVALID
|
||||
cache.clear()
|
||||
invite = create_password_invite(self._make_employee())
|
||||
# забиваем лимит невалидными токенами с того же IP
|
||||
for _ in range(SETUP_MAX_INVALID + 3):
|
||||
self.client.get('/set-password/nope/')
|
||||
# валидный токен всё равно открывается (счётчик растёт только на невалидных)
|
||||
self.assertEqual(self.client.get(f'/set-password/{invite.token}/').status_code, 200)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Публичная установка пароля по приглашению."""
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
@@ -11,6 +13,17 @@ from apps.employees.services.password_invite import (
|
||||
get_active_invite,
|
||||
)
|
||||
|
||||
# Антиэнумерация токенов: считаем только НЕвалидные токены по IP — легитимный
|
||||
# пользователь попадает на валидный токен и счётчик не трогает. Токен 256-бит
|
||||
# (token_urlsafe(32)), поэтому это мягкий дополнительный рубеж.
|
||||
SETUP_MAX_INVALID = 15
|
||||
SETUP_BLOCK_SECONDS = 600 # 10 минут
|
||||
|
||||
|
||||
def _setup_throttle_key(request) -> str:
|
||||
ip = request.META.get('REMOTE_ADDR', '') or 'unknown'
|
||||
return f'setpw_invalid:ip:{ip}'
|
||||
|
||||
|
||||
def _validate_password_pair(password: str, confirm: str) -> list[str]:
|
||||
errors = []
|
||||
@@ -30,6 +43,11 @@ def _validate_password_pair(password: str, confirm: str) -> list[str]:
|
||||
def password_setup_view(request, token):
|
||||
invite = get_active_invite(token)
|
||||
if invite is None:
|
||||
key = _setup_throttle_key(request)
|
||||
attempts = cache.get(key, 0)
|
||||
if attempts >= SETUP_MAX_INVALID:
|
||||
return HttpResponse('Слишком много попыток. Повторите позже.', status=429)
|
||||
cache.set(key, attempts + 1, SETUP_BLOCK_SECONDS)
|
||||
return render(request, 'password-setup.html', {
|
||||
'invalid': True,
|
||||
}, status=404)
|
||||
|
||||
@@ -660,3 +660,29 @@ class MembershipReconcileTests(TestCase):
|
||||
def test_already_member_is_not_an_error(self):
|
||||
self.service.add_user_to_team.side_effect = Exception('User is already a member of the team')
|
||||
self.assertTrue(sync_service.add_user_to_teams(self.employee))
|
||||
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@override_settings(MATTERMOST_SLASH_COMMAND_TOKEN='s3cr3t-slash-token-value')
|
||||
class SlashCommandTokenAuthTests(TestCase):
|
||||
"""Вебхук слэш-команды: токен сверяется constant-time (hmac.compare_digest)."""
|
||||
|
||||
def _url(self):
|
||||
return reverse('mattermost_slash_tasks')
|
||||
|
||||
def test_wrong_token_forbidden(self):
|
||||
r = self.client.post(self._url(), {'token': 'wrong-token', 'user_id': 'u1'})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_missing_token_forbidden(self):
|
||||
r = self.client.post(self._url(), {'user_id': 'u1'})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
@override_settings(MATTERMOST_SLASH_COMMAND_TOKEN='')
|
||||
def test_unconfigured_token_forbidden(self):
|
||||
# Токен в проекте не задан — fail-closed даже при любом присланном токене.
|
||||
r = self.client.post(self._url(), {'token': 'anything', 'user_id': 'u1'})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -25,7 +26,8 @@ def slash_command_tasks(request):
|
||||
"""Обработчик слэш-команды /tasks — Mattermost шлёт application/x-www-form-urlencoded.
|
||||
https://developers.mattermost.com/integrate/slash-commands/custom/"""
|
||||
token = request.POST.get('token', '')
|
||||
if not settings.MATTERMOST_SLASH_COMMAND_TOKEN or token != settings.MATTERMOST_SLASH_COMMAND_TOKEN:
|
||||
expected = settings.MATTERMOST_SLASH_COMMAND_TOKEN or ''
|
||||
if not expected or not hmac.compare_digest(token, expected):
|
||||
return HttpResponseForbidden('invalid token')
|
||||
|
||||
employee = _find_employee(request.POST.get('user_id'))
|
||||
@@ -77,7 +79,7 @@ def task_done_action(request):
|
||||
context = payload.get('context') or {}
|
||||
# Токен приходить у context (тіло POST), не в URL — без витоку в логи/Referer.
|
||||
provided = context.get('token') or payload.get('token') or ''
|
||||
if provided != expected:
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
return HttpResponseForbidden('invalid token')
|
||||
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@ REST API для регистрации посещений с PPOS/термина
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
@@ -13,11 +14,14 @@ 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.throttling import ScopedRateThrottle, UserRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.employees.models import Employee
|
||||
|
||||
from ..services import register_attendance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..models import AttendanceLog
|
||||
|
||||
_ALLOWED_MIME_SIGNATURES = {
|
||||
@@ -124,6 +128,8 @@ class AttendanceRegisterView(APIView):
|
||||
"""
|
||||
authentication_classes = [SessionAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = 'attendance'
|
||||
|
||||
def post(self, request):
|
||||
serializer = AttendanceCreateSerializer(data=request.data)
|
||||
@@ -162,8 +168,10 @@ class AttendanceRegisterView(APIView):
|
||||
}
|
||||
remote = client.register_attendance(payload)
|
||||
except PvmsClientError as exc:
|
||||
# Детали интеграции — только в лог, клиенту общее сообщение.
|
||||
logger.warning('PVMS register_attendance failed: %s', exc)
|
||||
return Response(
|
||||
{'error': str(exc)},
|
||||
{'error': 'Сервис учёта посещаемости временно недоступен'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return Response(remote, status=status.HTTP_201_CREATED)
|
||||
@@ -230,6 +238,7 @@ class AttendancePhotoView(APIView):
|
||||
"""
|
||||
authentication_classes = [SessionAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_classes = [UserRateThrottle]
|
||||
|
||||
def get(self, request, pk):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import date
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from apps.employees.models import Employee
|
||||
|
||||
|
||||
def _user():
|
||||
return Employee.objects.create(
|
||||
username='att.user', email='att.user@company.local',
|
||||
first_name='Атт', last_name='Тест', hire_date=date(2026, 1, 1),
|
||||
user_location_type='office', mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class AttendanceThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=_user())
|
||||
|
||||
def test_register_is_rate_limited(self):
|
||||
url = '/api/attendance/register/'
|
||||
# THROTTLE_RATES фиксируется при импорте DRF — понижаем лимит детерминированно.
|
||||
with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'attendance': '1/min'}):
|
||||
first = self.client.post(url, {}, format='json')
|
||||
self.assertNotEqual(first.status_code, 429) # 1-й проходит throttle
|
||||
second = self.client.post(url, {}, format='json')
|
||||
self.assertEqual(second.status_code, 429) # 2-й заблокирован
|
||||
Reference in New Issue
Block a user