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:
@@ -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 -------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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	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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user