feat(security): merge Phase 4b security hardening

Complete security hardening implementation including HTTPS enforcement,
security headers, rate limiting, and comprehensive security test suite.

Key features:
- HTTPS enforcement with HSTS support
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options)
- Rate limiting for all critical endpoints
- Enhanced email template security
- 87% test coverage with security-specific tests

Architect approval: 9.5/10

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 18:28:50 -07:00
parent 115e733604
commit d3c3e8dc6b
23 changed files with 3762 additions and 7 deletions

View File

@@ -0,0 +1,130 @@
"""Integration tests for security headers middleware."""
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def test_app(monkeypatch):
"""Create test FastAPI app with test configuration."""
# Set up test environment
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
# Set required environment variables
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
monkeypatch.setenv("GONDULF_BASE_URL", "https://auth.example.com")
monkeypatch.setenv("GONDULF_DATABASE_URL", f"sqlite:///{db_path}")
monkeypatch.setenv("GONDULF_DEBUG", "true")
# Import app AFTER setting env vars
from gondulf.main import app
yield app
@pytest.fixture
def client(test_app):
"""FastAPI test client."""
return TestClient(test_app)
class TestSecurityHeaders:
"""Test security headers middleware."""
def test_x_frame_options_header(self, client):
"""Test X-Frame-Options header is present."""
response = client.get("/health")
assert "X-Frame-Options" in response.headers
assert response.headers["X-Frame-Options"] == "DENY"
def test_x_content_type_options_header(self, client):
"""Test X-Content-Type-Options header is present."""
response = client.get("/health")
assert "X-Content-Type-Options" in response.headers
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_x_xss_protection_header(self, client):
"""Test X-XSS-Protection header is present."""
response = client.get("/health")
assert "X-XSS-Protection" in response.headers
assert response.headers["X-XSS-Protection"] == "1; mode=block"
def test_csp_header(self, client):
"""Test Content-Security-Policy header is present and configured correctly."""
response = client.get("/health")
assert "Content-Security-Policy" in response.headers
csp = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp
assert "img-src 'self' https:" in csp
assert "frame-ancestors 'none'" in csp
def test_referrer_policy_header(self, client):
"""Test Referrer-Policy header is present."""
response = client.get("/health")
assert "Referrer-Policy" in response.headers
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
def test_permissions_policy_header(self, client):
"""Test Permissions-Policy header is present."""
response = client.get("/health")
assert "Permissions-Policy" in response.headers
policy = response.headers["Permissions-Policy"]
assert "geolocation=()" in policy
assert "microphone=()" in policy
assert "camera=()" in policy
def test_hsts_header_not_in_debug_mode(self, client):
"""Test HSTS header is NOT present in debug mode."""
# This test assumes DEBUG=True in test environment
# In production, DEBUG=False and HSTS should be present
response = client.get("/health")
# Check current mode from Config
from gondulf.config import Config
if Config.DEBUG:
# HSTS should NOT be present in debug mode
assert "Strict-Transport-Security" not in response.headers
else:
# HSTS should be present in production mode
assert "Strict-Transport-Security" in response.headers
assert (
"max-age=31536000"
in response.headers["Strict-Transport-Security"]
)
assert (
"includeSubDomains"
in response.headers["Strict-Transport-Security"]
)
def test_headers_on_all_endpoints(self, client):
"""Test security headers are present on all endpoints."""
endpoints = [
"/",
"/health",
"/.well-known/oauth-authorization-server",
]
for endpoint in endpoints:
response = client.get(endpoint)
# All endpoints should have security headers
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
assert "Content-Security-Policy" in response.headers
def test_headers_on_error_responses(self, client):
"""Test security headers are present even on error responses."""
# Request non-existent endpoint (404)
response = client.get("/nonexistent")
assert response.status_code == 404
# Security headers should still be present
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers