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