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.
140 lines
5.1 KiB
Python
140 lines
5.1 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 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.
|
|
for i in range(ratelimit.ACCOUNT_LIMIT):
|
|
ratelimit.sign_in_by_account.record("someone@example.com", now=float(i))
|
|
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()
|
|
for i in range(ratelimit.ACCOUNT_LIMIT):
|
|
ratelimit.sign_in_by_account.record("someone@example.com", now=float(i))
|
|
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()
|
|
for i in range(ratelimit.REGISTER_LIMIT):
|
|
ratelimit.register_by_address.record("203.0.113.9", now=float(i))
|
|
resp = await client.post(
|
|
"/api/auth/register",
|
|
json={"email": "new@example.com", "password": "a-long-enough-password"},
|
|
headers={"X-Forwarded-For": "203.0.113.9"},
|
|
)
|
|
assert resp.status_code == 429
|
|
|
|
|
|
async def test_client_address_prefers_the_forwarded_client(app):
|
|
# Behind a reverse proxy, remote_addr is the PROXY for every request on earth —
|
|
# keying on it would rate-limit the entire internet as one caller. The leftmost
|
|
# X-Forwarded-For entry is the original client.
|
|
async with app.test_request_context("/", headers={"X-Forwarded-For": "198.51.100.4, 10.0.0.1"}):
|
|
assert ratelimit.client_address() == "198.51.100.4"
|
|
|
|
|
|
async def test_client_address_falls_back_to_the_peer(app):
|
|
async with app.test_request_context("/"):
|
|
# No proxy header: whatever the peer address is, it must be a usable key
|
|
# rather than an empty string sharing one bucket with everyone.
|
|
assert ratelimit.client_address()
|