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.
138 lines
4.8 KiB
Python
138 lines
4.8 KiB
Python
"""The credential throttle. DB-free, like the rest of this suite.
|
|
|
|
The window itself is exercised directly with an injected clock, so nothing here
|
|
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
|
|
|
|
The routes are exercised only as far as they get WITHOUT a database — a throttled
|
|
request returns 429 before any session is opened, which is the whole point of
|
|
checking the limit before the password. The happy path can't be reached here and is
|
|
not pretended at.
|
|
"""
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from thoughtsync import ratelimit
|
|
from thoughtsync.app import create_app
|
|
from thoughtsync.ratelimit import SlidingWindow
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_counters():
|
|
ratelimit.reset_all()
|
|
yield
|
|
ratelimit.reset_all()
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
def test_under_the_limit_is_not_blocked():
|
|
w = SlidingWindow(limit=3, window_s=60)
|
|
for i in range(3):
|
|
assert w.retry_after("k", now=i) is None
|
|
w.record("k", now=i)
|
|
assert w.retry_after("k", now=3) is not None
|
|
|
|
|
|
def test_window_slides_rather_than_resetting():
|
|
w = SlidingWindow(limit=2, window_s=60)
|
|
w.record("k", now=0)
|
|
w.record("k", now=30)
|
|
assert w.retry_after("k", now=31) is not None
|
|
# The 0s hit falls out at t=60, which frees exactly one slot — the 30s hit is
|
|
# still inside the window, so this is a slide and not a reset.
|
|
assert w.retry_after("k", now=61) is None
|
|
w.record("k", now=61)
|
|
assert w.retry_after("k", now=62) is not None
|
|
|
|
|
|
def test_retry_after_points_past_the_oldest_hit():
|
|
w = SlidingWindow(limit=1, window_s=100)
|
|
w.record("k", now=10)
|
|
wait = w.retry_after("k", now=40)
|
|
# The hit at t=10 leaves the window at t=110, i.e. 70s away. Rounded up, never
|
|
# under-reported — a client that waits exactly this long must not be refused
|
|
# again.
|
|
assert wait is not None
|
|
assert 70 <= wait <= 72
|
|
assert w.retry_after("k", now=40 + wait) is None
|
|
|
|
|
|
def test_keys_are_counted_separately():
|
|
w = SlidingWindow(limit=1, window_s=60)
|
|
w.record("a", now=0)
|
|
assert w.retry_after("a", now=1) is not None
|
|
assert w.retry_after("b", now=1) is None
|
|
|
|
|
|
def test_forget_clears_one_key():
|
|
w = SlidingWindow(limit=1, window_s=60)
|
|
w.record("a", now=0)
|
|
w.record("b", now=0)
|
|
w.forget("a")
|
|
assert w.retry_after("a", now=1) is None
|
|
assert w.retry_after("b", now=1) is not None
|
|
|
|
|
|
def test_bucket_count_is_bounded(monkeypatch):
|
|
# An attacker rotating a forged X-Forwarded-For must not be able to grow this
|
|
# dict without limit — the limiter cannot become the exhaustion it prevents.
|
|
monkeypatch.setattr(ratelimit, "MAX_BUCKETS", 8)
|
|
w = SlidingWindow(limit=5, window_s=60)
|
|
for i in range(50):
|
|
w.record(f"addr-{i}", now=i)
|
|
assert len(w._hits) <= 8
|
|
|
|
|
|
async def test_login_starts_refusing(app):
|
|
client = app.test_client()
|
|
body = {"email": "someone@example.com", "password": "wrong-password"}
|
|
# Pre-load the account's counter to its limit rather than posting that many
|
|
# times: every real attempt would need a database to reach the password check.
|
|
#
|
|
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
|
|
# at t=0..9 are fifteen minutes stale the moment the route reads
|
|
# `time.monotonic()` and get pruned before they can refuse anything.
|
|
now = time.monotonic()
|
|
for _ in range(ratelimit.ACCOUNT_LIMIT):
|
|
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
|
|
resp = await client.post("/api/auth/login", json=body)
|
|
assert resp.status_code == 429
|
|
assert resp.headers.get("Retry-After")
|
|
# The refusal says nothing about whether that account exists.
|
|
assert "someone@example.com" not in (await resp.get_data(as_text=True))
|
|
|
|
|
|
async def test_device_login_shares_the_account_counter(app):
|
|
client = app.test_client()
|
|
now = time.monotonic()
|
|
for _ in range(ratelimit.ACCOUNT_LIMIT):
|
|
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
|
|
resp = await client.post(
|
|
"/api/auth/device-login",
|
|
json={"email": "someone@example.com", "password": "wrong-password"},
|
|
)
|
|
# Same budget as /login — otherwise guessing just moves to the route that hands
|
|
# out a long-lived bearer token.
|
|
assert resp.status_code == 429
|
|
|
|
|
|
async def test_register_is_throttled_by_address(app):
|
|
client = app.test_client()
|
|
now = time.monotonic()
|
|
for _ in range(ratelimit.REGISTER_LIMIT):
|
|
ratelimit.register_by_address.record("203.0.113.9", now=now)
|
|
resp = await client.post(
|
|
"/api/auth/register",
|
|
json={"email": "new@example.com", "password": "a-long-enough-password"},
|
|
# One entry, so with the default single trusted hop this IS the address the
|
|
# limiter keys on. The forged-prefix cases live in test_proxy.py.
|
|
headers={"X-Forwarded-For": "203.0.113.9"},
|
|
)
|
|
assert resp.status_code == 429
|
|
|
|
|