- 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>
243 lines
9.3 KiB
Python
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"
|