""" Cookie Security Tests Tests for the dynamic cookie security behavior. Security model: - Secure flag is added iff wsgi.url_scheme == "https". - ProxyFix (with x_proto=1) translates X-Forwarded-Proto into wsgi.url_scheme before SecureCookieMiddleware sees it, so requests served over HTTPS by a reverse proxy correctly receive the Secure flag. - HTTP requests never get Secure regardless of source IP. Setting Secure on an HTTP response causes the browser to drop the cookie entirely, so doing so based on IP heuristics broke legitimate Docker/LAN access without providing any cryptographic protection (issue #3849). - TESTING mode: Never get Secure flag (for CI/development). """ import pytest from tests.test_utils import add_src_to_path add_src_to_path() @pytest.fixture def app(): """Create test application with TESTING mode enabled (default for tests).""" from local_deep_research.web.app_factory import create_app app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app @pytest.fixture def app_production_mode(): """Create test application with production-like cookie security.""" import os from local_deep_research.web.app_factory import create_app old_testing = os.environ.pop("TESTING", None) old_ci = os.environ.pop("CI", None) old_pytest = os.environ.pop("PYTEST_CURRENT_TEST", None) try: app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False app.config["LDR_TESTING_MODE"] = False app.config["PREFERRED_URL_SCHEME"] = "http" return app finally: if old_testing is not None: os.environ["TESTING"] = old_testing if old_ci is not None: os.environ["CI"] = old_ci if old_pytest is not None: os.environ["PYTEST_CURRENT_TEST"] = old_pytest @pytest.fixture def client(app): """Create test client.""" return app.test_client() class TestLocalhostCookieSecurity: """Localhost HTTP works without Secure flag.""" def test_localhost_http_no_secure_flag(self, client): response = client.get("/auth/login") set_cookie = response.headers.get("Set-Cookie", "") assert response.status_code == 200 assert "session=" in set_cookie def test_localhost_session_cookie_works(self, client): response1 = client.get("/auth/login") assert response1.status_code == 200 response2 = client.get("/auth/login") assert response2.status_code == 200 class TestLocalhostProductionMode: """Localhost HTTP works in production mode (non-testing).""" def test_localhost_http_no_secure_flag_in_production( self, app_production_mode ): app = app_production_mode with app.test_client() as client: response = client.get("/auth/login") set_cookie = response.headers.get("Set-Cookie", "") assert "; Secure" not in set_cookie, ( f"Localhost HTTP should NOT have Secure flag. Got: {set_cookie}" ) assert "session=" in set_cookie class TestHttpCookieSecurity: """HTTP requests never get the Secure flag, regardless of source IP. This is the core fix for #3849: setting Secure on an HTTP response makes the browser drop the cookie, breaking sessions without adding any real security (the underlying transport is still plaintext). """ @pytest.mark.parametrize( "remote_addr", [ "127.0.0.1", "192.168.1.100", "10.0.0.50", "172.16.0.1", "172.17.0.2", # Default Docker bridge "172.67.130.145", # Docker Desktop NAT (the #3849 trigger) "8.8.8.8", "104.16.0.1", ], ) def test_http_no_secure_flag(self, app_production_mode, remote_addr): app = app_production_mode with app.test_client() as client: response = client.get( "/auth/login", environ_base={"REMOTE_ADDR": remote_addr}, ) set_cookie = response.headers.get("Set-Cookie", "") assert "; Secure" not in set_cookie, ( f"HTTP from {remote_addr} should NOT have Secure flag. " f"Got: {set_cookie}" ) class TestHttpsCookieSecurity: """HTTPS requests always get the Secure flag.""" def test_https_via_x_forwarded_proto_gets_secure(self, app_production_mode): """A reverse proxy terminating HTTPS sets X-Forwarded-Proto: https. ProxyFix translates this into wsgi.url_scheme, after which Secure is added. """ app = app_production_mode with app.test_client() as client: response = client.get( "/auth/login", headers={"X-Forwarded-Proto": "https"}, environ_base={"REMOTE_ADDR": "10.0.0.1"}, ) set_cookie = response.headers.get("Set-Cookie", "") assert "; Secure" in set_cookie, ( f"HTTPS via reverse proxy should add Secure. Got: {set_cookie}" ) class TestTestingModeBehavior: """TESTING mode disables Secure flag entirely.""" def test_testing_mode_no_secure_flag(self, app): app.config["LDR_TESTING_MODE"] = True with app.test_client() as client: response = client.get( "/auth/login", headers={"X-Forwarded-Proto": "https"}, ) set_cookie = response.headers.get("Set-Cookie", "") assert "; Secure" not in set_cookie @pytest.mark.skip(reason="documentation/placeholder test - not implemented") def test_cookie_security_summary(): """ Summary of cookie security behavior for CI validation. Expected behavior: | Scenario | wsgi.url_scheme | Secure Flag | |-------------------------------|-----------------|-------------| | HTTP from localhost | http | No | | HTTP from LAN client | http | No | | HTTP from Docker NAT gateway | http | No | | HTTP from public IP | http | No | | HTTPS via reverse proxy | https | Yes | | Direct HTTPS | https | Yes | | TESTING=1 mode | any | No | The decision is based purely on the protocol (post-ProxyFix), not the source IP. Setting Secure on HTTP responses doesn't add security and breaks the browser's ability to store the cookie. """ assert True