Security hardening from the assessment; testing fixes

Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:08:06 +02:00
co-authored by Claude Opus 5.5
parent 30b806e7da
commit addb2232a1
54 changed files with 1265 additions and 187 deletions
+6 -2
View File
@@ -37,10 +37,14 @@ AUTOCONFIG_XML = """<?xml version="1.0"?>
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Bypass the real DNS/public-IP check so respx can intercept the fetch."""
async def ok(_host: str) -> None:
return None
async def ok(_host: str) -> list[str]:
return ["93.184.216.34"]
async def unpinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
return url, {}, {} # respx matches on the host name, not the pinned IP
monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
monkeypatch.setattr(ssrf, "_pinned", unpinned)
# --- XML parsing -------------------------------------------------------------
+27 -11
View File
@@ -310,7 +310,8 @@ async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
await api.session.commit()
aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
a["id"]
for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name
)
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
@@ -404,8 +405,11 @@ async def test_folder_counts(api: ApiCtx) -> None:
async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
assert api.session is not None
# The kickoff thread's first message; give it a real delivery_id.
kickoff = next(t for t in (await api.client.get("/api/threads")).json()["threads"]
if t["message_count"] == 2)
kickoff = next(
t
for t in (await api.client.get("/api/threads")).json()["threads"]
if t["message_count"] == 2
)
detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
first_id = uuid.UUID(detail["messages"][0]["id"])
first = await api.session.get(Message, first_id)
@@ -414,20 +418,32 @@ async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
# A second account receives the SAME email (same delivery_id) into the same thread.
acct2 = Account(
user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
user_id=api.user_id,
email="me2@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
api.session.add(acct2)
await api.session.flush()
inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox")
api.session.add(inbox2)
await api.session.flush()
api.session.add(Message(
user_id=api.user_id, account_id=acct2.id, mailbox_id=inbox2.id, uid=1,
thread_id=first.thread_id, message_id_hdr="<synthetic@synthetic.local>",
delivery_id="<shared@x.com>", subject=first.subject,
internal_date=datetime.now(UTC), blob_sha256="z" * 64, # later -> first stays the rep
))
api.session.add(
Message(
user_id=api.user_id,
account_id=acct2.id,
mailbox_id=inbox2.id,
uid=1,
thread_id=first.thread_id,
message_id_hdr="<synthetic@synthetic.local>",
delivery_id="<shared@x.com>",
subject=first.subject,
internal_date=datetime.now(UTC),
blob_sha256="z" * 64, # later -> first stays the rep
)
)
await api.session.commit()
body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
+1 -3
View File
@@ -104,9 +104,7 @@ async def test_saved_search_crud_and_upsert(ctx: Ctx) -> None:
async def test_saved_search_requires_csrf(ctx: Ctx) -> None:
# No CSRF header -> rejected (state-changing route).
resp = await ctx.client.post(
"/api/saved-searches", json={"name": "x", "query": "y"}
)
resp = await ctx.client.post("/api/saved-searches", json={"name": "x", "query": "y"})
assert resp.status_code == 403
+2
View File
@@ -21,6 +21,8 @@ os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
# Hosted mailboxes off: tests that need the mail server turn them on themselves
# (tests/hosted/), against the dev stack's Stalwart container.
os.environ["HOSTED_MAILBOXES"] = "false"
# No calls to the breached-password service from tests (app/identity/password_policy.py).
os.environ["PASSWORD_BREACH_CHECK"] = "false"
for _key, _value in {
"STALWART_URL": "http://127.0.0.1:8180",
"STALWART_ADMIN_SECRET": "stalwart_admin_dev",
+33
View File
@@ -421,3 +421,36 @@ async def test_delete_keeps_rows_while_unreachable(ctx: Ctx, monkeypatch) -> Non
select(func.count()).select_from(Message).where(Message.thread_id == ctx.thread_id)
)
assert left == len(ctx.message_ids) # kept: the server still has them
async def test_restore_moves_only_what_was_removed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""A thread restored from Spam brings its spam back; the user's own reply in
Sent (never removed, so no recorded origin) stays in Sent."""
sent = Mailbox(account_id=None, user_id=ctx.user_id, path="Sent", special_use="sent")
first = await ctx.session.get(Message, ctx.message_ids[0])
reply = await ctx.session.get(Message, ctx.message_ids[1])
assert first is not None and reply is not None
sent.account_id = first.account_id
ctx.session.add(sent)
await ctx.session.flush()
first.mailbox_id = ctx.archive_id # removed, origin unknown (as when synced in)
reply.mailbox_id = sent.id
await ctx.session.commit()
moves: list[tuple[int, str, str]] = []
class Recorder:
async def move(self, uid: int, src: str, dst: str) -> int:
moves.append((uid, src, dst))
return 100 + uid
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: Recorder())
await sync_actions.restore_thread_job({"job_try": 1}, str(ctx.thread_id))
assert moves == [(first.uid, "Archive", "INBOX")]
ctx.session.expunge_all()
async with session_scope() as verify:
kept = await verify.get(Message, ctx.message_ids[1])
assert kept is not None and kept.mailbox_id == sent.id
@@ -104,7 +104,8 @@ async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-unty
assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder
assert sent[-1]["title"] == "Dentist"
assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4"
assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=") and sent[-1]["sticky"] is True
assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=")
assert sent[-1]["sticky"] is True
assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again
# The worker was down when the 10-minute reminder came due: sent late, once.
assert await run(MON_9 - timedelta(minutes=4)) == 1
+34 -17
View File
@@ -49,8 +49,12 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(user)
await s.flush()
account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
@@ -64,12 +68,17 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(thread)
await s.flush()
msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1,
user_id=user.id,
account_id=account.id,
mailbox_id=mbox.id,
uid=1,
thread_id=thread.id,
subject="Your Invoice 123",
from_addr=[{"name": "Billing", "email": "billing@acme.com"}],
to_addr=[{"name": "Me", "email": "me@example.com"}],
flags=[], internal_date=datetime.now(UTC), blob_sha256="",
flags=[],
internal_date=datetime.now(UTC),
blob_sha256="",
)
s.add(msg)
await s.commit()
@@ -99,24 +108,30 @@ async def _flags(ctx: _Ctx) -> list[str]:
async def test_hook_applies_flag_on_match(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "subject", "contains": "invoice", "action": "read"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "read"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx)
async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert await _flags(ctx) == []
async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Flagged" in await _flags(ctx) # "pin" == the \Flagged flag in this app
@@ -124,16 +139,16 @@ async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
async def test_hook_matches_on_to(ctx: _Ctx) -> None:
# Multi-account setups match on the recipient address.
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx)
async def test_hook_trash_moves_thread_to_trash(
ctx: _Ctx, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_hook_trash_moves_thread_to_trash(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[object, ...]] = []
async def fake_enqueue(name: str, *args: object, **kwargs: object) -> None:
@@ -141,8 +156,10 @@ async def test_hook_trash_moves_thread_to_trash(
# trash() enqueues the same job the UI Delete uses (imported from app.queue).
monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue)
await sdk("filters").kv(ctx.user_id).set(
"_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}]
await (
sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}])
)
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls
+1 -3
View File
@@ -68,9 +68,7 @@ async def ctx() -> AsyncIterator[_Ctx]:
await c.client_a.aclose()
await c.client_b.aclose()
async with sm() as s:
await s.execute(
delete(User).where(User.id.in_([c.user_a, c.user_b]))
)
await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
await s.execute(
text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"),
{"a": str(c.user_a), "b": str(c.user_b)},
+4 -3
View File
@@ -51,9 +51,10 @@ async def test_install_run_uninstall_dropdata_roundtrip() -> None:
# Table exists, RLS is enabled on it, and the per-plugin version table is present.
assert await _regclass("plg_demo_note") == "plg_demo_note"
assert await _scalar(
"SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'"
) is True
assert (
await _scalar("SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'")
is True
)
assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo"
# --drop-data removes the table AND the version table: zero residue.
+14 -4
View File
@@ -27,8 +27,12 @@ async def test_messages_header_reads_raw_header() -> None:
s.add(user)
await s.flush()
account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
secret_ct=b"", discovery_src="manual",
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
@@ -44,8 +48,14 @@ async def test_messages_header_reads_raw_header() -> None:
sha = blobstore.store(user.id, raw.as_bytes())
msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1,
subject="Weekly digest", flags=[], internal_date=datetime.now(UTC), blob_sha256=sha,
user_id=user.id,
account_id=account.id,
mailbox_id=mbox.id,
uid=1,
subject="Weekly digest",
flags=[],
internal_date=datetime.now(UTC),
blob_sha256=sha,
)
s.add(msg)
await s.commit()
@@ -44,9 +44,7 @@ async def test_ui_manifest_endpoint() -> None:
)
try:
# Unauthenticated request is rejected.
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as anon:
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon:
assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401
resp = await client.get("/api/plugins/ui-manifest")
+9 -1
View File
@@ -100,7 +100,15 @@ def test_parse_search_uids() -> None:
def test_parse_vanished() -> None:
# aioimaplib strips the leading "* " before buffering the line.
assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [300, 301, 302, 303, 304, 305, 405] # noqa: E501
assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [
300,
301,
302,
303,
304,
305,
405,
] # noqa: E501
# Live-expunge form (no EARLIER) and a descending range are both handled.
assert parse_vanished([b"VANISHED 41", b"VANISHED (EARLIER) 5:3"]) == [41, 3, 4, 5]
# Non-VANISHED lines are ignored; non-bytes entries are skipped.
+1 -3
View File
@@ -52,9 +52,7 @@ async def test_meili_typo_tolerance_and_filters() -> None:
await backend.index(_doc(user_id))
# Acceptance (§12 M2): a typo still finds the message.
hits, _c = await backend.query(
to_search_query("invocie", user_id), limit=10, cursor=None
)
hits, _c = await backend.query(to_search_query("invocie", user_id), limit=10, cursor=None)
if not hits: # wait for the async index task
assert await _poll(backend, "invocie", user_id) == 1
hits, _c = await backend.query(
+6 -2
View File
@@ -159,8 +159,12 @@ async def test_search_excludes_trash_by_default(ctx: Ctx) -> None:
ctx.session.add(trash)
await ctx.session.flush()
await _ingest(
ctx, frm="X <x@corp.com>", subject="zephyr in trash", body="bye",
mailbox_id=trash.id, special="trash",
ctx,
frm="X <x@corp.com>",
subject="zephyr in trash",
body="bye",
mailbox_id=trash.id,
special="trash",
)
assert await _search(ctx, "zephyr") == 1 # inbox only; the trashed one is hidden
assert await _search(ctx, "in:trash zephyr") == 1 # explicit -> searches Trash
+285
View File
@@ -0,0 +1,285 @@
"""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)
+65
View File
@@ -371,3 +371,68 @@ async def test_new_inbox_mail_while_live_asks_for_a_push(monkeypatch) -> None:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_live_loop_catches_up_before_it_idles(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Mail that reached INBOX while the loop was not running (a new account's
first minutes, a reconnect) is fetched at once, not after the next new
message or the 10-minute IDLE timeout."""
import asyncio
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"idl{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(inbox)
await session.commit()
calls: list[str] = []
stop = asyncio.Event()
class FakeProvider:
async def verify(self) -> None:
return None
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
calls.append("poll")
waiting = RemoteMessage(
uid=1,
raw=_raw("Arrived while away", "<away@x.com>"),
flags=frozenset(),
internal_date=datetime.now(UTC),
)
return MailboxDelta([waiting] if len(calls) == 1 else [], [], [], None, None)
async def idle(self, mailbox: str) -> None:
calls.append("idle")
stop.set()
async def close(self) -> None:
return None
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
try:
await asyncio.wait_for(sync_engine.live_loop(account.id, "INBOX", stop), 10)
assert calls == ["poll", "idle"]
n = await session.scalar(
select(func.count()).select_from(Message).where(Message.mailbox_id == inbox.id)
)
assert n == 1
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()