3d8f88b61e
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>
185 lines
8.4 KiB
Python
185 lines
8.4 KiB
Python
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
|