feat(link): rate-limit /v1/link and /v1/repos

Last unbuilt item on the promotion-window hardening checklist. /v1/link
round-trips Zitadel, can CREATE a forge account and always mints a token, so
it is the endpoint that must not be free to hammer.

Sliding window per (route, client), ON by default -- unlimited has to be a
deliberate config act, not an omission. Defaults 5/hour and 60/hour; /health
never limited. 429 + Retry-After, decided BEFORE the body is read so an
abusive caller costs nothing.

Decisions worth naming:
- State is an in-process dict behind a lock. granthi-link is ONE
  ThreadingHTTPServer, so that IS the store -- no redis. Kept behind a class
  so a future multi-process move has one thing to change.
- Denied requests are NOT recorded. Recording them lets a hammering client
  push its own window forward and lock itself out forever.
- Key store is capped; at capacity it drops least-recent windows and logs
  loudly. Fail-open under key pressure, chosen over an unbounded dict that
  is a memory DoS.
- trust_forwarded_for OFF by default. Behind cloudflared every request comes
  from the tunnel, so limiting on the socket peer starves everyone; but XFF
  is client-controlled. A caller can PREPEND, a trusted proxy APPENDS what it
  actually saw -- so we read the LAST entry, never the first.
- A malformed rule refuses startup instead of silently meaning unlimited.

Tests 69 -> 87, including a 40-thread race proving the lock holds, the
self-lockout case, XFF spoof-resistance, and a real 429 on the wire.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
This commit is contained in:
Nirav Patel
2026-08-22 23:14:54 -04:00
co-authored by Claude Opus 5
parent 137d2cdd53
commit e2fed5886f
4 changed files with 351 additions and 3 deletions
+173
View File
@@ -482,3 +482,176 @@ class TestHealth(HandlerTestBase):
if __name__ == "__main__":
unittest.main()
class TestRateLimiterUnit(unittest.TestCase):
"""The limiter in isolation, on a fake clock -- no sleeping in tests."""
def setUp(self):
self.now = 1000.0
self.rl = granthi_link.RateLimiter(
rules={"/v1/link": (3, 60)}, clock=lambda: self.now)
def test_allows_up_to_the_limit_then_denies(self):
for i in range(3):
allowed, _ = self.rl.check("/v1/link", "1.1.1.1")
self.assertTrue(allowed, f"request {i} should pass")
allowed, retry = self.rl.check("/v1/link", "1.1.1.1")
self.assertFalse(allowed)
self.assertGreater(retry, 0)
self.assertLessEqual(retry, 61)
def test_window_slides(self):
for _ in range(3):
self.rl.check("/v1/link", "1.1.1.1")
self.assertFalse(self.rl.check("/v1/link", "1.1.1.1")[0])
self.now += 61
self.assertTrue(self.rl.check("/v1/link", "1.1.1.1")[0])
def test_denied_requests_do_not_extend_the_window(self):
"""A client that keeps hammering must not push its own window
forward and lock itself out forever."""
for _ in range(3):
self.rl.check("/v1/link", "1.1.1.1")
for _ in range(20): # hammer while denied
self.now += 1
self.assertFalse(self.rl.check("/v1/link", "1.1.1.1")[0])
self.now = 1000.0 + 61 # just past the ORIGINAL window
self.assertTrue(self.rl.check("/v1/link", "1.1.1.1")[0])
def test_clients_are_isolated(self):
for _ in range(3):
self.rl.check("/v1/link", "1.1.1.1")
self.assertFalse(self.rl.check("/v1/link", "1.1.1.1")[0])
self.assertTrue(self.rl.check("/v1/link", "2.2.2.2")[0])
def test_routes_are_isolated_and_unknown_routes_pass(self):
for _ in range(3):
self.rl.check("/v1/link", "1.1.1.1")
self.assertFalse(self.rl.check("/v1/link", "1.1.1.1")[0])
self.assertTrue(self.rl.check("/v1/repos", "1.1.1.1")[0])
for _ in range(50):
self.assertTrue(self.rl.check("/health", "1.1.1.1")[0])
def test_zero_limit_disables_the_endpoint(self):
rl = granthi_link.RateLimiter(rules={"/v1/link": (0, 60)},
clock=lambda: self.now)
self.assertFalse(rl.check("/v1/link", "1.1.1.1")[0])
def test_key_store_stays_bounded(self):
rl = granthi_link.RateLimiter(rules={"/v1/link": (5, 60)},
max_keys=50, clock=lambda: self.now)
for i in range(500):
self.now += 0.001
rl.check("/v1/link", f"10.0.{i // 256}.{i % 256}")
self.assertLessEqual(len(rl._hits), 50 + 1)
def test_expired_keys_are_reclaimed(self):
rl = granthi_link.RateLimiter(rules={"/v1/link": (5, 60)},
max_keys=10, clock=lambda: self.now)
for i in range(10):
rl.check("/v1/link", f"10.0.0.{i}")
self.now += 120 # everything expires
for i in range(10, 25):
rl.check("/v1/link", f"10.0.0.{i}")
self.assertLessEqual(len(rl._hits), 11)
def test_concurrent_checks_never_exceed_the_limit(self):
"""The lock has to actually hold under threads: 40 racing callers
against a limit of 10 must yield exactly 10 allows."""
rl = granthi_link.RateLimiter(rules={"/v1/link": (10, 60)})
results, lock = [], threading.Lock()
def hit():
a, _ = rl.check("/v1/link", "9.9.9.9")
with lock:
results.append(a)
ts = [threading.Thread(target=hit) for _ in range(40)]
for t in ts:
t.start()
for t in ts:
t.join()
self.assertEqual(sum(results), 10)
class _FakeHandler:
def __init__(self, peer, xff=None):
self.client_address = (peer, 12345)
self.headers = {} if xff is None else {"X-Forwarded-For": xff}
if xff is not None:
self.headers = type("H", (), {"get": lambda s, k, d="":
xff if k == "X-Forwarded-For" else d})()
class TestClientIp(unittest.TestCase):
def test_socket_peer_by_default(self):
h = _FakeHandler("5.5.5.5", xff="1.2.3.4")
self.assertEqual(granthi_link.client_ip(h, False), "5.5.5.5")
def test_trusted_proxy_uses_the_last_hop_not_the_client_supplied_first(self):
"""A caller can PREPEND anything to X-Forwarded-For; a trusted proxy
appends the peer it really saw. Only the last entry is trustworthy."""
h = _FakeHandler("10.0.0.1", xff="1.2.3.4, 203.0.113.9")
self.assertEqual(granthi_link.client_ip(h, True), "203.0.113.9")
def test_trusted_proxy_falls_back_when_header_absent(self):
h = _FakeHandler("10.0.0.1")
self.assertEqual(granthi_link.client_ip(h, True), "10.0.0.1")
class TestRateLimitConfig(ServiceTestBase):
def _svc(self, rl):
cfg = dict(self.svc.cfg)
cfg["rate_limit"] = rl
return granthi_link.LinkService(cfg)
def test_enabled_by_default_when_key_absent(self):
self.assertIsNotNone(self.svc.limiter)
def test_explicit_disable_is_honored(self):
self.assertIsNone(self._svc({"enabled": False}).limiter)
def test_custom_rule_overrides_default(self):
svc = self._svc({"rules": {"/v1/link": [99, 120]}})
self.assertEqual(svc.limiter.rules["/v1/link"], (99, 120))
def test_malformed_rule_refuses_startup_rather_than_meaning_unlimited(self):
for bad in ({"rules": {"/v1/link": [5]}},
{"rules": {"/v1/link": "5/hour"}},
{"rules": {"/v1/link": [5, 0]}},
{"rules": {"/v1/link": [5, -1]}},
{"rules": {"/v1/link": ["5", "60"]}}):
with self.subTest(cfg=bad):
with self.assertRaises(SystemExit):
self._svc(bad)
class TestRateLimitOverHttp(HandlerTestBase):
"""Proven on the wire: real 429, real Retry-After."""
def setUp(self):
super().setUp()
self.svc.limiter = granthi_link.RateLimiter(rules={"/v1/link": (2, 60)})
def test_third_request_gets_429_with_retry_after(self):
body = json.dumps({"zitadel_access_token": "x"}).encode()
hdrs = {"Content-Length": str(len(body)),
"Content-Type": "application/json"}
for _ in range(2):
st, _ = self.raw_post("/v1/link", body, hdrs)
self.assertNotEqual(st, 429)
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
self.addCleanup(conn.close)
conn.request("POST", "/v1/link", body=body, headers=hdrs)
resp = conn.getresponse()
self.assertEqual(resp.status, 429)
self.assertTrue(resp.getheader("Retry-After"))
self.assertIn("rate limit", json.loads(resp.read())["error"])
def test_health_is_never_rate_limited(self):
for _ in range(30):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
conn.request("GET", "/health")
self.assertEqual(conn.getresponse().status, 200)
conn.close()