Trust proxy headers by hop count, and log every credential event
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy hops setting for how many proxy hops we should trust a shared real-ip at… and is there any session logging."* Neither existed, and the first one was a real hole. **The address was forgeable.** `client_address()` read the LEFTMOST `X-Forwarded-For` entry — nominally "the original client", and precisely the one a caller controls, because anything they send arrives before what proxies append. So `curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh rate-limit bucket every time. Concretely: stuffing ONE account stayed limited (the account key is unforgeable and that is why it exists), but spraying MANY accounts from one source was not — each account got its own budget, and the per-address cap meant to bound the total was defeated by a header. On a LAN that is nothing. It is not nothing on a public host. Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1. Each hop appends what it saw, so the rightmost entries are the ones our own infrastructure wrote and a forged prefix lands to the left of them where it can never be selected — proven for the honest, forged, padded, CDN and shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in front of a proxy. Too high is the dangerous direction, so a header shorter than configured falls back to the socket address rather than reaching further left. `X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a new `proxy.py` rather than being written twice — two places holding one decision is how issue 2183 happened, and this is the same decision. Env rather than the Settings UI, against rule 25's usual pull: it is deployment topology rather than preference, and the limiter consults it BEFORE opening a database connection, which is the entire point of checking a throttle before doing expensive work. Easy to move if that reads wrong. **And there was no logging at all** — `auth.py` had no logger, and the only record of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips, new accounts and device-token issuance now all log, with the attempted email and the trusted address. Deliberately including the email: it is the operator's own server, and "somebody failed a login" without saying against which account is not actionable. `basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers and leaves the root at WARNING — without it every line above would have gone nowhere, which is a worse failure than not writing them. This is the app log, not an audit table. Not queryable, not retained past log rotation. The table is task 2939; this is what makes the next few days observable.
This commit is contained in:
+27
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -11,8 +12,8 @@ from .common import iso
|
||||
from .db import session_scope
|
||||
from .models.device_token import DeviceToken
|
||||
from .models.user import User
|
||||
from .proxy import client_address
|
||||
from .ratelimit import (
|
||||
client_address,
|
||||
register_by_address,
|
||||
sign_in_by_account,
|
||||
sign_in_by_address,
|
||||
@@ -22,6 +23,16 @@ from .settings import get_setting, set_settings
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
# Every credential event goes to the app log — there is no audit TABLE yet (see task
|
||||
# 2939), and until there is, `docker compose logs` is the only way to know whether
|
||||
# anyone is knocking. That matters most in exactly the window this was written for: a
|
||||
# freshly-exposed instance.
|
||||
#
|
||||
# The attempted email is included deliberately. It is the operator's own server, and
|
||||
# "somebody failed a login" without saying against WHICH account tells you nothing you
|
||||
# can act on. Passwords, obviously, never appear.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_KEY = "user_id"
|
||||
MIN_PASSWORD_LEN = 8
|
||||
DEVICE_NAME_CAP = 100
|
||||
@@ -120,6 +131,7 @@ def _throttled(retry_after: int):
|
||||
`Retry-After` is standard and is the one thing a legitimate client (or person)
|
||||
genuinely needs.
|
||||
"""
|
||||
logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after)
|
||||
return (
|
||||
jsonify({"error": "too many attempts — try again shortly"}),
|
||||
429,
|
||||
@@ -186,6 +198,7 @@ async def register():
|
||||
# The first account bootstraps the admin and is always allowed, even when
|
||||
# registration is otherwise closed.
|
||||
if not is_first and not await get_setting(db, "allow_registration"):
|
||||
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "registration is closed"}), 403
|
||||
existing = await db.scalar(select(User).where(User.email == email))
|
||||
if existing is not None:
|
||||
@@ -215,6 +228,9 @@ async def register():
|
||||
await db.refresh(user)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
logger.info(
|
||||
"account created email=%s admin=%s from=%s", email, is_first, client_address()
|
||||
)
|
||||
return jsonify(_serialize_user(user)), 201
|
||||
|
||||
|
||||
@@ -236,13 +252,16 @@ async def login():
|
||||
# difference is a reliable oracle for which emails have accounts here.
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
logger.warning("sign-in failed (no such account) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
_sign_in_succeeded(email)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
logger.info("sign-in ok email=%s from=%s", email, client_address())
|
||||
return jsonify(_serialize_user(user))
|
||||
|
||||
|
||||
@@ -310,12 +329,19 @@ async def device_login():
|
||||
if user is None or not user.password_hash:
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
logger.warning("device-login failed (no such account) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
logger.warning("device-login failed (bad password) email=%s from=%s", email, client_address())
|
||||
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 "")
|
||||
# A device token outlives the session that made it, so its creation is the
|
||||
# most consequential thing on this blueprint.
|
||||
logger.info(
|
||||
"device token issued email=%s device=%s from=%s", email, row.name, client_address()
|
||||
)
|
||||
await db.commit()
|
||||
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
|
||||
|
||||
|
||||
Reference in New Issue
Block a user