"""Fixes from the 2026-10-14 security assessment (ultimo-security-report.md): cross-site requests refused (login CSRF), terse validation errors, the new-password policy, link schemes in message HTML, and DNS-rebinding-proof outbound fetches.""" from __future__ import annotations import asyncio import uuid from collections.abc import AsyncIterator import httpx import pytest import pytest_asyncio from httpx import ASGITransport from starlette.datastructures import Headers from app.accounts import ssrf from app.config import get_settings from app.identity import password_policy from app.identity.password_policy import PasswordRejected, check_new_password from app.indexer.sanitize import sanitize_html from app.main import create_app from app.security.cross_site import is_cross_site @pytest_asyncio.fixture async def client() -> AsyncIterator[httpx.AsyncClient]: async with httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://mail.example.org" ) as c: yield c def _login(client: httpx.AsyncClient, headers: dict[str, str]): # type: ignore[no-untyped-def] return client.post( "/api/auth/login", json={"handle": "nobody", "password": "x"}, headers=headers ) # --- cross-site requests (finding 3) -------------------------------------------------- @pytest.mark.parametrize( "headers", [ {"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}, {"Origin": "https://evil.example"}, # an older browser: Origin only {"Origin": "null"}, # a sandboxed frame ], ) async def test_sign_in_from_another_site_is_refused( client: httpx.AsyncClient, headers: dict[str, str] ) -> None: resp = await _login(client, headers) assert resp.status_code == 403 assert resp.json()["error"]["code"] == "cross_site" @pytest.mark.parametrize( "headers", [ {"Sec-Fetch-Site": "same-origin", "Origin": "http://mail.example.org"}, {"Origin": "http://mail.example.org"}, {"Origin": "https://mail.example.org:8443"}, # the port does not matter {"Sec-Fetch-Site": "same-site"}, # our own subdomain {"Sec-Fetch-Site": "none"}, # typed into the address bar {}, # not a browser (scripts, mail apps) ], ) async def test_sign_in_from_our_own_pages_goes_through( client: httpx.AsyncClient, headers: dict[str, str] ) -> None: resp = await _login(client, headers) assert resp.status_code == 401 # evaluated: wrong credentials, not refused assert resp.json()["error"]["code"] == "invalid_credentials" async def test_reads_from_another_site_are_not_refused(client: httpx.AsyncClient) -> None: resp = await client.get("/api/auth/registration", headers={"Sec-Fetch-Site": "cross-site"}) assert resp.status_code == 200 def test_origin_on_our_own_domain_passes_whatever_the_host_header() -> None: domain = get_settings().app_domain headers = Headers({"origin": f"https://{domain}", "host": "localhost:8088"}) assert is_cross_site(headers) is False sub = Headers({"origin": f"https://mail.{domain}", "host": "localhost:8088"}) assert is_cross_site(sub) is False lookalike = Headers({"origin": f"https://evil{domain}", "host": "localhost:8088"}) assert is_cross_site(lookalike) is True def test_origin_matches_the_name_a_reverse_proxy_saw() -> None: headers = Headers( {"origin": "https://ultimo.email", "host": "web:80", "x-forwarded-host": "ultimo.email"} ) assert is_cross_site(headers) is False # --- validation errors (finding 5) ---------------------------------------------------- async def test_validation_errors_do_not_spell_out_the_request_model( client: httpx.AsyncClient, ) -> None: resp = await client.post("/api/auth/login", json={}) assert resp.status_code == 422 body = resp.json() assert "detail" not in body assert body["error"]["code"] == "validation_error" assert body["error"]["message"].startswith("handle:") async def test_a_too_short_password_says_so(client: httpx.AsyncClient) -> None: resp = await client.post("/api/auth/register", json={"localpart": "abcde", "password": "123"}) assert resp.status_code == 422 assert "password" in resp.json()["error"]["message"] async def test_new_passwords_are_capped_at_128(client: httpx.AsyncClient) -> None: resp = await client.post( "/api/auth/register", json={"localpart": "abcde", "password": "x" * 129} ) assert resp.status_code == 422 # --- new-password policy (finding 6) -------------------------------------------------- @pytest.mark.parametrize( "password", ["password123", "Password123", "12345678", "aaaaaaaaaa", "abababab", "jsmith2026"], ) async def test_guessable_passwords_are_refused(password: str) -> None: with pytest.raises(PasswordRejected): await check_new_password(password, "jsmith@example.org") async def test_a_decent_password_passes() -> None: await check_new_password("correct horse battery", "jsmith@example.org") async def test_breached_passwords_are_refused(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(get_settings(), "password_breach_check", True) sha = "A" * 40 async def breached(_password: str) -> bool: return True monkeypatch.setattr(password_policy, "_breached", breached) with pytest.raises(PasswordRejected) as err: await check_new_password("long unusual phrase " + sha, "jsmith@example.org") assert err.value.code == "breached_password" async def test_the_breach_service_being_down_does_not_block_sign_up( monkeypatch: pytest.MonkeyPatch, ) -> None: def unreachable(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("down", request=request) real_client = httpx.AsyncClient def client_factory(*args, **kwargs): # type: ignore[no-untyped-def] return real_client(transport=httpx.MockTransport(unreachable)) monkeypatch.setattr(password_policy.httpx, "AsyncClient", client_factory) assert await password_policy._breached("anything at all") is False async def test_sign_up_with_a_common_password_is_refused(client: httpx.AsyncClient) -> None: resp = await client.post( "/api/auth/register", json={"localpart": f"pw{uuid.uuid4().hex[:8]}", "password": "password123"}, ) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "weak_password" # --- message HTML (V2) ---------------------------------------------------------------- @pytest.mark.parametrize( "html", [ 'x', 'x', 'x', 'x', "", "", "