Files
FabledGrimoire/src/grimoire/rate_limit.py
T
bvandeusenandClaude Opus 5.5 dbd6d5b8b5
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Failing after 1m20s
CI & Build / integration (push) Successful in 2m27s
CI & Build / Build & push image (push) Skipped
feat(6838): read the client address and HTTPS from the trusted proxy hop
grimoire/proxy.py is the one reader of X-Forwarded-For and X-Forwarded-Proto.
It takes the entry TRUSTED_PROXY_HOPS in from the right (default 1; a shorter
header reads its leftmost entry; 0 trusts no proxy), so an entry a client puts
left of the proxy's is never believed. Before this, with TRUST_PROXY_HEADERS
on, the leftmost X-Forwarded-For entry keyed the login, register, forgot and
reset rate limits and the audit log, and a client could choose it.

The session cookie is Secure, and HSTS (max-age=31536000) is sent, exactly
when the request is HTTPS by the trusted proxy's word. Plain HTTP is
untouched and nothing redirects. SECURE_COOKIES is gone (a startup warning says
so when it is still set); TRUST_PROXY_HEADERS=false still maps to 0 hops, with
a warning to move to TRUSTED_PROXY_HOPS. Admin audit lines now use the same
client address instead of the socket peer.

The placeholder-key refusal keyed on SECURE_COOKIES, so it now refuses either
placeholder key outright; the instance makes its own key when none is set.

Family idea #5004 (IsHTTPS, trusted-hop X-Forwarded-Proto), canon v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:20 -05:00

28 lines
930 B
Python

"""In-process sliding-window rate limiter.
IMPORTANT — deployment note:
Rate limit counters are stored in memory and are lost on process restart.
The bucket key is the client address from grimoire/proxy.py, read by
TRUSTED_PROXY_HOPS. A hop count that is too low keys every caller on the
proxy's address, so they all share one bucket.
"""
import asyncio
import time
from collections import defaultdict
_buckets: dict[str, list[float]] = defaultdict(list)
_lock = asyncio.Lock()
async def is_rate_limited(key: str, max_requests: int, window_seconds: int) -> bool:
"""Returns True if request should be blocked (limit exceeded)."""
async with _lock:
now = time.monotonic()
cutoff = now - window_seconds
_buckets[key] = [t for t in _buckets[key] if t > cutoff]
if len(_buckets[key]) >= max_requests:
return True
_buckets[key].append(now)
return False