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