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:
2026-11-01 20:42:35 +02:00
parent 3d8f88b61e
commit 9fb33be6b2
7 changed files with 133 additions and 3 deletions
+8
View File
@@ -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.*, а не только
+34
View File
@@ -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)
+18
View File
@@ -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)
+26
View File
@@ -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)
+4 -2
View File
@@ -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:
+10 -1
View File
@@ -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:
+33
View File
@@ -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-й заблокирован