fix(link): address 4 codex [P2] findings on the rate limiter
All four were real bypass or fail-open paths on an endpoint about to be publicly exposed: - X-Forwarded-For was trusted from ANY peer. The origin also listens on the tailnet, so anyone reaching it directly could pick -- and rotate -- their own rate-limit key by sending a header. Now honored only when the socket peer is in a configured trusted_proxies list, and the last hop must parse as a real IP. trust_forwarded_for without trusted_proxies REFUSES startup. - Capacity eviction was fail-open and exploitable: an attacker able to mint many distinct keys could evict their own live window and start fresh. Now reclaims only EXPIRED windows and refuses the new key when all are live. Fail closed -- /v1/link is invite-only, so hitting the cap is an attack. - The clock was read outside the lock, so racing threads could append out of order; both retry_after (hits[0]) and reclamation (v[-1]) assume the list is chronological. Moved inside. - Config types were unvalidated: `"enabled": null` or `0` silently disabled limiting, and the string "false" enabled XFF trust (non-empty strings are truthy). Booleans must now be real JSON booleans; rate_limit must be an object. Codex confirmed no path-variant bypass (dispatch is exact-match) and no keep-alive/pipelining bypass (rejects set close_connection). Tests 87 -> 99: capacity fail-closed with the victim's window proven untouched through a 40-key flood, 200-thread chronological-order check, untrusted-peer spoof, junk XFF, and every config-type trap. 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
e2fed5886f
commit
c442721aff
+118
-3
@@ -585,19 +585,43 @@ class _FakeHandler:
|
||||
|
||||
|
||||
class TestClientIp(unittest.TestCase):
|
||||
PROXY = ("10.0.0.0/8",)
|
||||
|
||||
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")
|
||||
self.assertEqual(granthi_link.client_ip(h, False, self.PROXY), "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")
|
||||
self.assertEqual(granthi_link.client_ip(h, True, self.PROXY),
|
||||
"203.0.113.9")
|
||||
|
||||
def test_untrusted_peer_cannot_choose_its_own_key(self):
|
||||
"""The origin also listens on the tailnet. Anyone reaching it
|
||||
directly must not be able to pick (and rotate) their rate-limit key
|
||||
just by sending a header."""
|
||||
h = _FakeHandler("203.0.113.50", xff="9.9.9.9")
|
||||
self.assertEqual(granthi_link.client_ip(h, True, self.PROXY),
|
||||
"203.0.113.50")
|
||||
|
||||
def test_non_ip_last_hop_falls_back_to_peer(self):
|
||||
for junk in ("not-an-ip", "', OR 1=1", "x" * 500, "10.0.0.1:8080"):
|
||||
with self.subTest(junk=junk):
|
||||
h = _FakeHandler("10.0.0.1", xff=f"1.2.3.4, {junk}")
|
||||
self.assertEqual(
|
||||
granthi_link.client_ip(h, True, self.PROXY), "10.0.0.1")
|
||||
|
||||
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")
|
||||
self.assertEqual(granthi_link.client_ip(h, True, self.PROXY),
|
||||
"10.0.0.1")
|
||||
|
||||
def test_malformed_trusted_proxy_entry_does_not_grant_trust(self):
|
||||
h = _FakeHandler("10.0.0.1", xff="9.9.9.9")
|
||||
self.assertEqual(
|
||||
granthi_link.client_ip(h, True, ("not-a-cidr",)), "10.0.0.1")
|
||||
|
||||
|
||||
class TestRateLimitConfig(ServiceTestBase):
|
||||
@@ -655,3 +679,94 @@ class TestRateLimitOverHttp(HandlerTestBase):
|
||||
conn.request("GET", "/health")
|
||||
self.assertEqual(conn.getresponse().status, 200)
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestRateLimitHardening(unittest.TestCase):
|
||||
"""The four codex [P2] findings, each pinned by a test."""
|
||||
|
||||
def test_capacity_fails_closed_instead_of_resetting_a_live_window(self):
|
||||
"""Evicting a live window would let an attacker who can mint many
|
||||
distinct keys clear their OWN limit on demand. New keys are refused
|
||||
instead while every window is still live."""
|
||||
now = [1000.0]
|
||||
rl = granthi_link.RateLimiter(rules={"/v1/link": (1, 3600)},
|
||||
max_keys=5, clock=lambda: now[0])
|
||||
for i in range(5):
|
||||
self.assertTrue(rl.check("/v1/link", f"10.0.0.{i}")[0])
|
||||
victim_hits = list(rl._hits[("/v1/link", "10.0.0.0")])
|
||||
for i in range(100, 140): # identity flood
|
||||
allowed, retry = rl.check("/v1/link", f"10.0.1.{i}")
|
||||
self.assertFalse(allowed)
|
||||
self.assertGreater(retry, 0)
|
||||
# the earlier client's window survived the flood untouched
|
||||
self.assertEqual(rl._hits[("/v1/link", "10.0.0.0")], victim_hits)
|
||||
self.assertLessEqual(len(rl._hits), 5)
|
||||
|
||||
def test_capacity_recovers_once_windows_expire(self):
|
||||
now = [1000.0]
|
||||
rl = granthi_link.RateLimiter(rules={"/v1/link": (1, 60)},
|
||||
max_keys=3, clock=lambda: now[0])
|
||||
for i in range(3):
|
||||
rl.check("/v1/link", f"10.0.0.{i}")
|
||||
self.assertFalse(rl.check("/v1/link", "10.0.9.9")[0])
|
||||
now[0] += 61
|
||||
self.assertTrue(rl.check("/v1/link", "10.0.9.9")[0])
|
||||
|
||||
def test_hits_stay_chronological_under_thread_contention(self):
|
||||
"""retry_after uses hits[0] and reclamation uses v[-1]; both assume
|
||||
the list is ordered. Reading the clock outside the lock let racing
|
||||
threads append out of order."""
|
||||
rl = granthi_link.RateLimiter(rules={"/v1/link": (500, 3600)})
|
||||
ts = [threading.Thread(target=lambda: rl.check("/v1/link", "7.7.7.7"))
|
||||
for _ in range(200)]
|
||||
for t in ts:
|
||||
t.start()
|
||||
for t in ts:
|
||||
t.join()
|
||||
hits = rl._hits[("/v1/link", "7.7.7.7")]
|
||||
self.assertEqual(len(hits), 200)
|
||||
self.assertEqual(hits, sorted(hits), "timestamps out of order")
|
||||
|
||||
|
||||
class TestRateLimitConfigTypes(ServiceTestBase):
|
||||
def _svc(self, rl):
|
||||
cfg = dict(self.svc.cfg)
|
||||
cfg["rate_limit"] = rl
|
||||
return granthi_link.LinkService(cfg)
|
||||
|
||||
def test_non_boolean_enabled_refuses_startup(self):
|
||||
"""`"enabled": null` or `0` must not quietly mean unlimited."""
|
||||
for bad in (None, 0, "", "false", "no", []):
|
||||
with self.subTest(enabled=bad):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._svc({"enabled": bad})
|
||||
|
||||
def test_string_false_does_not_enable_forwarded_trust(self):
|
||||
"""Every non-empty string is truthy -- "false" used to mean True."""
|
||||
with self.assertRaises(SystemExit):
|
||||
self._svc({"trust_forwarded_for": "false",
|
||||
"trusted_proxies": ["10.0.0.0/8"]})
|
||||
|
||||
def test_rate_limit_must_be_an_object(self):
|
||||
for bad in ("yes", 5, ["/v1/link"]):
|
||||
with self.subTest(rl=bad):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._svc(bad)
|
||||
|
||||
def test_null_rate_limit_means_defaults_not_disabled(self):
|
||||
svc = self._svc(None)
|
||||
self.assertIsNotNone(svc.limiter)
|
||||
|
||||
def test_forwarded_trust_without_trusted_proxies_refuses_startup(self):
|
||||
"""Trusting the header from ANY peer lets callers choose their own
|
||||
rate-limit key -- that must not be reachable by omission."""
|
||||
with self.assertRaises(SystemExit):
|
||||
self._svc({"trust_forwarded_for": True})
|
||||
with self.assertRaises(SystemExit):
|
||||
self._svc({"trust_forwarded_for": True, "trusted_proxies": []})
|
||||
|
||||
def test_forwarded_trust_with_proxies_is_accepted(self):
|
||||
svc = self._svc({"trust_forwarded_for": True,
|
||||
"trusted_proxies": ["10.0.0.0/8", "127.0.0.1/32"]})
|
||||
self.assertTrue(svc.trust_forwarded_for)
|
||||
self.assertEqual(len(svc.trusted_proxies), 2)
|
||||
|
||||
Reference in New Issue
Block a user