Files
webmail-client/backend/tests/security/test_hardening.py
T

286 lines
10 KiB
Python
Raw Normal View History

"""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",
[
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
'<a href="javascript:alert(1)">x</a>',
'<a href="jav&#x09;ascript:alert(1)">x</a>',
'<a href="cid:part1">x</a>',
"<img src=x onerror=alert(1)>",
"<svg onload=alert(1)><circle/></svg>",
"<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>",
'<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
'<iframe srcdoc="<script>alert(1)</script>"></iframe>',
'<div style="width:expression(alert(1))">x</div>',
'<meta http-equiv="refresh" content="0;url=https://evil.example">',
'<base href="https://evil.example/">',
'<form action="https://evil.example"><input type=password></form>',
],
)
def test_no_active_content_survives(html: str) -> None:
out = sanitize_html(html, uuid.uuid4()).html.lower()
for marker in (
"<script", "onerror", "onload", "javascript:", "data:text", "cid:", "expression(",
"<iframe", "<meta", "<base", "<form", "<svg", "<math",
): # fmt: skip
assert marker not in out, (html, out)
def test_ordinary_links_stay() -> None:
out = sanitize_html(
'<a href="https://example.com/x">w</a><a href="mailto:a@b.c">m</a>', uuid.uuid4()
).html
assert 'href="https://example.com/x"' in out and 'href="mailto:a@b.c"' in out
async def test_the_message_document_carries_its_own_strict_policy() -> None:
from app.api import messages
resp = messages._html("<p>x</p>")
csp = resp.headers["content-security-policy"]
assert "script-src 'none'" in csp and "sandbox" in csp
# --- outbound fetches (V3) ------------------------------------------------------------
@pytest.mark.parametrize(
"ip", ["100.64.0.1", "::ffff:127.0.0.1", "fd00::1", "169.254.169.254", "fe80::1%eth0"]
)
def test_more_non_public_addresses_are_recognised(ip: str) -> None:
assert ssrf._is_public_ip(ip) is False
async def test_requests_go_to_the_address_that_was_checked(
monkeypatch: pytest.MonkeyPatch,
) -> None:
answers = iter([["93.184.216.34"], ["127.0.0.1"]]) # a rebinding DNS server
async def resolve(_host: str) -> list[str]:
return next(answers)
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
url, headers, extensions = await ssrf._pinned("https://cal.example.com:8443/dav/?a=1")
assert url == "https://93.184.216.34:8443/dav/?a=1"
assert headers == {"Host": "cal.example.com:8443"}
assert extensions == {"sni_hostname": "cal.example.com"}
async def test_pinning_prefers_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
async def resolve(_host: str) -> list[str]:
return ["2606:4700::6810:84e5", "104.16.132.229"]
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
url, _, _ = await ssrf._pinned("https://example.com/")
assert url == "https://104.16.132.229/"
async def test_mail_servers_are_checked_again_at_every_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.providers.base import ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
async def resolve(_host: str) -> list[str]:
return ["10.0.0.5"] # the name now points inside the network
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
provider = ImapSmtpProvider(
ProviderConfig(
imap_host="imap.example.com",
imap_port=993,
imap_tls="ssl",
smtp_host="smtp.example.com",
smtp_port=465,
smtp_tls="ssl",
username="u",
password="p",
public_only=True,
)
)
with pytest.raises(ProviderConnectError):
await asyncio.wait_for(provider.verify(), 5)
with pytest.raises(ProviderConnectError):
await asyncio.wait_for(provider.send(b"x", "u@example.com", ["v@example.com"]), 5)