File size: 11,156 Bytes
9f8362f
 
 
 
392b662
 
9f8362f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392b662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""Unit tests for src/api/middleware.py: RateLimiter token-bucket behavior in isolation from the live app."""

from __future__ import annotations

import json

import pytest

from src.api.middleware import RateLimiter, new_request_id


class TestNewRequestId:
    def test_returns_unique_hex_strings(self):
        ids = {new_request_id() for _ in range(100)}
        assert len(ids) == 100
        assert all(len(i) == 32 for i in ids)  # uuid4().hex


class TestRateLimiter:
    def test_allows_up_to_capacity(self):
        limiter = RateLimiter(rpm=5)
        results = [limiter.allow("1.2.3.4") for _ in range(5)]
        assert all(results)

    def test_rejects_beyond_capacity_within_the_same_instant(self):
        limiter = RateLimiter(rpm=5)
        for _ in range(5):
            assert limiter.allow("1.2.3.4")
        assert limiter.allow("1.2.3.4") is False

    def test_buckets_are_independent_per_client(self):
        limiter = RateLimiter(rpm=1)
        assert limiter.allow("1.2.3.4") is True
        assert limiter.allow("1.2.3.4") is False
        # A different IP has its own untouched bucket.
        assert limiter.allow("5.6.7.8") is True

    def test_refills_over_time(self, monkeypatch):
        import src.api.middleware as mw

        t = [1000.0]
        monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])

        limiter = RateLimiter(rpm=60)  # 1 token/sec
        for _ in range(60):
            assert limiter.allow("1.2.3.4")
        assert limiter.allow("1.2.3.4") is False

        t[0] += 5.0  # 5 seconds later -> 5 tokens refilled
        allowed = [limiter.allow("1.2.3.4") for _ in range(5)]
        assert all(allowed)
        assert limiter.allow("1.2.3.4") is False

    def test_distinct_clients_are_capped_not_retained_forever(self):
        # Without a bound, a client that can vary its apparent source IP (trivial over IPv6) has an unbounded memory-exhaustion vector: one dict entry per distinct IP ever seen, forever.
        limiter = RateLimiter(rpm=5, max_clients=10)
        for i in range(1000):
            limiter.allow(f"10.0.0.{i}")
        assert len(limiter._buckets) == 10

    def test_least_recently_seen_client_is_evicted_first(self):
        limiter = RateLimiter(rpm=5, max_clients=2)
        limiter.allow("a")
        limiter.allow("b")
        limiter.allow("a")  # touches "a" again, so "b" is now the least-recently-seen
        limiter.allow("c")  # forces an eviction
        assert set(limiter._buckets) == {"a", "c"}

    def test_partially_drained_bucket_is_retained_across_calls(self):
        limiter = RateLimiter(rpm=5)
        limiter.allow("1.2.3.4")
        limiter.allow("1.2.3.4")
        assert "1.2.3.4" in limiter._buckets
        tokens, _ = limiter._buckets["1.2.3.4"]
        assert tokens == pytest.approx(3.0)


class TestRateLimiterPersistence:
    """state_path=None (every test above) is unaffected by any of this — persistence is opt-in, needed because supervisord's autorestart silently resets rate-limit state on every crash-restart."""

    def test_no_state_path_means_no_persistence(self, tmp_path):
        limiter = RateLimiter(rpm=5)
        limiter.allow("1.2.3.4")
        assert list(tmp_path.iterdir()) == []

    def test_persists_state_and_reloads_across_instances(self, tmp_path, monkeypatch):
        import src.api.middleware as mw

        t = [1000.0]
        monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])

        state_path = tmp_path / "state.json"
        limiter1 = RateLimiter(rpm=5, state_path=state_path)
        assert limiter1.allow("1.2.3.4")
        t[0] += 2.0  # past the persist-throttle interval, so each call below actually hits disk
        assert limiter1.allow("1.2.3.4")
        t[0] += 2.0
        assert limiter1.allow("1.2.3.4")

        # A fresh instance sharing state_path should see the drained bucket (2 of 5 tokens left), not the full 5 a reset-on-restart would give it.
        limiter2 = RateLimiter(rpm=5, state_path=state_path)
        assert limiter2.allow("1.2.3.4")
        assert limiter2.allow("1.2.3.4")
        assert limiter2.allow("1.2.3.4") is False

    def test_persistence_is_throttled_within_the_interval(self, tmp_path, monkeypatch):
        import src.api.middleware as mw

        t = [1000.0]
        monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])

        state_path = tmp_path / "state.json"
        limiter = RateLimiter(rpm=5, state_path=state_path)
        limiter.allow("1.2.3.4")
        first_write = state_path.read_text()

        limiter.allow("1.2.3.4")  # same instant — still inside the throttle window
        assert state_path.read_text() == first_write

        t[0] += 2.0
        limiter.allow("1.2.3.4")
        assert state_path.read_text() != first_write

    def test_rejection_forces_a_persist_sooner_than_the_normal_throttle_window(self, tmp_path, monkeypatch):
        # Verified against a real burst against a live uvicorn process first: a rapid burst can exhaust a bucket in-memory well before the next throttled write reaches disk, so a crash right after would recover a stale, under-depleted snapshot and hand out free requests. A rejection reaches disk within _FORCE_PERSIST_INTERVAL_S, well inside the normal 1s window — but still throttled, not unconditional (see the bounded-write-rate test below for why: cost scales with total tracked clients, not just the rejected one).
        import src.api.middleware as mw

        t = [1000.0]
        monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])

        state_path = tmp_path / "state.json"
        limiter = RateLimiter(rpm=1, state_path=state_path)
        assert limiter.allow("1.2.3.4") is True  # first-ever persist, unconditional
        first_write = state_path.read_text()

        t[0] += 0.5  # past the force interval, still well inside the normal 1s throttle window
        assert limiter.allow("1.2.3.4") is False  # rejected — must still reach disk well before the normal window
        assert state_path.read_text() != first_write

    def test_rapid_repeated_rejections_do_not_each_force_a_write(self, tmp_path, monkeypatch):
        # A client retrying immediately after a 429 with no backoff — malicious or just buggy — must not force a full-dict write on every single request: write cost scales with the *total* tracked-client count (every persist serializes the whole dict), not just the rejected client. Measured against a real RateLimiter before this fix: ~2.5ms/request at 5,000 tracked clients, ~66ms/request at the 100k cap, run synchronously on the single event-loop thread (allow() is called with no `await` before it in `async def observability`) — an unthrottled force write per rejection stalls every other visitor too.
        import src.api.middleware as mw

        t = [1000.0]
        monkeypatch.setattr(mw.time, "monotonic", lambda: t[0])

        state_path = tmp_path / "state.json"
        limiter = RateLimiter(rpm=1, state_path=state_path)
        limiter.allow("1.2.3.4")  # consumes the only token

        write_calls = []
        real_replace = mw.Path.replace
        def counting_replace(self, target):
            write_calls.append(1)
            return real_replace(self, target)
        monkeypatch.setattr(mw.Path, "replace", counting_replace)

        for _ in range(500):
            assert limiter.allow("1.2.3.4") is False  # every call rejected, same instant — no time advances

        assert len(write_calls) <= 1  # at most the throttle allows within a single instant, never one per rejection

    def test_missing_state_file_starts_fresh(self, tmp_path):
        limiter = RateLimiter(rpm=5, state_path=tmp_path / "does_not_exist.json")
        assert limiter._buckets == {}

    def test_corrupted_state_file_starts_fresh_instead_of_raising(self, tmp_path):
        state_path = tmp_path / "state.json"
        state_path.write_text("not valid json{{{")
        limiter = RateLimiter(rpm=5, state_path=state_path)
        assert limiter._buckets == {}

    def test_refuses_to_persist_through_a_symlink(self, tmp_path):
        # `not target.exists()` alone is a false-confidence assertion here: the write is a temp-file + rename(), which POSIX guarantees never dereferences a symlink at the destination — target would stay untouched even with the is_symlink() guard deleted entirely (verified by mutation). What the guard actually prevents is silently clobbering state_path's own symlink into a plain file; that's the assertion that actually distinguishes guarded from unguarded behavior.
        target = tmp_path / "real_target.json"
        state_path = tmp_path / "state.json"
        state_path.symlink_to(target)

        limiter = RateLimiter(rpm=5, state_path=state_path)
        limiter.allow("1.2.3.4")

        assert state_path.is_symlink()
        assert not target.exists()

    def test_refuses_to_load_through_a_symlink(self, tmp_path):
        target = tmp_path / "real_target.json"
        target.write_text('{"1.2.3.4": [0.0, 1000.0]}')
        state_path = tmp_path / "state.json"
        state_path.symlink_to(target)

        limiter = RateLimiter(rpm=5, state_path=state_path)

        assert limiter._buckets == {}

    def test_oversized_state_file_starts_fresh_instead_of_loading(self, tmp_path):
        # Content must be valid JSON, not just large bytes: a giant invalid-JSON blob (e.g. "x" * N) gets rejected by the parse-error path regardless of the size guard, so that version of this test passed even with the size check deleted entirely (found by mutation). Building real, valid, oversized JSON is the only way to prove the size guard itself is load-bearing.
        import src.api.middleware as mw

        entries = {str(i): [1.0, 2.0] for i in range(600_000)}
        content = json.dumps(entries)
        assert len(content) > mw._MAX_STATE_BYTES

        state_path = tmp_path / "state.json"
        state_path.write_text(content)

        limiter = RateLimiter(rpm=5, state_path=state_path)

        assert limiter._buckets == {}

    @pytest.mark.parametrize(
        "content",
        [
            "[1, 2, 3]",  # valid JSON, but a list, not an object
            '{"1.2.3.4": "oops"}',  # value isn't a [tokens, last] pair at all
            '{"1.2.3.4": ["not-a-number", "also-not"]}',  # pair present, values aren't numeric
            '{"1.2.3.4": [1.0]}',  # pair present but wrong length
        ],
    )
    def test_malformed_but_valid_json_state_file_starts_fresh_instead_of_crashing(self, tmp_path, content):
        # Demonstrated exploitable before this guard: each of these crashed __init__ with an unhandled AttributeError/ValueError/TypeError. Because `_rate_limiter = RateLimiter(state_path=...)` runs at src/api/main.py's module import time, that crash takes down the whole app on every single startup — turning a supervisord crash-restart into a permanent crash loop instead of recovering it, the opposite of what persistence was built for.
        state_path = tmp_path / "state.json"
        state_path.write_text(content)

        limiter = RateLimiter(rpm=5, state_path=state_path)

        assert limiter._buckets == {}