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:
@@ -0,0 +1,74 @@
|
||||
"""Reading what the proxies in front of this app say about a request.
|
||||
|
||||
Two headers carry information the app cannot see for itself — who the client is
|
||||
(`X-Forwarded-For`) and whether they arrived over TLS (`X-Forwarded-Proto`) — and both
|
||||
are trusted by the same rule, so the rule lives in one place. Writing it twice is
|
||||
precisely how issue 2183 happened: two places holding one decision, and only one of
|
||||
them updated.
|
||||
|
||||
## The rule
|
||||
|
||||
A forwarding header grows LEFT to RIGHT. Each hop appends what IT saw, so the
|
||||
rightmost entries are the ones our own infrastructure wrote, and anything a caller
|
||||
sent arrives to the LEFT of those.
|
||||
|
||||
That inverts the intuitive reading. The leftmost entry is nominally "the original
|
||||
client" — and is exactly the one a caller can forge, by sending the header themselves.
|
||||
So we count in from the right by the number of proxies we actually run
|
||||
(`THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1), and a forged prefix can never be
|
||||
selected no matter how much of it there is.
|
||||
|
||||
Too HIGH a hop count is the dangerous direction: it starts believing entries no proxy
|
||||
of ours wrote. Too low just means several callers share a bucket. So when the header
|
||||
is shorter than configured — fewer proxies than expected — we fall back to the socket
|
||||
address rather than reaching further left.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import has_request_context, request
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
def trusted_entry(header: str, hops: int) -> str | None:
|
||||
"""The nth-from-the-right entry of a forwarding header, or None if there isn't one.
|
||||
|
||||
Pure, so the trust boundary is testable without a request context.
|
||||
"""
|
||||
if hops <= 0:
|
||||
return None
|
||||
entries = [part.strip() for part in header.split(",") if part.strip()]
|
||||
if len(entries) < hops:
|
||||
return None
|
||||
return entries[-hops]
|
||||
|
||||
|
||||
def forwarded_for(header: str, remote_addr: str | None, hops: int) -> str:
|
||||
"""The client address a proxy chain vouches for, else this connection's peer."""
|
||||
entry = trusted_entry(header, hops)
|
||||
return (entry or remote_addr or "unknown")[:64] # bounded: becomes a dict key
|
||||
|
||||
|
||||
def client_address() -> str:
|
||||
"""The caller's address, as far as the deployment's own proxies vouch for it."""
|
||||
return forwarded_for(
|
||||
request.headers.get("X-Forwarded-For", ""),
|
||||
request.remote_addr,
|
||||
Config.trusted_proxy_hops(),
|
||||
)
|
||||
|
||||
|
||||
def is_https() -> bool:
|
||||
"""Whether this request reached us over TLS — directly, or via a trusted proxy.
|
||||
|
||||
Shared by the session cookie's `Secure` flag and by HSTS, because they are the same
|
||||
question. Read with the same hop count as the address: a caller who sets
|
||||
`X-Forwarded-Proto: https` on a plain-HTTP request puts it to the left of whatever
|
||||
our proxy appended, so it is not what gets read.
|
||||
"""
|
||||
if not has_request_context():
|
||||
return False
|
||||
if request.is_secure:
|
||||
return True
|
||||
entry = trusted_entry(request.headers.get("X-Forwarded-Proto", ""), Config.trusted_proxy_hops())
|
||||
return (entry or "").lower() == "https"
|
||||
Reference in New Issue
Block a user