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:
@@ -125,9 +125,7 @@ def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
|
||||
smtp = _server("outgoingServer", "smtp")
|
||||
if imap is None or smtp is None:
|
||||
return None
|
||||
return CandidateConfig(
|
||||
imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth)
|
||||
)
|
||||
return CandidateConfig(imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth))
|
||||
|
||||
|
||||
# --- DNS SRV (RFC 6186) ------------------------------------------------------
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no
|
||||
off-domain redirects, and the target host must resolve exclusively to public IPs
|
||||
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap
|
||||
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1;
|
||||
a pinned-IP transport can close it later.
|
||||
(reject private/loopback/link-local/reserved ranges). The request then goes to
|
||||
the very address that was checked — the name is not looked up a second time —
|
||||
so a DNS answer that changes in between (DNS rebinding) cannot point it inward.
|
||||
TLS is still verified against the host name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,15 +27,12 @@ class SsrfError(Exception):
|
||||
|
||||
|
||||
def _is_public_ip(ip: str) -> bool:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return not (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_multicast
|
||||
or addr.is_reserved
|
||||
or addr.is_unspecified
|
||||
)
|
||||
addr = ipaddress.ip_address(ip.split("%", 1)[0]) # drop an IPv6 zone index
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped # ::ffff:127.0.0.1 is 127.0.0.1
|
||||
# is_global: not private, loopback, link-local, reserved, unspecified, nor
|
||||
# shared/carrier-grade NAT (100.64.0.0/10, which is_private misses).
|
||||
return addr.is_global and not addr.is_multicast
|
||||
|
||||
|
||||
async def _resolve_all(host: str) -> list[str]:
|
||||
@@ -43,8 +41,10 @@ async def _resolve_all(host: str) -> list[str]:
|
||||
return [info[4][0] for info in infos]
|
||||
|
||||
|
||||
async def assert_host_is_public(host: str) -> None:
|
||||
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6)."""
|
||||
async def assert_host_is_public(host: str) -> list[str]:
|
||||
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6).
|
||||
|
||||
Returns the addresses checked, for the caller to connect to."""
|
||||
try:
|
||||
addresses = await _resolve_all(host)
|
||||
except socket.gaierror as exc:
|
||||
@@ -54,6 +54,31 @@ async def assert_host_is_public(host: str) -> None:
|
||||
for ip in addresses:
|
||||
if not _is_public_ip(ip):
|
||||
raise SsrfError(f"{host} resolves to non-public address {ip}")
|
||||
return addresses
|
||||
|
||||
|
||||
async def _pinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
|
||||
"""``url`` aimed at the address just checked: (url, headers, extensions).
|
||||
|
||||
The URL names the IP, the Host header keeps the name (virtual hosting), and
|
||||
TLS gets the name for SNI and for checking the certificate."""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme != "https":
|
||||
raise SsrfError("only https is permitted")
|
||||
hostname = parts.hostname
|
||||
if not hostname:
|
||||
raise SsrfError("missing host")
|
||||
addresses = await assert_host_is_public(hostname)
|
||||
# IPv4 first: containers often have no IPv6 route, though DNS returns AAAA.
|
||||
ip = next((a for a in addresses if ":" not in a), addresses[0])
|
||||
literal = f"[{ip}]" if ":" in ip else ip
|
||||
netloc = f"{literal}:{parts.port}" if parts.port else literal
|
||||
host_header = f"{hostname}:{parts.port}" if parts.port else hostname
|
||||
return (
|
||||
parts._replace(netloc=netloc).geturl(),
|
||||
{"Host": host_header},
|
||||
{"sni_hostname": hostname},
|
||||
)
|
||||
|
||||
|
||||
async def guarded_get(url: str) -> str | None:
|
||||
@@ -63,12 +88,7 @@ async def guarded_get(url: str) -> str | None:
|
||||
(any non-200 response). Raises :class:`SsrfError` on a policy violation
|
||||
(bad scheme, private IP, oversize); lets httpx network errors propagate.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme != "https":
|
||||
raise SsrfError("only https is permitted")
|
||||
if not parts.hostname:
|
||||
raise SsrfError("missing host")
|
||||
await assert_host_is_public(parts.hostname)
|
||||
target, pin_headers, extensions = await _pinned(url)
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(
|
||||
@@ -76,7 +96,7 @@ async def guarded_get(url: str) -> str | None:
|
||||
timeout=HTTP_TIMEOUT,
|
||||
headers={"User-Agent": "webmail-autodiscovery/1.0"},
|
||||
) as client,
|
||||
client.stream("GET", url) as resp,
|
||||
client.stream("GET", target, headers=pin_headers, extensions=extensions) as resp,
|
||||
):
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
@@ -114,16 +134,17 @@ async def guarded_request(
|
||||
fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
|
||||
capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
|
||||
status + headers + body (all statuses; the caller decides what's an error)."""
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme != "https":
|
||||
raise SsrfError("only https is permitted")
|
||||
if not parts.hostname:
|
||||
raise SsrfError("missing host")
|
||||
await assert_host_is_public(parts.hostname)
|
||||
target, pin_headers, extensions = await _pinned(url)
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
|
||||
client.stream(method, url, headers=headers, content=content) as resp,
|
||||
client.stream(
|
||||
method,
|
||||
target,
|
||||
headers={**(headers or {}), **pin_headers},
|
||||
content=content,
|
||||
extensions=extensions,
|
||||
) as resp,
|
||||
):
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
|
||||
@@ -89,6 +89,7 @@ async def test_account(
|
||||
smtp_tls=smtp.tls,
|
||||
username=username,
|
||||
password=password,
|
||||
public_only=True,
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -125,9 +125,7 @@ async def mark_all_read(
|
||||
return {"changed": 0}
|
||||
messages = list(
|
||||
await db.scalars(
|
||||
select(Message).where(
|
||||
Message.thread_id.in_(thread_ids), Message.user_id == user.id
|
||||
)
|
||||
select(Message).where(Message.thread_id.in_(thread_ids), Message.user_id == user.id)
|
||||
)
|
||||
)
|
||||
add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([])
|
||||
@@ -137,9 +135,7 @@ async def mark_all_read(
|
||||
await _push_and_publish(user.id, changed, add, remove)
|
||||
bus = get_event_bus()
|
||||
for tid in thread_ids:
|
||||
await bus.publish(
|
||||
"thread.updated", {"user_id": str(user.id), "thread_id": str(tid)}
|
||||
)
|
||||
await bus.publish("thread.updated", {"user_id": str(user.id), "thread_id": str(tid)})
|
||||
return {"changed": len(changed)}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
@@ -29,7 +30,23 @@ async def _handler(request: Request, exc: StarletteHTTPException) -> JSONRespons
|
||||
return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
|
||||
|
||||
|
||||
async def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""A malformed request, in the usual error shape. Names the first field at
|
||||
fault and what is wrong with it — enough to show the user — but not the
|
||||
whole request model the framework's own answer would spell out."""
|
||||
errors = exc.errors()
|
||||
first = errors[0] if errors else {}
|
||||
loc = [str(p) for p in first.get("loc", ()) if p not in ("body", "query", "path")]
|
||||
field = loc[-1] if loc else ""
|
||||
reason = str(first.get("msg", "invalid value"))
|
||||
message = f"{field}: {reason}" if field else reason
|
||||
return JSONResponse(
|
||||
status_code=422, content={"error": {"code": "validation_error", "message": message}}
|
||||
)
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
"""Install the error-shape handler for all HTTP exceptions."""
|
||||
app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RequestValidationError, _validation_handler) # type: ignore[arg-type]
|
||||
|
||||
@@ -88,6 +88,23 @@ def _wrap(fragment: str) -> str:
|
||||
return _DOC_TEMPLATE.format(body=fragment)
|
||||
|
||||
|
||||
# The sanitized message as a document of its own. The app shows it in a
|
||||
# sandboxed frame (frontend MessageBody), but this URL can also be opened
|
||||
# directly in a tab, on our own origin: there, this policy is what keeps any
|
||||
# script the sanitizer might ever miss from running — no scripts, no plugins,
|
||||
# no forms, a sandbox of its own origin, and nothing framing it elsewhere.
|
||||
_MESSAGE_CSP = (
|
||||
"default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; "
|
||||
"form-action 'none'; frame-ancestors 'self'; img-src 'self' data: https: http:; "
|
||||
"style-src 'self' 'unsafe-inline'; font-src 'self' data: https:; media-src 'self'; "
|
||||
"sandbox allow-popups allow-popups-to-escape-sandbox"
|
||||
)
|
||||
|
||||
|
||||
def _html(document: str) -> HTMLResponse:
|
||||
return HTMLResponse(content=document, headers={"Content-Security-Policy": _MESSAGE_CSP})
|
||||
|
||||
|
||||
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
|
||||
msg = await db.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.user_id == user_id)
|
||||
@@ -123,7 +140,7 @@ async def message_html(
|
||||
|
||||
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
|
||||
if html_part is not None and not allow_remote and not stale:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
return _html(_wrap(html_part.content))
|
||||
|
||||
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
|
||||
# If the raw blob is unavailable, fall back to whatever is already stored.
|
||||
@@ -142,13 +159,13 @@ async def message_html(
|
||||
html_part.content = result.html # persist the upgraded sanitization
|
||||
html_part.sanitizer_v = result.sanitizer_v
|
||||
await db.commit()
|
||||
return HTMLResponse(content=_wrap(result.html))
|
||||
return _html(_wrap(result.html))
|
||||
|
||||
if html_part is not None:
|
||||
return HTMLResponse(content=_wrap(html_part.content))
|
||||
return _html(_wrap(html_part.content))
|
||||
text_part = next((p for p in parts if p.kind == "text"), None)
|
||||
body = html_lib.escape(text_part.content) if text_part else ""
|
||||
return HTMLResponse(content=_wrap(f'<div class="wm-plaintext">{body}</div>'))
|
||||
return _html(_wrap(f'<div class="wm-plaintext">{body}</div>'))
|
||||
|
||||
|
||||
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
|
||||
|
||||
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
|
||||
# Spelling and grammar checking in the compose window (LanguageTool server,
|
||||
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
|
||||
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
|
||||
# New passwords are checked against known data breaches (Have I Been Pwned's
|
||||
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
|
||||
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
|
||||
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import nh3
|
||||
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
|
||||
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
|
||||
# v5: this server's own inline-image URLs quoted in a message load from here.
|
||||
SANITIZER_VERSION = 5
|
||||
# v6: links keep only http(s) and mailto (no data: pages).
|
||||
SANITIZER_VERSION = 6
|
||||
|
||||
# This server's inline-image URL, however it ended up written: "/api/…",
|
||||
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
|
||||
@@ -202,6 +203,11 @@ def sanitize_html(
|
||||
return _sanitize_style(value, allow_remote_images)
|
||||
if tag == "a" and attr == "target":
|
||||
return "_blank"
|
||||
if attr == "href":
|
||||
# A link goes to the web or to a mail address: data: (a page made
|
||||
# up by the sender) and cid: are kept for inline images only.
|
||||
scheme = value.strip().lower().split(":", 1)[0] if ":" in value else ""
|
||||
return value if scheme in ("", "http", "https", "mailto") else None
|
||||
if tag == "img" and attr == "src":
|
||||
lowered = value.strip().lower()
|
||||
if lowered.startswith("cid:"):
|
||||
|
||||
@@ -37,6 +37,10 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_error_handlers(app)
|
||||
|
||||
from app.security.cross_site import CrossSiteGuard
|
||||
|
||||
app.add_middleware(CrossSiteGuard)
|
||||
|
||||
@app.get("/api/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness/readiness probe: checks DB and Redis (dev-setup §6)."""
|
||||
|
||||
@@ -168,9 +168,7 @@ class Mailbox(Base):
|
||||
# IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
|
||||
uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
|
||||
highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
|
||||
last_seen_uid: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("0")
|
||||
)
|
||||
last_seen_uid: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class Thread(Base):
|
||||
|
||||
@@ -21,9 +21,7 @@ class PluginHttp:
|
||||
content: bytes | str | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> GuardedResponse:
|
||||
return await guarded_request(
|
||||
method, url, headers=headers, content=content, auth=auth
|
||||
)
|
||||
return await guarded_request(method, url, headers=headers, content=content, auth=auth)
|
||||
|
||||
async def get(self, url: str, **kw: object) -> GuardedResponse:
|
||||
return await self.request("GET", url, **kw) # type: ignore[arg-type]
|
||||
|
||||
@@ -122,6 +122,16 @@ def _date_criteria(since: date | None, before: date | None) -> str:
|
||||
return " ".join(parts) if parts else "ALL"
|
||||
|
||||
|
||||
async def _require_public(host: str) -> None:
|
||||
"""Refuse a user-named server that now resolves to a non-public address."""
|
||||
from app.accounts.ssrf import SsrfError, assert_host_is_public
|
||||
|
||||
try:
|
||||
await assert_host_is_public(host)
|
||||
except SsrfError as exc:
|
||||
raise ProviderConnectError(f"refusing to connect to {host}: {exc}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfig:
|
||||
imap_host: str
|
||||
@@ -138,6 +148,10 @@ class ProviderConfig:
|
||||
# The hosted mail server: every user's mailbox is on that one host, so the
|
||||
# per-host connection cap (which spares outside providers) does not apply.
|
||||
internal: bool = False
|
||||
# A server the user named (every external account): its name is checked
|
||||
# again before each connection, so DNS changed after the account was added
|
||||
# cannot turn the worker on the internal network (DNS rebinding).
|
||||
public_only: bool = False
|
||||
|
||||
|
||||
class ImapSmtpProvider(MailProvider):
|
||||
@@ -196,6 +210,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
if self._imap is not None:
|
||||
return self._imap
|
||||
cfg = self._cfg
|
||||
if cfg.public_only:
|
||||
await _require_public(cfg.imap_host)
|
||||
await self._acquire_conn_slots()
|
||||
try:
|
||||
try:
|
||||
@@ -540,6 +556,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
|
||||
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
|
||||
cfg = self._cfg
|
||||
if cfg.public_only:
|
||||
await _require_public(cfg.smtp_host)
|
||||
use_tls = cfg.smtp_tls == "ssl"
|
||||
start_tls = cfg.smtp_tls == "starttls"
|
||||
host_slot = (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Refuse state-changing API requests that another web site makes a browser send.
|
||||
|
||||
The double-submit CSRF token (app/identity/deps.py ``require_csrf``) guards
|
||||
everything a signed-in user can change. The requests made before anyone is
|
||||
signed in — sign-in itself, sign-up, a password reset — have no token yet, so a
|
||||
hostile page could make a visitor's browser sign in to an account of the
|
||||
attacker's choosing (login CSRF): whatever the victim then writes, files or
|
||||
uploads lands in the attacker's account.
|
||||
|
||||
Browsers say where a request comes from: ``Sec-Fetch-Site`` in every current
|
||||
browser, ``Origin`` on any cross-origin POST in older ones. A request another
|
||||
site started is refused here, before any route runs. Mail apps, calendar apps
|
||||
and servers send neither header and are not affected (DAV lives outside /api
|
||||
anyway).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
_BODY = json.dumps(
|
||||
{"error": {"code": "cross_site", "message": "request refused: sent from another site"}}
|
||||
).encode()
|
||||
|
||||
|
||||
def _hostname(value: str) -> str:
|
||||
"""``host[:port]`` -> ``host``, lower-cased (IPv6 brackets kept)."""
|
||||
value = value.strip().lower()
|
||||
if value.startswith("["):
|
||||
return value.split("]", 1)[0] + "]"
|
||||
return value.rsplit(":", 1)[0] if value.count(":") == 1 else value
|
||||
|
||||
|
||||
def is_cross_site(headers: Headers) -> bool:
|
||||
"""Whether a browser reports this request as started by another site."""
|
||||
fetch_site = headers.get("sec-fetch-site")
|
||||
if fetch_site is not None:
|
||||
# "same-origin", "same-site" (our own subdomains), "none" (typed or
|
||||
# bookmarked): all fine. Only another site is refused.
|
||||
return fetch_site.strip().lower() == "cross-site"
|
||||
origin = headers.get("origin")
|
||||
if origin is None:
|
||||
return False # not a browser, or a same-origin request from an old one
|
||||
if origin.strip().lower() == "null":
|
||||
return True # sandboxed frame, data: URL, file: page
|
||||
origin_host = (urlsplit(origin.strip()).hostname or "").lower()
|
||||
# Our own domain, whatever a reverse proxy did to the Host header.
|
||||
domain = get_settings().app_domain.lower()
|
||||
if origin_host == domain or origin_host.endswith("." + domain):
|
||||
return False
|
||||
# Otherwise the name the browser used: Host, or the one a reverse proxy saw.
|
||||
ours = {_hostname(headers.get("host", ""))}
|
||||
forwarded = headers.get("x-forwarded-host")
|
||||
if forwarded:
|
||||
ours.add(_hostname(forwarded.split(",")[0]))
|
||||
return origin_host not in ours
|
||||
|
||||
|
||||
class CrossSiteGuard:
|
||||
"""ASGI middleware: 403 on cross-site POST/PUT/PATCH/DELETE under /api/."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if (
|
||||
scope["type"] == "http"
|
||||
and scope["method"] not in _SAFE_METHODS
|
||||
and scope["path"].startswith("/api/")
|
||||
and is_cross_site(Headers(scope=scope))
|
||||
):
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 403,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(_BODY)).encode()),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": _BODY})
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
@@ -302,7 +302,8 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
||||
|
||||
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
|
||||
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
|
||||
with no recorded origin fall back to the account's Inbox.
|
||||
with no recorded origin fall back to the account's Inbox. Only messages in
|
||||
Archive, Trash or Spam move: the thread's others (your reply in Sent) stay.
|
||||
"""
|
||||
tid = uuid.UUID(thread_id)
|
||||
async with session_scope() as session:
|
||||
@@ -319,10 +320,13 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
||||
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
|
||||
)
|
||||
path_by_id = {mb.id: mb.path for mb in mailboxes}
|
||||
removal_ids = {mb.id for mb in mailboxes if mb.special_use in _REMOVAL_FOLDERS}
|
||||
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
|
||||
# (message_id, account_id, source_path, target_id) — resolve the target now.
|
||||
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
|
||||
for m in messages:
|
||||
if m.mailbox_id not in removal_ids:
|
||||
continue # never removed: nothing to restore
|
||||
target_id = m.restore_mailbox_id
|
||||
if target_id is None or target_id not in path_by_id:
|
||||
target_id = inbox_by_account.get(m.account_id)
|
||||
|
||||
+40
-25
@@ -172,9 +172,7 @@ class _BackfillProgress:
|
||||
self._mailboxes.add(mailbox_path)
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Account)
|
||||
.where(Account.id == self.account.id)
|
||||
.values(backfill_done=self.done)
|
||||
update(Account).where(Account.id == self.account.id).values(backfill_done=self.done)
|
||||
)
|
||||
await get_event_bus().publish(
|
||||
"sync.progress",
|
||||
@@ -321,16 +319,28 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
dedupe = gmail and mailbox.special_use == "archive"
|
||||
if since is not None: # 3months: single windowed pass
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=since,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
since=since,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
else: # all: recent window first (usable fast), then the remainder
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=recent_cutoff,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
since=recent_cutoff,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, before=recent_cutoff,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
before=recent_cutoff,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
await _set_state(account_id, "live")
|
||||
@@ -491,9 +501,7 @@ async def apply_delta(
|
||||
await _reconcile_drafts(provider, account, mailbox)
|
||||
|
||||
|
||||
async def _reconcile_drafts(
|
||||
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
|
||||
) -> None:
|
||||
async def _reconcile_drafts(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> None:
|
||||
"""Drop DB rows for drafts that no longer exist on the server (were expunged)."""
|
||||
from app.db import session_scope
|
||||
|
||||
@@ -548,13 +556,13 @@ async def live_loop(
|
||||
await provider.verify() # signed in: the account is reachable
|
||||
if health is not None:
|
||||
await health.ok()
|
||||
first = True
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
except ProviderNotSupported:
|
||||
await asyncio.sleep(idle_fallback)
|
||||
if stop.is_set():
|
||||
break
|
||||
# Catch up before each IDLE, not only after it: IDLE reports what
|
||||
# arrives from now on, so mail that came while this loop was not
|
||||
# running (a new account's first minutes, a reconnect, a worker
|
||||
# restart) would otherwise wait for the next new message or the
|
||||
# IDLE timeout (10 min) to show.
|
||||
async with session_scope() as session:
|
||||
mailbox = await session.scalar(
|
||||
select(Mailbox).where(
|
||||
@@ -562,14 +570,23 @@ async def live_loop(
|
||||
)
|
||||
)
|
||||
mailbox_obj = mailbox
|
||||
if mailbox_obj is None:
|
||||
if mailbox_obj is not None:
|
||||
delta = await provider.poll(
|
||||
mailbox_path,
|
||||
last_seen_uid=mailbox_obj.last_seen_uid,
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
elif not first:
|
||||
break # the folder is gone
|
||||
# else: the account's first sync has not recorded the folder yet.
|
||||
first = False
|
||||
if stop.is_set():
|
||||
break
|
||||
delta = await provider.poll(
|
||||
mailbox_path,
|
||||
last_seen_uid=mailbox_obj.last_seen_uid,
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
except ProviderNotSupported:
|
||||
await asyncio.sleep(idle_fallback)
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
@@ -630,9 +647,7 @@ async def poll_folders_once(
|
||||
await provider.close()
|
||||
|
||||
|
||||
async def _poll_secondary_once(
|
||||
account_id: uuid.UUID, health: AccountHealth | None = None
|
||||
) -> None:
|
||||
async def _poll_secondary_once(account_id: uuid.UUID, health: AccountHealth | None = None) -> None:
|
||||
await poll_folders_once(account_id, include_inbox=False, health=health)
|
||||
|
||||
|
||||
|
||||
@@ -28,24 +28,52 @@ def _compact(name: str) -> str:
|
||||
# Localized folder names by special_use. Written readably; compacted at load.
|
||||
_NAMES: dict[str, list[str]] = {
|
||||
"sent": [
|
||||
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail
|
||||
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish
|
||||
"gesendet", "gesendete objekte", "gesendete elemente", # German
|
||||
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French
|
||||
"enviados", "elementos enviados", "correo enviado", # Spanish
|
||||
"inviata", "posta inviata", "messaggi inviati", # Italian
|
||||
"verzonden", "verzonden items", "verzonden berichten", # Dutch
|
||||
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese
|
||||
"отправленные", "исходящие", # Russian
|
||||
"skickat", "skickade", "skickade objekt", # Swedish
|
||||
"sendt", "sendte", "sendte elementer", # Norwegian / Danish
|
||||
"sent",
|
||||
"sent items",
|
||||
"sent mail",
|
||||
"sent messages", # English / Outlook / Gmail
|
||||
"wysłane",
|
||||
"elementy wysłane",
|
||||
"wiadomości wysłane", # Polish
|
||||
"gesendet",
|
||||
"gesendete objekte",
|
||||
"gesendete elemente", # German
|
||||
"envoyés",
|
||||
"éléments envoyés",
|
||||
"messages envoyés",
|
||||
"courrier envoyé", # French
|
||||
"enviados",
|
||||
"elementos enviados",
|
||||
"correo enviado", # Spanish
|
||||
"inviata",
|
||||
"posta inviata",
|
||||
"messaggi inviati", # Italian
|
||||
"verzonden",
|
||||
"verzonden items",
|
||||
"verzonden berichten", # Dutch
|
||||
"enviadas",
|
||||
"itens enviados",
|
||||
"mensagens enviadas", # Portuguese
|
||||
"отправленные",
|
||||
"исходящие", # Russian
|
||||
"skickat",
|
||||
"skickade",
|
||||
"skickade objekt", # Swedish
|
||||
"sendt",
|
||||
"sendte",
|
||||
"sendte elementer", # Norwegian / Danish
|
||||
"lähetetyt", # Finnish
|
||||
"odeslané", "odeslaná pošta", # Czech
|
||||
"odeslané",
|
||||
"odeslaná pošta", # Czech
|
||||
],
|
||||
"drafts": [
|
||||
"drafts", "draft",
|
||||
"robocze", "kopie robocze", "wersje robocze",
|
||||
"entwürfe", "entwurf",
|
||||
"drafts",
|
||||
"draft",
|
||||
"robocze",
|
||||
"kopie robocze",
|
||||
"wersje robocze",
|
||||
"entwürfe",
|
||||
"entwurf",
|
||||
"brouillons",
|
||||
"borradores",
|
||||
"bozze",
|
||||
@@ -58,44 +86,80 @@ _NAMES: dict[str, list[str]] = {
|
||||
"koncepty", # Czech
|
||||
],
|
||||
"trash": [
|
||||
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin",
|
||||
"trash",
|
||||
"deleted",
|
||||
"deleted items",
|
||||
"deleted messages",
|
||||
"bin",
|
||||
"recycle bin",
|
||||
"kosz",
|
||||
"papierkorb", "gelöschte objekte", "gelöschte elemente",
|
||||
"corbeille", "éléments supprimés",
|
||||
"papelera", "elementos eliminados",
|
||||
"cestino", "posta eliminata",
|
||||
"prullenbak", "verwijderde items",
|
||||
"lixeira", "lixo", "itens excluídos",
|
||||
"корзина", "удаленные",
|
||||
"papperskorg", "borttaget",
|
||||
"papirkurv", "slettet",
|
||||
"roskakori", "poistetut",
|
||||
"papierkorb",
|
||||
"gelöschte objekte",
|
||||
"gelöschte elemente",
|
||||
"corbeille",
|
||||
"éléments supprimés",
|
||||
"papelera",
|
||||
"elementos eliminados",
|
||||
"cestino",
|
||||
"posta eliminata",
|
||||
"prullenbak",
|
||||
"verwijderde items",
|
||||
"lixeira",
|
||||
"lixo",
|
||||
"itens excluídos",
|
||||
"корзина",
|
||||
"удаленные",
|
||||
"papperskorg",
|
||||
"borttaget",
|
||||
"papirkurv",
|
||||
"slettet",
|
||||
"roskakori",
|
||||
"poistetut",
|
||||
"koš", # Czech
|
||||
],
|
||||
"junk": [
|
||||
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk",
|
||||
"wiadomości-śmieci", "niechciane",
|
||||
"unerwünscht", "werbung",
|
||||
"courrier indésirable", "indésirables", "pourriel",
|
||||
"correo no deseado", "no deseado", "correo basura",
|
||||
"posta indesiderata", "indesiderata",
|
||||
"ongewenst", "ongewenste e-mail",
|
||||
"junk",
|
||||
"junk email",
|
||||
"junk e-mail",
|
||||
"spam",
|
||||
"bulk mail",
|
||||
"bulk",
|
||||
"wiadomości-śmieci",
|
||||
"niechciane",
|
||||
"unerwünscht",
|
||||
"werbung",
|
||||
"courrier indésirable",
|
||||
"indésirables",
|
||||
"pourriel",
|
||||
"correo no deseado",
|
||||
"no deseado",
|
||||
"correo basura",
|
||||
"posta indesiderata",
|
||||
"indesiderata",
|
||||
"ongewenst",
|
||||
"ongewenste e-mail",
|
||||
"lixo eletrônico",
|
||||
"спам", "нежелательная почта",
|
||||
"skräp", "skräppost",
|
||||
"спам",
|
||||
"нежелательная почта",
|
||||
"skräp",
|
||||
"skräppost",
|
||||
"uønsket",
|
||||
"roskaposti",
|
||||
"nevyžádaná pošta",
|
||||
],
|
||||
"archive": [
|
||||
"archive", "archives", "all mail",
|
||||
"archive",
|
||||
"archives",
|
||||
"all mail",
|
||||
"archiwum",
|
||||
"archiv",
|
||||
"archivio",
|
||||
"archivo",
|
||||
"archief",
|
||||
"arquivo", "arquivo morto",
|
||||
"архив", "вся почта",
|
||||
"arquivo",
|
||||
"arquivo morto",
|
||||
"архив",
|
||||
"вся почта",
|
||||
"arkiv",
|
||||
"arkisto",
|
||||
],
|
||||
|
||||
@@ -33,5 +33,6 @@ def build_provider(account: Account) -> ImapSmtpProvider:
|
||||
smtp_tls=str(smtp.get("tls", "ssl")),
|
||||
username=account.email,
|
||||
password=password,
|
||||
public_only=True, # a server the user named: never one inside our network
|
||||
)
|
||||
return ImapSmtpProvider(config)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.events import get_event_bus
|
||||
from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE
|
||||
from app.identity.sessions import resolve_session
|
||||
from app.redis_client import get_redis
|
||||
from app.security.cross_site import is_cross_site
|
||||
from app.sync import leases
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +28,12 @@ router = APIRouter()
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket) -> None:
|
||||
# Only our own pages may listen in: a socket another site opens is refused
|
||||
# even if the browser sent the session cookie with it (cross-site WebSocket
|
||||
# hijacking; SameSite=Lax already stops current browsers from sending it).
|
||||
if is_cross_site(websocket.headers):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get(
|
||||
SESSION_COOKIE_INSECURE, ""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user