"""CSRF, origin and browser-binding checks of the consent and callback routes (SEC-17, SEC-19).""" from __future__ import annotations import pytest from pytest_httpx import HTTPXMock from starlette.testclient import TestClient from aegis_gitea_mcp.oauth.provider import PENDING_TTL from tests.test_oauth_helpers import ( BASE, CLIENT_STATE, CSRF_FIELD, GITEA_CODE, REDIRECT, FakeClock, browser, build_oauth_app, exchange_code, gitea_requests, mock_gitea_login, pkce, query_of, register, start_flow, ) @pytest.fixture(autouse=True) def _env(mock_env: None) -> None: """The audit logger and the rate limiter read the process-wide settings.""" def open_consent(client: TestClient, registration: dict[str, str]) -> tuple[dict[str, str], str]: """Register and call /authorize, open the consent page; return (form fields, consent URL).""" authorize = client.get( "/authorize", params={ "client_id": registration["client_id"], "redirect_uri": REDIRECT, "response_type": "code", "code_challenge": pkce()[1], "code_challenge_method": "S256", "state": CLIENT_STATE, }, ) consent_url = authorize.headers["location"] match = CSRF_FIELD.search(client.get(consent_url).text) assert match is not None form = {"request": query_of(consent_url)["request"], "csrf": match.group(1)} return {**form, "decision": "approve"}, consent_url def assert_refused(response: object) -> None: assert response.status_code == 400 # type: ignore[attr-defined] assert "location" not in response.headers # type: ignore[attr-defined] # Consent POST (SEC-17) @pytest.mark.parametrize( "headers", [ {}, {"Origin": "null"}, {"Origin": "https://evil.example"}, {"Origin": "http://mcp.example.com"}, {"Origin": "https://mcp.example.com.evil.example"}, {"Referer": "https://evil.example/page"}, {"Referer": "not a url"}, {"Origin": "https://evil.example", "Referer": f"{BASE}/oauth/consent"}, ], ids=lambda headers: ",".join(f"{k}={v}" for k, v in headers.items()) or "no-origin-no-referer", ) def test_consent_post_without_a_matching_origin_is_refused(headers: dict[str, str]) -> None: oauth = build_oauth_app() client = browser(oauth.app) form, _ = open_consent(client, register(client).json()) response = client.post("/oauth/consent", data=form, headers=headers) assert_refused(response) # The request was not consumed: the real form still works afterwards. assert client.post("/oauth/consent", data=form, headers={"Origin": BASE}).status_code == 303 def test_consent_post_falls_back_to_the_referer_origin() -> None: client = browser(build_oauth_app().app) form, consent_url = open_consent(client, register(client).json()) response = client.post("/oauth/consent", data=form, headers={"Referer": consent_url}) assert response.status_code == 303 def test_consent_post_without_the_csrf_cookie_is_refused() -> None: oauth = build_oauth_app() client = browser(oauth.app) form, _ = open_consent(client, register(client).json()) assert_refused(browser(oauth.app).post("/oauth/consent", data=form, headers={"Origin": BASE})) def test_consent_post_with_a_mismatched_csrf_token_is_refused() -> None: client = browser(build_oauth_app().app) form, _ = open_consent(client, register(client).json()) assert_refused( client.post("/oauth/consent", data={**form, "csrf": "x" * 43}, headers={"Origin": BASE}) ) assert_refused( client.post("/oauth/consent", data={**form, "csrf": ""}, headers={"Origin": BASE}) ) missing = {key: value for key, value in form.items() if key != "csrf"} assert_refused(client.post("/oauth/consent", data=missing, headers={"Origin": BASE})) def test_a_valid_consent_still_works_and_is_single_use() -> None: client = browser(build_oauth_app().app) form, _ = open_consent(client, register(client).json()) approved = client.post("/oauth/consent", data=form, headers={"Origin": BASE}) replay = client.post("/oauth/consent", data=form, headers={"Origin": BASE}) assert approved.status_code == 303 assert approved.headers["location"].startswith( "https://gitea.example.com/login/oauth/authorize" ) assert_refused(replay) # Callback def test_callback_without_the_binding_cookie_is_refused_and_audited( httpx_mock: HTTPXMock, ) -> None: oauth = build_oauth_app() client = browser(oauth.app) gitea_url = start_flow(client, register(client).json(), pkce()[1]) response = browser(oauth.app).get( "/oauth/callback", params={"code": GITEA_CODE, "state": query_of(gitea_url)["state"]} ) assert_refused(response) assert gitea_requests(httpx_mock, "https://gitea.example.com") == [] audit = oauth.settings.resolved_audit_log_path.read_text("utf-8") assert "oauth_callback_binding_mismatch" in audit def test_callback_with_another_flows_cookie_is_refused(httpx_mock: HTTPXMock) -> None: oauth = build_oauth_app() client = browser(oauth.app) registration = register(client).json() first = start_flow(client, registration, pkce()[1]) cookies = dict(client.cookies) second_client = browser(oauth.app) second = start_flow(second_client, register(second_client).json(), pkce()[1]) # The cookie of flow one is presented for flow two's state. attacker = browser(oauth.app) for name, value in cookies.items(): attacker.cookies.set(name, value) response = attacker.get( "/oauth/callback", params={"code": GITEA_CODE, "state": query_of(second)["state"]} ) assert_refused(response) assert query_of(first)["state"] != query_of(second)["state"] assert gitea_requests(httpx_mock, "https://gitea.example.com") == [] def test_callback_with_a_replayed_state_is_refused(httpx_mock: HTTPXMock) -> None: oauth = build_oauth_app() client = browser(oauth.app) state = query_of(start_flow(client, register(client).json(), pkce()[1]))["state"] mock_gitea_login(httpx_mock) first = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state}) replay = client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state}) assert first.status_code == 302 assert_refused(replay) def test_callback_with_an_expired_or_unknown_state_is_refused() -> None: clock = FakeClock() oauth = build_oauth_app(clock=clock) client = browser(oauth.app) state = query_of(start_flow(client, register(client).json(), pkce()[1]))["state"] clock.advance(PENDING_TTL + 1) assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE, "state": state})) assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE, "state": "unknown"})) assert_refused(client.get("/oauth/callback", params={"code": GITEA_CODE})) assert_refused(client.get("/oauth/callback")) def test_callback_never_echoes_its_parameters() -> None: client = browser(build_oauth_app().app) response = client.get( "/oauth/callback", params={"code": "code-value-123", "state": "state-value-123"} ) assert "code-value-123" not in response.text and "state-value-123" not in response.text # Concurrent sign-ins in one browser (SEC-19) def test_two_interleaved_flows_in_one_browser_both_complete(httpx_mock: HTTPXMock) -> None: oauth = build_oauth_app() client = browser(oauth.app) registration = register(client).json() verifier_one, challenge_one = pkce() verifier_two, challenge_two = pkce() first = start_flow(client, registration, challenge_one) second = start_flow(client, registration, challenge_two) assert len({name for name in client.cookies if "oauth-flow" in name}) == 2 mock_gitea_login(httpx_mock, login="alice") mock_gitea_login(httpx_mock, login="alice") done_first = client.get( "/oauth/callback", params={"code": GITEA_CODE, "state": query_of(first)["state"]} ) done_second = client.get( "/oauth/callback", params={"code": GITEA_CODE, "state": query_of(second)["state"]} ) assert done_first.status_code == 302 and done_second.status_code == 302 for done, verifier in ((done_first, verifier_one), (done_second, verifier_two)): code = query_of(done.headers["location"])["code"] assert exchange_code(client, registration, code, verifier).status_code == 200 audit = oauth.settings.resolved_audit_log_path.read_text("utf-8") assert "oauth_callback_binding_mismatch" not in audit