"""Test HTTP security headers.""" import pytest class TestSecurityHeaders: """Test HTTP security headers are properly set.""" @pytest.fixture def test_endpoint(self): """Return a test endpoint to check headers.""" return "/" def test_x_frame_options_header(self, client, test_endpoint): """Test X-Frame-Options header is set correctly.""" response = client.get(test_endpoint) assert "X-Frame-Options" in response.headers assert response.headers["X-Frame-Options"] == "SAMEORIGIN" def test_x_content_type_options_header(self, client, test_endpoint): """Test X-Content-Type-Options header is set correctly.""" response = client.get(test_endpoint) assert "X-Content-Type-Options" in response.headers assert response.headers["X-Content-Type-Options"] == "nosniff" def test_referrer_policy_header(self, client, test_endpoint): """Test Referrer-Policy header is set correctly.""" response = client.get(test_endpoint) assert "Referrer-Policy" in response.headers assert ( response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" ) def test_permissions_policy_header(self, client, test_endpoint): """Test Permissions-Policy header is set correctly.""" response = client.get(test_endpoint) assert "Permissions-Policy" in response.headers permissions = response.headers["Permissions-Policy"] assert "geolocation=()" in permissions assert "microphone=()" in permissions assert "camera=()" in permissions def test_content_security_policy_header(self, client, test_endpoint): """Test Content-Security-Policy header is set correctly.""" response = client.get(test_endpoint) assert "Content-Security-Policy" in response.headers csp = response.headers["Content-Security-Policy"] # Verify key CSP directives are present assert "default-src 'self'" in csp assert "script-src" in csp assert "style-src" in csp def test_hsts_header_not_set_for_http(self, client, test_endpoint): """Test HSTS header is not set for HTTP requests.""" # In test environment, requests are HTTP by default response = client.get(test_endpoint) # HSTS should NOT be set for non-HTTPS requests assert "Strict-Transport-Security" not in response.headers def test_hsts_header_set_for_https(self, app, test_endpoint): """Test HSTS header is set correctly for HTTPS requests.""" # Configure app to think we're using HTTPS app.config["PREFERRED_URL_SCHEME"] = "https" with app.test_client() as https_client: response = https_client.get( test_endpoint, environ_base={"wsgi.url_scheme": "https"} ) assert "Strict-Transport-Security" in response.headers hsts = response.headers["Strict-Transport-Security"] assert "max-age=" in hsts assert "includeSubDomains" in hsts class TestSecurityHeadersOnAPIEndpoints: """Test security headers are set on API endpoints.""" def test_security_headers_on_api_endpoint(self, client): """Test security headers are present on API endpoints.""" # Test a health check endpoint that should exist response = client.get("/api/health") # Security headers should be present even on API endpoints assert "X-Frame-Options" in response.headers assert "X-Content-Type-Options" in response.headers assert "Referrer-Policy" in response.headers def test_cors_and_security_headers_coexist(self, client): """Test CORS and security headers can coexist on API endpoints.""" # API endpoints should have both CORS and security headers response = client.get("/api/health") # CORS headers (if applicable to this endpoint) # Note: CORS headers might only be set for certain API routes # Security headers should always be present assert "X-Frame-Options" in response.headers assert "X-Content-Type-Options" in response.headers class TestSecurityHeadersComprehensive: """Comprehensive security header validation.""" def test_all_critical_security_headers_present(self, client): """Test all critical security headers are present.""" response = client.get("/") critical_headers = [ "Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy", "Permissions-Policy", ] for header in critical_headers: assert header in response.headers, ( f"Missing critical header: {header}" ) def test_security_headers_on_authenticated_routes(self, client): """Test security headers are present on authenticated routes.""" # Test auth-related endpoints response = client.get("/auth/login") assert response.status_code == 200 assert "X-Frame-Options" in response.headers assert "X-Content-Type-Options" in response.headers assert "Content-Security-Policy" in response.headers def test_security_headers_values_are_secure(self, client): """Test security header values follow best practices.""" response = client.get("/") # X-Frame-Options should be DENY or SAMEORIGIN (not ALLOW-FROM) xfo = response.headers.get("X-Frame-Options", "") assert xfo in ["DENY", "SAMEORIGIN"] # X-Content-Type-Options should be nosniff assert response.headers.get("X-Content-Type-Options") == "nosniff" # Referrer-Policy should not be "unsafe-url" or "no-referrer-when-downgrade" rp = response.headers.get("Referrer-Policy", "") assert rp not in ["unsafe-url", "no-referrer-when-downgrade"] class TestSecurityHeadersClass: """Tests for SecurityHeaders class directly.""" def test_init_with_flask_app(self): """SecurityHeaders can be initialized with Flask app.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) assert sh.app == app def test_init_without_app(self): """SecurityHeaders can be initialized without app.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) sh = SecurityHeaders() # Should not have app attribute set assert not hasattr(sh, "app") or sh.app is None def test_init_app_later(self): """SecurityHeaders can use init_app for delayed initialization.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders() sh.init_app(app) assert sh.app == app class TestValidateCorsConfig: """Tests for _validate_cors_config method.""" def test_cors_disabled_passes_validation(self): """Should pass validation when CORS is disabled.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ENABLED"] = False # Should not raise sh = SecurityHeaders(app) assert sh is not None def test_credentials_with_wildcard_raises_error(self): """Should raise ValueError for credentials with wildcard origin.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ENABLED"] = True app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "*" app.config["SECURITY_CORS_ALLOW_CREDENTIALS"] = True with pytest.raises(ValueError) as exc_info: SecurityHeaders(app) assert "Cannot use credentials with wildcard" in str(exc_info.value) def test_single_origin_with_credentials_passes(self): """Should pass for single origin with credentials.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ENABLED"] = True app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "https://example.com" app.config["SECURITY_CORS_ALLOW_CREDENTIALS"] = True # Should not raise sh = SecurityHeaders(app) assert sh is not None class TestCorsHeaders: """Tests for _add_cors_headers method.""" def test_wildcard_origin_sets_star(self): """Should set Access-Control-Allow-Origin to * for wildcard.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "*" app.config["SECURITY_CORS_ALLOW_CREDENTIALS"] = False sh = SecurityHeaders(app) with app.test_request_context("/api/test"): from flask import make_response response = make_response("test") response = sh._add_cors_headers(response) assert response.headers.get("Access-Control-Allow-Origin") == "*" def test_single_origin_sets_that_origin(self): """Should set configured single origin.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "https://myapp.com" app.config["SECURITY_CORS_ALLOW_CREDENTIALS"] = False sh = SecurityHeaders(app) with app.test_request_context("/api/test"): from flask import make_response response = make_response("test") response = sh._add_cors_headers(response) assert ( response.headers.get("Access-Control-Allow-Origin") == "https://myapp.com" ) def test_multi_origin_reflects_request_origin(self): """Should reflect request origin if in whitelist.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = ( "https://a.com,https://b.com" ) app.config["SECURITY_CORS_ALLOW_CREDENTIALS"] = False sh = SecurityHeaders(app) with app.test_request_context( "/api/test", headers={"Origin": "https://b.com"} ): from flask import make_response response = make_response("test") response = sh._add_cors_headers(response) assert ( response.headers.get("Access-Control-Allow-Origin") == "https://b.com" ) class TestCspPolicy: """Tests for get_csp_policy method.""" def test_default_csp_includes_self(self): """Default CSP should include 'self' directive.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "default-src 'self'" in csp def test_csp_includes_connect_src(self): """CSP should include connect-src directive.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "connect-src" in csp def test_custom_connect_src_used(self): """Custom connect-src should be used when configured.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" app.config["SECURITY_CSP_CONNECT_SRC"] = "'self' https://api.mysite.com" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "https://api.mysite.com" in csp def test_csp_includes_media_src(self): """CSP should include media-src directive.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "media-src 'self'" in csp def test_csp_includes_child_src(self): """CSP should include child-src directive.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "child-src 'self' blob:" in csp def test_csp_includes_manifest_src(self): """CSP should include manifest-src directive.""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() assert "manifest-src 'self'" in csp def test_csp_includes_frame_ancestors(self): """CSP should include frame-ancestors directive (no fallback to default-src).""" from flask import Flask from local_deep_research.security.security_headers import ( SecurityHeaders, ) app = Flask(__name__) app.config["SECRET_KEY"] = "test-key" sh = SecurityHeaders(app) csp = sh.get_csp_policy() # frame-ancestors is required because it doesn't fall back to default-src assert "frame-ancestors 'self'" in csp class TestPermissionsPolicy: """Tests for get_permissions_policy method.""" def test_disables_geolocation(self): """Should disable geolocation.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) policy = SecurityHeaders.get_permissions_policy() assert "geolocation=()" in policy def test_disables_camera(self): """Should disable camera.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) policy = SecurityHeaders.get_permissions_policy() assert "camera=()" in policy def test_disables_microphone(self): """Should disable microphone.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) policy = SecurityHeaders.get_permissions_policy() assert "microphone=()" in policy class TestIsApiRoute: """Tests for _is_api_route static method.""" def test_api_prefix_detected(self): """Should detect /api/ paths as API routes.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) assert SecurityHeaders._is_api_route("/api/v1/data") is True assert SecurityHeaders._is_api_route("/api/health") is True def test_research_api_detected(self): """Should detect /research/api/ paths as API routes.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) assert SecurityHeaders._is_api_route("/research/api/status") is True def test_non_api_not_detected(self): """Should not detect regular paths as API routes.""" from local_deep_research.security.security_headers import ( SecurityHeaders, ) assert SecurityHeaders._is_api_route("/") is False assert SecurityHeaders._is_api_route("/dashboard") is False assert SecurityHeaders._is_api_route("/static/js/app.js") is False class TestServerHeaderRemoval: """Test that Server header is removed from responses.""" def test_server_header_not_present(self, client): """Server header should not leak version information.""" response = client.get("/") assert "Server" not in response.headers def test_server_header_not_present_on_api(self, client): """Server header should not be present on API responses.""" response = client.get("/api/health") assert "Server" not in response.headers def test_server_header_not_present_on_login(self, client): """Server header should not be present on login page.""" response = client.get("/auth/login") assert "Server" not in response.headers class TestCacheControlHeaders: """Test Cache-Control headers are properly set.""" def test_cache_control_on_root(self, client): """Root page should have Cache-Control headers.""" response = client.get("/") assert "Cache-Control" in response.headers cache_control = response.headers["Cache-Control"] assert "no-store" in cache_control assert "no-cache" in cache_control def test_cache_control_on_login(self, client): """Login page should have Cache-Control headers.""" response = client.get("/auth/login") assert "Cache-Control" in response.headers cache_control = response.headers["Cache-Control"] assert "no-store" in cache_control assert "no-cache" in cache_control def test_cache_control_on_api(self, client): """API endpoints should have Cache-Control headers.""" response = client.get("/api/health") assert "Cache-Control" in response.headers cache_control = response.headers["Cache-Control"] assert "no-store" in cache_control def test_pragma_header_present(self, client): """Pragma header should be present for HTTP/1.0 compatibility.""" response = client.get("/") assert "Pragma" in response.headers assert response.headers["Pragma"] == "no-cache" def test_expires_header_present(self, client): """Expires header should be present.""" response = client.get("/") assert "Expires" in response.headers assert response.headers["Expires"] == "0" class TestContentTypeHeaders: """Test Content-Type headers are properly set for HTML routes.""" def test_login_page_has_html_content_type(self, client): """Login page should have proper Content-Type.""" response = client.get("/auth/login") content_type = response.headers.get("Content-Type", "") assert "text/html" in content_type def test_root_page_has_html_content_type(self, client): """Root page should have proper Content-Type when redirecting.""" response = client.get("/") # Either redirect (302) or HTML response (200) if response.status_code == 200: content_type = response.headers.get("Content-Type", "") assert "text/html" in content_type def test_api_endpoint_has_json_content_type(self, client): """API endpoints should have proper Content-Type.""" response = client.get("/api/health") content_type = response.headers.get("Content-Type", "") assert "application/json" in content_type class TestSecurityHeadersResponseCoverage: """Pin that security headers fire across every response path Flask produces, not just successful HTML routes. SecurityHeaders registers app.after_request, which Flask invokes on every Response — including error handlers, streaming responses, and static file routes. These tests catch silent regressions if anyone refactors error handling, adds WSGI middleware that strips headers, or returns a raw response that somehow bypasses the after_request chain. """ REQUIRED_HEADERS = ( "Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy", "Permissions-Policy", ) def _assert_required_headers(self, response): missing = [ h for h in self.REQUIRED_HEADERS if h not in response.headers ] assert not missing, ( f"Response (status={response.status_code}) missing security " f"headers: {missing}. Present headers: {list(response.headers.keys())}" ) def test_headers_on_unknown_path_404(self, client): """404 from the global errorhandler must still carry headers.""" response = client.get("/this-path-does-not-exist-zzz-12345") assert response.status_code == 404 self._assert_required_headers(response) def test_headers_on_unknown_api_path_404(self, client): """404 on an /api/ path (JSON branch of the errorhandler).""" response = client.get("/api/does-not-exist-zzz-12345") assert response.status_code == 404 self._assert_required_headers(response) def test_headers_on_favicon(self, client): """The custom /favicon.ico route uses send_from_directory.""" response = client.get("/favicon.ico") # Status may be 200 (file exists) or 404 (file missing in test # fixture). Either path must have headers applied. self._assert_required_headers(response) def test_headers_on_unhandled_exception_500(self, app, client): """The 500 errorhandler returns make_response — headers must apply.""" # TESTING=True normally propagates exceptions; turn that off so # the errorhandler runs. app.config["PROPAGATE_EXCEPTIONS"] = False @app.route("/_test_force_500") def _force_500(): raise RuntimeError("forced exception for header coverage test") try: response = client.get("/_test_force_500") assert response.status_code == 500 self._assert_required_headers(response) finally: app.config["PROPAGATE_EXCEPTIONS"] = True def test_headers_on_streaming_sse_response(self, app, client): """Streaming Response(stream_with_context(...)) — used by SSE endpoints in library_routes.py and rag_routes.py.""" from flask import Response, stream_with_context @app.route("/_test_stream_sse") def _stream(): def generate(): yield "data: one\n\n" yield "data: two\n\n" return Response( stream_with_context(generate()), mimetype="text/event-stream" ) response = client.get("/_test_stream_sse") assert response.status_code == 200 self._assert_required_headers(response) def test_headers_on_abort_413_request_too_large(self, app, client): """Custom 413 errorhandler returns make_response.""" from flask import abort @app.route("/_test_force_413") def _force_413(): abort(413) response = client.get("/_test_force_413") assert response.status_code == 413 self._assert_required_headers(response)