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:
@@ -22,9 +22,11 @@ came from, and either one can refuse it:
|
||||
is what stops credential stuffing against one known email, no matter how many
|
||||
addresses the attempts arrive from.
|
||||
- **The address** bounds the damage from one source spraying many accounts. It is
|
||||
best-effort by nature — behind a reverse proxy the client address is read from
|
||||
``X-Forwarded-For``, which a caller can set to anything if the app is exposed
|
||||
directly. That is precisely why it is not the only key.
|
||||
read from ``X-Forwarded-For``, counting in from the RIGHT by
|
||||
``THOUGHTSYNC_TRUSTED_PROXY_HOPS`` so that only entries our own proxies wrote are
|
||||
believed — a forged header lands to the left of those and is never selected. It is
|
||||
still the weaker of the two keys, because it depends on that setting matching the
|
||||
deployment; the account key depends on nothing.
|
||||
|
||||
Counting is by failure for the sign-in routes and by attempt for registration: a
|
||||
correct password should never move someone closer to being locked out, but every
|
||||
@@ -35,7 +37,6 @@ from __future__ import annotations
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from quart import request
|
||||
|
||||
# Failed sign-ins tolerated per account before it stops answering, and for how long.
|
||||
# Ten is comfortably above a person mistyping a password and far below anything that
|
||||
@@ -116,24 +117,6 @@ sign_in_by_address = SlidingWindow(ADDRESS_LIMIT, ADDRESS_WINDOW_S)
|
||||
register_by_address = SlidingWindow(REGISTER_LIMIT, REGISTER_WINDOW_S)
|
||||
|
||||
|
||||
def client_address() -> str:
|
||||
"""The caller's address, as well as it can be known.
|
||||
|
||||
``X-Forwarded-For`` is a list appended to by each hop, so the leftmost entry is
|
||||
the original client — and also the only entry a client can choose for itself.
|
||||
It is trusted here anyway, because the alternative behind a reverse proxy is to
|
||||
see the proxy's address for every request on earth and rate-limit the entire
|
||||
internet as one caller. The account-keyed limit is the one that holds when this
|
||||
one is lied to.
|
||||
"""
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first[:64] # bounded: this becomes a dict key
|
||||
return (request.remote_addr or "unknown")[:64]
|
||||
|
||||
|
||||
def reset_all() -> None:
|
||||
"""Drop every counter. For tests — nothing in the app calls this."""
|
||||
for window in (sign_in_by_account, sign_in_by_address, register_by_address):
|
||||
|
||||
Reference in New Issue
Block a user