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
+145
View File
@@ -0,0 +1,145 @@
"""What a new account password must not be (sign-up and reset).
Length is bounded by the request schemas (8–128). On top of that a password is
refused when it is one of the passwords every guessing attack tries first, is
made of the user's own address, is one character repeated, or has turned up in
a known data breach. The breach check asks Have I Been Pwned's range API with
the first five hex digits of the password's SHA-1 only (k-anonymity: neither the
password nor its hash leaves the server) and is skipped, not failed, when that
service cannot be reached — a sign-up never hangs on someone else's outage.
Set ``PASSWORD_BREACH_CHECK=false`` to never ask it.
"""
from __future__ import annotations
import hashlib
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
MIN_LENGTH = 8
MAX_LENGTH = 128
_RANGE_API = "https://api.pwnedpasswords.com/range/"
_TIMEOUT = 3.0
# The front of every guessing list (and of every breach), lower-cased.
_COMMON = frozenset(
{
"password",
"password1",
"password12",
"password123",
"password1234",
"passw0rd",
"p@ssw0rd",
"p@ssword",
"12345678",
"123456789",
"1234567890",
"0123456789",
"87654321",
"11111111",
"00000000",
"12341234",
"qwertyui",
"qwerty123",
"qwerty1234",
"qwertyuiop",
"1q2w3e4r",
"1q2w3e4r5t",
"1qaz2wsx",
"zaq12wsx",
"asdfghjk",
"asdfasdf",
"iloveyou",
"iloveyou1",
"sunshine",
"princess",
"football",
"baseball",
"welcome1",
"welcome123",
"letmein1",
"trustno1",
"superman",
"batman123",
"dragon123",
"monkey123",
"abc12345",
"abcd1234",
"aa123456",
"qazwsxedc",
"changeme",
"changeme1",
"administrator",
"admin123",
"adminadmin",
"computer",
"internet",
"whatever",
"starwars",
"michelle",
"jennifer",
"charlie1",
"zaq1zaq1",
"haslo123",
"polska123",
"kochamcie",
"zaq1@wsx",
"qwerty12",
"1234qwer",
"q1w2e3r4",
}
)
class PasswordRejected(Exception):
"""The password may not be used; ``code`` and ``message`` say why."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
async def _breached(password: str) -> bool:
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() # noqa: S324 - HIBP's format
prefix, suffix = digest[:5], digest[5:]
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(_RANGE_API + prefix, headers={"Add-Padding": "true"})
resp.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("breached-password check skipped: %s", exc)
return False
for line in resp.text.splitlines():
candidate, _, count = line.partition(":")
if candidate.strip() == suffix and count.strip() not in ("", "0"): # padding rows say 0
return True
return False
async def check_new_password(password: str, handle: str) -> None:
"""Raise :class:`PasswordRejected` if ``password`` may not be used by ``handle``."""
lowered = password.lower()
if len(set(password)) <= 2:
raise PasswordRejected("weak_password", "this password is too easy to guess")
if lowered in _COMMON:
raise PasswordRejected("weak_password", "this is one of the most common passwords")
localpart = handle.split("@", 1)[0].lower()
if len(localpart) >= 3 and localpart in lowered:
# The name with a year or a few characters added ("jsmith2026") is the
# first thing anyone who knows the address will try.
rest = lowered.replace(localpart, "", 1)
if len(rest) < 4 or all(not c.isalpha() for c in rest):
raise PasswordRejected("weak_password", "the password must not be your address")
if get_settings().password_breach_check and await _breached(password):
raise PasswordRejected(
"breached_password",
"this password has appeared in a data breach — please choose another",
)
+10
View File
@@ -27,6 +27,7 @@ from app.identity.cookies import (
)
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
from app.identity.password_policy import PasswordRejected, check_new_password
from app.identity.passwords import hash_password, verify_password
from app.identity.reset import (
consume_token,
@@ -125,6 +126,10 @@ _REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession
) -> UserOut:
try:
await check_new_password(body.password, resolve_handle(body.localpart))
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
try:
user = await register_user(
db,
@@ -312,6 +317,11 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user = await db.get(User, user_id) if user_id else None
if user is None:
raise_api_error(400, "invalid_token", "this link is invalid or has expired")
# Before the 2FA code and the link are spent: a refused password costs neither.
try:
await check_new_password(body.password, user.handle)
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
if user.totp_secret is not None:
if not body.totp:
raise_api_error(401, "totp_required", "a 2FA code is required")
+2 -2
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=1024)
password: str = Field(min_length=8, max_length=128)
recovery_email: str | None = None
@@ -49,7 +49,7 @@ class PasswordResetRequestIn(BaseModel):
class PasswordResetIn(BaseModel):
token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024)
password: str = Field(min_length=8, max_length=128)
totp: str | None = None # required when the account has 2FA enabled