Files
hermes-relay/plugin/tests/test_auth_rate_limiter.py
Bailey DixonandClaude Opus 4.7 9633f4694d feat(auth): split pairing vs session auth rate-limit buckets
The previous single RateLimiter bucket (5-in-60s → 5 min block) was
too strict for legitimate users fumbling a 6-char pair code. Split
into two independent buckets:

* Pairing bucket: 10 failures / 60s → 2-minute block.
  Users misread QR codes, typo 0 for O, hit Enter on half-typed codes.
  The PairingManager's 10-minute TTL + single-use consumption + 36^6
  alphabet already bound brute-force risk, so we can afford the looser
  threshold here.
* Session bucket: 5 failures / 60s → 5-minute block (unchanged).
  A bad session bearer is either an attacker or a badly-broken client;
  stricter threshold is appropriate.

Shared block state: once either bucket bans an IP, is_blocked() returns
True for every subsequent auth attempt. Simpler reasoning — a ban is a
ban — and loopback pair routes (clear_all_blocks) still wipe the whole
table atomically.

New surface:
* RateLimitConfig dataclass.
* record_pairing_failure(ip) and record_session_failure(ip).
* pairing_config / session_config properties for introspection.

Back-compat preserved:
* record_failure(ip) kept as alias for record_session_failure (strict
  path — matches pre-split behavior for any call site we forgot to
  update).
* Legacy positional RateLimiter(max, window, block) constructor
  configures both buckets with the same values.
* _failures property merges both dicts so existing assertions in
  test_rate_limit_clear keep working.

server.py _authenticate now routes failures based on what the client
attempted:
* Only session_token sent → record_session_failure (reconnect attempt).
* Only pairing_code sent → record_pairing_failure (fresh pair).
* Both sent (unusual — cached token fallback to QR) → both buckets
  since both validations genuinely failed.
* Neither sent → record_session_failure (stricter, client sent
  nothing to validate).

Test suite gains 15 cases in plugin/tests/test_auth_rate_limiter.py:
defaults, bucket independence, shared-block semantics, record_success
clearing both dicts, clear_all_blocks clearing both dicts, and
back-compat for record_failure + positional constructor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:49:11 -04:00

215 lines
8.7 KiB
Python

"""Unit tests for :class:`plugin.relay.auth.RateLimiter`'s split buckets.
Covers the Commit-2 refactor: pairing-code failures and session-token
failures track independently, blocks are shared (any ban blocks every
bucket), and the backwards-compat ``record_failure`` alias still routes
to the session bucket.
"""
from __future__ import annotations
import unittest
from plugin.relay.auth import (
RateLimitConfig,
RateLimiter,
_PAIRING_BLOCK_SECONDS,
_PAIRING_MAX_ATTEMPTS,
_SESSION_BLOCK_SECONDS,
_SESSION_MAX_ATTEMPTS,
)
class RateLimiterSplitBucketDefaultsTests(unittest.TestCase):
"""Default construction — verify the library defaults match the
documented wire contract (pairing: 10-in-60s → 2 min, session:
5-in-60s → 5 min)."""
def test_default_pairing_config(self) -> None:
rl = RateLimiter()
self.assertEqual(rl.pairing_config.max_attempts, 10)
self.assertEqual(rl.pairing_config.window_seconds, 60)
self.assertEqual(rl.pairing_config.block_seconds, 120)
# Sanity — the constants we imported agree.
self.assertEqual(rl.pairing_config.max_attempts, _PAIRING_MAX_ATTEMPTS)
self.assertEqual(rl.pairing_config.block_seconds, _PAIRING_BLOCK_SECONDS)
def test_default_session_config(self) -> None:
rl = RateLimiter()
self.assertEqual(rl.session_config.max_attempts, 5)
self.assertEqual(rl.session_config.window_seconds, 60)
self.assertEqual(rl.session_config.block_seconds, 300)
self.assertEqual(rl.session_config.max_attempts, _SESSION_MAX_ATTEMPTS)
self.assertEqual(rl.session_config.block_seconds, _SESSION_BLOCK_SECONDS)
class RateLimiterBucketIndependenceTests(unittest.TestCase):
"""Each bucket's failure counter ticks independently. Failures
accrued in one bucket do not advance the other's counter."""
def test_pairing_bucket_does_not_advance_session(self) -> None:
rl = RateLimiter(
pairing_config=RateLimitConfig(
max_attempts=10, window_seconds=60, block_seconds=120
),
session_config=RateLimitConfig(
max_attempts=5, window_seconds=60, block_seconds=300
),
)
# Fire 4 pairing failures — below both thresholds.
for _ in range(4):
rl.record_pairing_failure("1.1.1.1")
self.assertFalse(rl.is_blocked("1.1.1.1"))
# Session bucket should still be empty for this IP.
self.assertNotIn("1.1.1.1", rl._session_failures)
self.assertEqual(len(rl._pairing_failures["1.1.1.1"]), 4)
def test_session_bucket_does_not_advance_pairing(self) -> None:
rl = RateLimiter()
for _ in range(4):
rl.record_session_failure("2.2.2.2")
self.assertFalse(rl.is_blocked("2.2.2.2"))
self.assertNotIn("2.2.2.2", rl._pairing_failures)
self.assertEqual(len(rl._session_failures["2.2.2.2"]), 4)
def test_pairing_bucket_needs_more_failures_to_trip(self) -> None:
"""With defaults (pairing=10, session=5), five pairing failures
should NOT ban the IP (session bucket untouched; pairing bucket
not yet at max)."""
rl = RateLimiter()
for _ in range(5):
rl.record_pairing_failure("3.3.3.3")
self.assertFalse(rl.is_blocked("3.3.3.3"))
def test_pairing_bucket_trips_at_its_max(self) -> None:
rl = RateLimiter()
for _ in range(_PAIRING_MAX_ATTEMPTS):
rl.record_pairing_failure("4.4.4.4")
self.assertTrue(rl.is_blocked("4.4.4.4"))
def test_session_bucket_trips_at_its_max(self) -> None:
rl = RateLimiter()
for _ in range(_SESSION_MAX_ATTEMPTS):
rl.record_session_failure("5.5.5.5")
self.assertTrue(rl.is_blocked("5.5.5.5"))
class RateLimiterSharedBlockStateTests(unittest.TestCase):
"""Either bucket banning an IP blocks every subsequent auth
attempt, regardless of which bucket the new attempt routes to."""
def test_session_block_blocks_pairing_attempt(self) -> None:
rl = RateLimiter()
for _ in range(_SESSION_MAX_ATTEMPTS):
rl.record_session_failure("6.6.6.6")
self.assertTrue(rl.is_blocked("6.6.6.6"))
# The IP is banned — doesn't matter that the pairing bucket is
# empty, the caller still sees is_blocked=True.
self.assertNotIn("6.6.6.6", rl._pairing_failures)
self.assertTrue(rl.is_blocked("6.6.6.6"))
def test_pairing_block_blocks_session_attempt(self) -> None:
rl = RateLimiter()
for _ in range(_PAIRING_MAX_ATTEMPTS):
rl.record_pairing_failure("7.7.7.7")
self.assertTrue(rl.is_blocked("7.7.7.7"))
self.assertNotIn("7.7.7.7", rl._session_failures)
self.assertTrue(rl.is_blocked("7.7.7.7"))
class RateLimiterRecordSuccessClearsBothBucketsTests(unittest.TestCase):
"""record_success must drop the IP from both bucket counters — a
successful auth means no hostile activity from this IP, regardless
of which path succeeded."""
def test_success_clears_both_buckets(self) -> None:
rl = RateLimiter()
rl.record_pairing_failure("8.8.8.8")
rl.record_session_failure("8.8.8.8")
self.assertIn("8.8.8.8", rl._pairing_failures)
self.assertIn("8.8.8.8", rl._session_failures)
rl.record_success("8.8.8.8")
self.assertNotIn("8.8.8.8", rl._pairing_failures)
self.assertNotIn("8.8.8.8", rl._session_failures)
class RateLimiterClearAllClearsBothBucketsTests(unittest.TestCase):
"""clear_all_blocks (called from loopback pairing endpoints) must
wipe block state AND both pending-failure dicts."""
def test_clear_all_drops_everything(self) -> None:
rl = RateLimiter()
rl.record_pairing_failure("9.9.9.9")
rl.record_session_failure("10.10.10.10")
for _ in range(_SESSION_MAX_ATTEMPTS):
rl.record_session_failure("11.11.11.11")
self.assertTrue(rl.is_blocked("11.11.11.11"))
rl.clear_all_blocks()
self.assertFalse(rl.is_blocked("11.11.11.11"))
self.assertEqual(rl._blocked, {})
self.assertEqual(rl._pairing_failures, {})
self.assertEqual(rl._session_failures, {})
class RateLimiterBackCompatTests(unittest.TestCase):
"""Legacy positional constructor + record_failure() alias must
keep working for any imports reaching into the private module."""
def test_legacy_positional_construct_mirrors_both_buckets(self) -> None:
rl = RateLimiter(max_attempts=3, window_seconds=60, block_seconds=300)
# Legacy attributes must reflect the passed value.
self.assertEqual(rl.max_attempts, 3)
self.assertEqual(rl.window_seconds, 60)
self.assertEqual(rl.block_seconds, 300)
# Both buckets should use the legacy value.
self.assertEqual(rl.pairing_config.max_attempts, 3)
self.assertEqual(rl.session_config.max_attempts, 3)
def test_record_failure_is_alias_for_session_bucket(self) -> None:
"""The back-compat alias must increment the session counter, not
the pairing counter (session is the strict/default bucket)."""
rl = RateLimiter()
rl.record_failure("12.12.12.12")
self.assertIn("12.12.12.12", rl._session_failures)
self.assertNotIn("12.12.12.12", rl._pairing_failures)
def test_legacy_failures_property_merges_both_buckets(self) -> None:
"""``_failures`` is introspected by older tests — verify the
alias presents a merged view across both split dicts."""
rl = RateLimiter()
rl.record_pairing_failure("13.13.13.13")
rl.record_session_failure("13.13.13.13")
merged = rl._failures
self.assertIn("13.13.13.13", merged)
self.assertEqual(len(merged["13.13.13.13"]), 2)
class RateLimiterBucketExhaustionTests(unittest.TestCase):
"""Once a bucket trips, subsequent failures in that bucket (while
blocked) don't re-add counts — the block clock is what the caller
must wait out."""
def test_blocked_ip_continues_to_show_blocked(self) -> None:
rl = RateLimiter(
pairing_config=RateLimitConfig(
max_attempts=2, window_seconds=60, block_seconds=120
),
session_config=RateLimitConfig(
max_attempts=2, window_seconds=60, block_seconds=120
),
)
rl.record_pairing_failure("14.14.14.14")
rl.record_pairing_failure("14.14.14.14")
self.assertTrue(rl.is_blocked("14.14.14.14"))
# Further failures don't unblock or crash.
rl.record_pairing_failure("14.14.14.14")
rl.record_session_failure("14.14.14.14")
self.assertTrue(rl.is_blocked("14.14.14.14"))
if __name__ == "__main__":
unittest.main()