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:
co-authored by
Claude Opus 5
parent
137d2cdd53
commit
e2fed5886f
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user