Files
ACORE/core/middleware.py
lokses 3d8f88b61e security: CSP-middleware (Фаза 1, Report-Only, выкл. по умолчанию)
ContentSecurityPolicyMiddleware ставит заголовок CSP при CSP_ENABLED=True.
По умолчанию выключен; CSP_REPORT_ONLY=True — только отчёты, ничего не
блокирует (для сбора нарушений перед боевым режимом).

Политика (дефолт под текущий стек): default-src 'self'; object-src 'none';
base-uri 'self'; frame-ancestors 'none'; form-action 'self';
script/style-src с 'unsafe-inline' (пока есть инлайн onclick/<script> —
Фаза 2: вынести и перейти на nonce) + CDN (jsdelivr/unpkg/cdnjs) + тайлы карт.
CSP_EXTRA_HOSTS — внешние хосты (напр. домен OnlyOffice Document Server).

htmx CSP-чистый (allowEval=false, нет hx-on) — 'unsafe-eval' не нужен.

Тесты: выкл по умолчанию, Report-Only/enforce заголовки, extra-hosts. 795 ок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-01 16:49:07 +02:00

185 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import timedelta
from django.utils import timezone
from core.access_preview import SESSION_KEY as ACCESS_PREVIEW_SESSION_KEY
from core.access_preview import access_preview_available, attach_preview_to_user
# Не пишем last_seen на каждый запрос — обновляем не чаще раза в этот интервал.
LAST_SEEN_WRITE_THROTTLE = timedelta(seconds=60)
# Порог, после которого сотрудник считается офлайн (используется в core.views.dashboard_view).
ONLINE_WINDOW = timedelta(minutes=5)
def _build_csp(settings) -> str:
"""Собрать строку CSP из настроек (с дефолтами под текущий стек)."""
policy = getattr(settings, 'CONTENT_SECURITY_POLICY', '') or ''
if policy:
return policy
# Внешние хосты из конфигурации (напр. хост OnlyOffice Document Server).
extra = ' '.join(getattr(settings, 'CSP_EXTRA_HOSTS', []) or [])
cdn = 'https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com'
tiles = 'https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org'
# 'unsafe-inline' нужен, пока в шаблонах есть инлайн onclick/<script> (Фаза 2 —
# вынести их и перейти на nonce). Даже так CSP блокирует внешние вредоносные
# скрипты (<script src=//evil>), плагины, base-tag и клик-джекинг.
return '; '.join(filter(None, [
"default-src 'self'",
f"script-src 'self' 'unsafe-inline' {cdn} {extra}".strip(),
f"style-src 'self' 'unsafe-inline' {cdn}".strip(),
f"img-src 'self' data: {tiles} {extra}".strip(),
f"font-src 'self' data: {cdn}".strip(),
f"connect-src 'self' {extra}".strip(),
f"frame-src 'self' {extra}".strip(),
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
]))
class ContentSecurityPolicyMiddleware:
"""Выставляет заголовок CSP. По умолчанию выключен (CSP_ENABLED=False).
CSP_REPORT_ONLY=True (дефолт) — режим отчётов: ничего не блокируется,
нарушения видны в консоли/отчётах. Для боевой блокировки — выставить
CSP_REPORT_ONLY=False, когда инлайн-скрипты вынесены (Фаза 2).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
from django.conf import settings
if not getattr(settings, 'CSP_ENABLED', False):
return response
if 'Content-Security-Policy' in response or 'Content-Security-Policy-Report-Only' in response:
return response
header = (
'Content-Security-Policy-Report-Only'
if getattr(settings, 'CSP_REPORT_ONLY', True)
else 'Content-Security-Policy'
)
response[header] = _build_csp(settings)
return response
class DevAccessPreviewMiddleware:
"""В DEBUG вешает на request.user пресет «смотреть как…» из session."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, 'user', None)
preview_id = ''
if user is not None and access_preview_available(user):
preview_id = (request.session.get(ACCESS_PREVIEW_SESSION_KEY) or '').strip()
attach_preview_to_user(user, preview_id)
else:
if user is not None and getattr(user, 'is_authenticated', False):
attach_preview_to_user(user, '')
request.access_preview = preview_id if preview_id else ''
return self.get_response(request)
class MaintenanceModeMiddleware:
"""Режим техработ: при MAINTENANCE_MODE=True отдаёт 503 всем, кроме
суперпользователей (чтобы чинить), страниц входа/админки и статики.
По умолчанию выключен — поведение не меняется.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
from django.conf import settings
if getattr(settings, 'MAINTENANCE_MODE', False):
user = getattr(request, 'user', None)
if not (user and getattr(user, 'is_superuser', False)) and not self._allowed(request):
from django.shortcuts import render
return render(request, '503.html', status=503)
return self.get_response(request)
@staticmethod
def _allowed(request) -> bool:
# Логин/2FA/статика — чтобы суперпользователь смог войти и выключить режим.
path = request.path
if path.startswith(('/accounts/login', '/login', '/2fa/', '/static/', '/logout')):
return True
try:
from django.urls import reverse
if path.startswith(reverse('admin:index')):
return True
except Exception:
pass
return False
class PortalSectionAccessMiddleware:
"""Барьер доступа к разделам портала по префиксу URL.
Ранее «открытые» разделы (новости, задачи, чат и т.п.) были доступны
любому вошедшему. Теперь сотрудник без назначенной группы их не видит и
не может открыть даже прямым URL. Проверка централизована здесь, чтобы
покрыть и вложенные страницы (например /tasks/5/), а не только лендинги.
Базовые разделы (BASELINE_OPEN_KEYS) и разделы с собственным
@section_required сюда не входят.
"""
# первый сегмент пути -> ключ раздела портала
PATH_SECTION = {
'news': 'news',
'tasks': 'tasks',
'documents': 'documents',
'chat': 'chat',
'calendar': 'calendar',
'dislocation': 'dislocation',
'hr-portal': 'hr_portal',
'help': 'help',
}
# /settings/ не гейтим: это лишь редирект на свой профиль (2FA/пароль).
# Пункт меню «Настройки» скрывается в шаблоне через can_section.
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, 'user', None)
if user is not None and getattr(user, 'is_authenticated', False):
segment = request.path.strip('/').split('/', 1)[0]
key = self.PATH_SECTION.get(segment)
if key is not None:
from core.portal_access import deny_section_access, user_can_access
if not user_can_access(user, key):
return deny_section_access(request)
return self.get_response(request)
class UpdateLastSeenMiddleware:
"""
Обновляет Employee.last_seen для авторизованных пользователей — основа для
метрики «онлайн зараз» на дашборде. Пишет через .update(), а не save(),
чтобы не дёргать post_save-сигналы синхронизации с AD/Mattermost на каждый запрос.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
user = getattr(request, 'user', None)
if user is not None and user.is_authenticated:
now = timezone.now()
if not user.last_seen or now - user.last_seen >= LAST_SEEN_WRITE_THROTTLE:
# user — SimpleLazyObject; user.__class__ (в отличие от type(user))
# корректно возвращает класс обёрнутого объекта (Employee).
user.__class__.objects.filter(pk=user.pk).update(last_seen=now)
user.last_seen = now
return response