aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:14 +0200
commit4fbd2e5c5e21ab0d26bb16245dd656557f87765b (patch)
tree5465fea1b58c17ca0e47dd719c464b733e4d3276 /packages/meshbay-hub
parentd8357d864e323c2f7febce3b625478e0de21327b (diff)
downloadmeshbay-4fbd2e5c5e21ab0d26bb16245dd656557f87765b.tar.gz
fix(hub): rate-limit per client, not per proxy
Behind Caddy every request's TCP peer is loopback, and the limiter was keyed on that peer (slowapi's get_remote_address), so each limit was one bucket for the whole internet: ten node sign-ins a minute shared by every node. A node starting while others signed in got 429 and sat in waiting_for_hub, which the desktop app took for no node at all. Key it on client_ip, which already resolves X-Forwarded-For from a trusted proxy and was written for this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/middleware.py10
-rw-r--r--packages/meshbay-hub/tests/test_rate_limit_key.py48
2 files changed, 55 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
index bed7b54..3a2a5c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/middleware.py
@@ -7,7 +7,11 @@ to mitigate credential stuffing and registration floods.
"""
from slowapi import Limiter
-from slowapi.util import get_remote_address
-# Rate limiter instance — mounted on the FastAPI app in app.py
-limiter = Limiter(key_func=get_remote_address)
+from meshbay_hub.api.netutil import client_ip
+
+# Rate limiter instance — mounted on the FastAPI app in app.py.
+# Keyed on client_ip, not slowapi's get_remote_address: behind Caddy every
+# request's peer is loopback, so the peer address put the whole internet in one
+# bucket — ten node sign-ins a minute, shared by every node there is.
+limiter = Limiter(key_func=client_ip)
diff --git a/packages/meshbay-hub/tests/test_rate_limit_key.py b/packages/meshbay-hub/tests/test_rate_limit_key.py
new file mode 100644
index 0000000..679f546
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_rate_limit_key.py
@@ -0,0 +1,48 @@
+"""
+Rate limits are per client, not per proxy.
+
+meshbay.org's hub sits behind Caddy on the same host, so every request's TCP peer
+is loopback. The limiter was keyed on that peer (slowapi's get_remote_address)
+while `client_ip` -- written "for the audit log and rate limiting" -- went
+unused by it, so each limit was one bucket for the whole internet: ten node
+sign-ins a minute shared by every node. A node that started while others signed
+in got 429, sat in `waiting_for_hub`, and the desktop app took that for no node
+at all. Every other test runs with the limiter disabled, which is how it hid.
+"""
+
+import pytest
+from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
+
+
+@pytest.fixture
+def limits_on():
+ limiter.reset()
+ limiter.enabled = True
+ yield
+ limiter.enabled = False
+ limiter.reset()
+
+
+def _auth(client, ip):
+ # The ASGI test transport's peer is 127.0.0.1 -- a trusted proxy, as Caddy is.
+ return client.post("/v1/nodes/auth",
+ json={"username": "nobody", "timestamp": 0, "signature": "AAAA"},
+ headers={"X-Forwarded-For": ip})
+
+
+async def test_one_client_is_limited(client, limits_on):
+ for _ in range(10):
+ assert (await _auth(client, "203.0.113.1")).status_code != 429
+ assert (await _auth(client, "203.0.113.1")).status_code == 429
+
+
+async def test_another_client_behind_the_same_proxy_is_not(client, limits_on):
+ for _ in range(11):
+ await _auth(client, "203.0.113.1")
+ assert (await _auth(client, "203.0.113.2")).status_code != 429, (
+ "a second client behind the proxy shared the first one's bucket")
+
+
+def test_the_limiter_resolves_clients_the_way_the_audit_log_does():
+ assert limiter._key_func is client_ip