Files
training-software/tests/test_security.py
Paperclip CTO 1f6a4183d4 feat(TRA-247): M5 security hardening — tests, markers, and header enforcement
- tests/test_security.py: 30 security regression tests covering secure
  headers, CSP directives, middleware ordering, DRF throttle configuration,
  and SecurityAuditMiddleware event-detection logic
- tests/test_upload.py: 19 upload defense tests covering extension allow-list,
  byte-length limits, and magic-byte signature validation (polyglot / disguised
  executable detection)
- pytest.ini: register 'security' and 'upload' markers (--strict-markers
  enforcement was already on)

Security settings already committed in feat(TRA-233) via harness include:
SECURE_REFERRER_POLICY, CSP_* directives, DEFAULT_THROTTLE_*, MAX_UPLOAD_SIZE,
SESSION/CSRF cookie hardening, AWS presigned URL policy, and
SecurityAuditMiddleware with dual-logger (access + security) pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:11:23 +02:00

243 lines
9.3 KiB
Python

"""
Security regression tests — M5: Security Hardening and Upload Defense.
Verifies that:
- Secure headers are configured in all settings profiles.
- CSP directives are present and restrictive.
- DRF throttle classes and rates are wired up.
- The SecurityAuditMiddleware is in the middleware stack.
- The security middleware correctly identifies events to log.
"""
import importlib
import os
import pytest
from unittest.mock import patch, MagicMock
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_settings(module_path: str):
env_patch = {
"DJANGO_SECRET_KEY": "test-secret-key",
"DJANGO_ALLOWED_HOSTS": "localhost",
"DATABASE_URL": "postgres://training:training@localhost:5432/training",
"REDIS_URL": "redis://localhost:6379/0",
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
"EMAIL_HOST": "smtp.example.com",
"EMAIL_HOST_USER": "user@example.com",
"EMAIL_HOST_PASSWORD": "secret",
"DEFAULT_FROM_EMAIL": "noreply@example.com",
}
with patch.dict(os.environ, env_patch, clear=False):
return importlib.import_module(module_path)
# ---------------------------------------------------------------------------
# Secure-header settings
# ---------------------------------------------------------------------------
@pytest.mark.security
class TestSecureHeaderSettings:
def test_x_frame_options_deny(self):
mod = _load_settings("config.settings.base")
assert mod.X_FRAME_OPTIONS == "DENY"
def test_content_type_nosniff(self):
mod = _load_settings("config.settings.base")
assert mod.SECURE_CONTENT_TYPE_NOSNIFF is True
def test_referrer_policy(self):
mod = _load_settings("config.settings.base")
assert mod.SECURE_REFERRER_POLICY == "strict-origin-when-cross-origin"
def test_prod_hsts_configured(self):
mod = _load_settings("config.settings.prod")
assert mod.SECURE_HSTS_SECONDS >= 31536000
def test_prod_hsts_include_subdomains(self):
mod = _load_settings("config.settings.prod")
assert mod.SECURE_HSTS_INCLUDE_SUBDOMAINS is True
def test_prod_hsts_preload(self):
mod = _load_settings("config.settings.prod")
assert mod.SECURE_HSTS_PRELOAD is True
def test_prod_ssl_redirect(self):
mod = _load_settings("config.settings.prod")
assert mod.SECURE_SSL_REDIRECT is True
def test_prod_session_cookie_secure(self):
mod = _load_settings("config.settings.prod")
assert mod.SESSION_COOKIE_SECURE is True
def test_prod_session_cookie_httponly(self):
mod = _load_settings("config.settings.prod")
assert mod.SESSION_COOKIE_HTTPONLY is True
def test_prod_csrf_cookie_secure(self):
mod = _load_settings("config.settings.prod")
assert mod.CSRF_COOKIE_SECURE is True
def test_prod_csrf_cookie_httponly(self):
mod = _load_settings("config.settings.prod")
assert mod.CSRF_COOKIE_HTTPONLY is True
# ---------------------------------------------------------------------------
# CSP settings
# ---------------------------------------------------------------------------
@pytest.mark.security
class TestCSPSettings:
def test_csp_default_src_self_only(self):
mod = _load_settings("config.settings.base")
assert "'self'" in mod.CSP_DEFAULT_SRC
def test_csp_frame_src_none(self):
mod = _load_settings("config.settings.base")
assert "'none'" in mod.CSP_FRAME_SRC
def test_csp_object_src_none(self):
mod = _load_settings("config.settings.base")
assert "'none'" in mod.CSP_OBJECT_SRC
def test_csp_base_uri_none(self):
mod = _load_settings("config.settings.base")
assert "'none'" in mod.CSP_BASE_URI
def test_csp_form_action_self(self):
mod = _load_settings("config.settings.base")
assert "'self'" in mod.CSP_FORM_ACTION
def test_csp_middleware_in_stack(self):
mod = _load_settings("config.settings.base")
assert "csp.middleware.CSPMiddleware" in mod.MIDDLEWARE
# ---------------------------------------------------------------------------
# Middleware ordering
# ---------------------------------------------------------------------------
@pytest.mark.security
class TestMiddlewareStack:
def test_security_audit_middleware_present(self):
mod = _load_settings("config.settings.base")
assert "api.middleware.SecurityAuditMiddleware" in mod.MIDDLEWARE
def test_security_middleware_first_position(self):
"""Django SecurityMiddleware should be the outermost middleware."""
mod = _load_settings("config.settings.base")
assert mod.MIDDLEWARE[0] == "django.middleware.security.SecurityMiddleware"
def test_csrf_middleware_present(self):
mod = _load_settings("config.settings.base")
assert "django.middleware.csrf.CsrfViewMiddleware" in mod.MIDDLEWARE
# ---------------------------------------------------------------------------
# DRF Throttling
# ---------------------------------------------------------------------------
@pytest.mark.security
class TestThrottleSettings:
def test_throttle_classes_configured(self):
mod = _load_settings("config.settings.base")
classes = mod.REST_FRAMEWORK.get("DEFAULT_THROTTLE_CLASSES", [])
assert len(classes) >= 2
def test_anon_throttle_rate_set(self):
mod = _load_settings("config.settings.base")
rates = mod.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {})
assert "anon" in rates
def test_user_throttle_rate_set(self):
mod = _load_settings("config.settings.base")
rates = mod.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {})
assert "user" in rates
def test_login_throttle_rate_set(self):
mod = _load_settings("config.settings.base")
rates = mod.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {})
assert "login" in rates
def test_upload_throttle_rate_set(self):
mod = _load_settings("config.settings.base")
rates = mod.REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {})
assert "upload" in rates
def test_throttle_classes_importable(self):
from api.throttles import LoginRateThrottle, UploadRateThrottle, AbuseRateThrottle
assert LoginRateThrottle.scope == "login"
assert UploadRateThrottle.scope == "upload"
assert AbuseRateThrottle.scope == "abuse"
# ---------------------------------------------------------------------------
# SecurityAuditMiddleware logic
# ---------------------------------------------------------------------------
@pytest.mark.security
class TestSecurityAuditMiddleware:
def _make_request(self, path="/api/v1/auth/login/", method="POST"):
request = MagicMock()
request.path = path
request.method = method
request.META = {"REMOTE_ADDR": "10.0.0.1"}
request.user = MagicMock(pk=None)
return request
def _make_response(self, status_code):
response = MagicMock()
response.status_code = status_code
return response
def test_401_is_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/courses/")
res = self._make_response(401)
assert SecurityAuditMiddleware._is_security_event(req, res) is True
def test_403_is_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/courses/")
res = self._make_response(403)
assert SecurityAuditMiddleware._is_security_event(req, res) is True
def test_429_is_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/courses/")
res = self._make_response(429)
assert SecurityAuditMiddleware._is_security_event(req, res) is True
def test_400_on_auth_path_is_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/auth/login/")
res = self._make_response(400)
assert SecurityAuditMiddleware._is_security_event(req, res) is True
def test_200_on_regular_path_is_not_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/courses/")
res = self._make_response(200)
assert SecurityAuditMiddleware._is_security_event(req, res) is False
def test_400_on_regular_path_is_not_security_event(self):
from api.middleware import SecurityAuditMiddleware
req = self._make_request(path="/api/v1/courses/")
res = self._make_response(400)
assert SecurityAuditMiddleware._is_security_event(req, res) is False
def test_client_ip_from_forwarded_header(self):
from api.middleware import SecurityAuditMiddleware
req = MagicMock()
req.META = {"HTTP_X_FORWARDED_FOR": "1.2.3.4, 10.0.0.1"}
assert SecurityAuditMiddleware._client_ip(req) == "1.2.3.4"
def test_client_ip_fallback_to_remote_addr(self):
from api.middleware import SecurityAuditMiddleware
req = MagicMock()
req.META = {"REMOTE_ADDR": "5.6.7.8"}
assert SecurityAuditMiddleware._client_ip(req) == "5.6.7.8"