server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is reachable by everyone, and nothing in front of it was counting. Three credential routes — /login, /register and /device-login — now throttle. Every attempt is counted against BOTH the account and the calling address, and either can refuse it. The account key is the one that matters and the one that cannot be forged: it stops stuffing against a known email no matter how many addresses the attempts arrive from. The address key bounds one source spraying many accounts, and is best-effort by nature — behind a proxy it comes from X-Forwarded-For, which a caller can set to anything if the app is exposed directly. That is exactly why it isn't the only key. The check runs BEFORE the password is verified, which is the other half of what this protects. bcrypt is deliberately slow; an unauthenticated caller who can trigger it without limit has a CPU exhaustion primitive as well as a guessing one. Sliding rather than fixed windows, because a fixed one lets twice the limit through across a boundary. Bucket count is capped so a rotating forged header can't turn the limiter into the exhaustion it prevents. A sign-in against an email with no account now spends a real bcrypt against a throwaway hash first. Without it "no such account" returned in microseconds while a wrong password took ~100ms, which is a reliable oracle for which emails are registered here. Every response carries a CSP with script-src 'self', object-src 'none' and frame-ancestors 'none', plus nosniff, a referrer policy and a permissions policy. The app has no inline and no third-party scripts, so this concedes nothing; the exceptions are honest — inline STYLE (Vue writes it itself for v-show and the FLIP), and remote images (a link preview renders the og:image of an arbitrary host, over either scheme, since a LAN install is served over http). HSTS only where the request already arrived over TLS, and scoped to the one host: no includeSubDomains, no preload, neither of which is this app's to commit. X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's Secure flag and HSTS are the same question, and answering it twice is how the two drift apart. docs/public-hosting.md is the rest of it: the four things only the operator can do (close registration, terminate TLS and forward the scheme, stop publishing the app port, back up the attachment volume as well as the database), and an honest list of what the app does NOT have — no email verification, no password reset, no second factor, no per-user quota, no audit log. Those aren't blockers for an instance whose accounts are people you know. They're the reason not to leave signups open to strangers.
This commit is contained in:
+90
-3
@@ -11,7 +11,13 @@ from .common import iso
|
||||
from .db import session_scope
|
||||
from .models.device_token import DeviceToken
|
||||
from .models.user import User
|
||||
from .security import generate_token, hash_password, hash_token, verify_password
|
||||
from .ratelimit import (
|
||||
client_address,
|
||||
register_by_address,
|
||||
sign_in_by_account,
|
||||
sign_in_by_address,
|
||||
)
|
||||
from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password
|
||||
from .settings import get_setting
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
@@ -106,6 +112,52 @@ def require_admin(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _throttled(retry_after: int):
|
||||
"""The 429 every throttled credential route returns.
|
||||
|
||||
Deliberately says nothing about WHICH limit was hit or how many attempts are
|
||||
left — that would tell someone probing whether the email they guessed exists.
|
||||
`Retry-After` is standard and is the one thing a legitimate client (or person)
|
||||
genuinely needs.
|
||||
"""
|
||||
return (
|
||||
jsonify({"error": "too many attempts — try again shortly"}),
|
||||
429,
|
||||
{"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
|
||||
def _sign_in_block(email: str) -> int | None:
|
||||
"""Seconds to wait before this sign-in may be attempted, or None to proceed.
|
||||
|
||||
Checked BEFORE the password is verified, so a throttled attempt costs no bcrypt
|
||||
— which is the other half of what this protects: hashing is deliberately slow,
|
||||
and an unauthenticated caller who can trigger it without limit has a CPU
|
||||
exhaustion primitive, not just a guessing one.
|
||||
"""
|
||||
address = client_address()
|
||||
waits = [
|
||||
sign_in_by_address.retry_after(address),
|
||||
sign_in_by_account.retry_after(email) if email else None,
|
||||
]
|
||||
live = [w for w in waits if w is not None]
|
||||
return max(live) if live else None
|
||||
|
||||
|
||||
def _sign_in_failed(email: str) -> None:
|
||||
address = client_address()
|
||||
sign_in_by_address.record(address)
|
||||
if email:
|
||||
sign_in_by_account.record(email)
|
||||
|
||||
|
||||
def _sign_in_succeeded(email: str) -> None:
|
||||
"""Clear the account's history on success. The address keeps its count: one
|
||||
correct password does not vouch for the other attempts from there."""
|
||||
if email:
|
||||
sign_in_by_account.forget(email)
|
||||
|
||||
|
||||
@bp.post("/register")
|
||||
async def register():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
@@ -120,6 +172,14 @@ async def register():
|
||||
if not display_name:
|
||||
display_name = email.split("@", 1)[0]
|
||||
|
||||
# Counted by attempt rather than by failure: a rejected registration still cost a
|
||||
# round trip and a uniqueness check, and on an instance with signups open the
|
||||
# thing worth bounding is how fast accounts can appear at all.
|
||||
wait = register_by_address.retry_after(client_address())
|
||||
if wait is not None:
|
||||
return _throttled(wait)
|
||||
register_by_address.record(client_address())
|
||||
|
||||
async with session_scope() as db:
|
||||
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
is_first = user_count == 0
|
||||
@@ -150,10 +210,23 @@ async def login():
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
wait = _sign_in_block(email)
|
||||
if wait is not None:
|
||||
return _throttled(wait)
|
||||
|
||||
async with session_scope() as db:
|
||||
user = await db.scalar(select(User).where(User.email == email))
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
if user is None or not user.password_hash:
|
||||
# Hash anyway. Without this, "no such account" returns in microseconds
|
||||
# while a wrong password takes bcrypt's deliberate ~100ms, and the
|
||||
# difference is a reliable oracle for which emails have accounts here.
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
_sign_in_succeeded(email)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
return jsonify(_serialize_user(user))
|
||||
@@ -210,10 +283,24 @@ async def device_login():
|
||||
password = data.get("password") or ""
|
||||
if not email or not password:
|
||||
return jsonify({"error": "email and password are required"}), 400
|
||||
|
||||
# Same budget as the web sign-in, and the SAME counters — this route hands out a
|
||||
# long-lived bearer token, so leaving it unthrottled would just move the guessing
|
||||
# here from /login.
|
||||
wait = _sign_in_block(email)
|
||||
if wait is not None:
|
||||
return _throttled(wait)
|
||||
|
||||
async with session_scope() as db:
|
||||
user = await db.scalar(select(User).where(User.email == email))
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
if user is None or not user.password_hash:
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
_sign_in_succeeded(email)
|
||||
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
|
||||
await db.commit()
|
||||
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
|
||||
|
||||
Reference in New Issue
Block a user