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>
146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""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",
|
||
)
|