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