Public /api/v1/relay websocket: no auth, no frame/subscription/rate limits #7

Open
opened 2026-10-09 16:47:33 +00:00 by padreug · 0 comments
Owner

views_api.py:116-140 (ws_relay) carries no auth dependency; with public_ws enabled (models.py:49, default false) any client reaches the multiplexer. Once connected, router.py:

  • :86 receive_text() with no frame size bound, then json.loads (:194);
  • :226-239 every REQ stores an entry in original_subscription_ids and pushes filters to all relays, with no cap on subscriptions per connection and no cap on connections (all_routers, views_api.py:140);
  • :209-224 every EVENT is fanned out raw to every configured relay via publish_message(json_str) with no validation beyond "has an id";
  • no rate limiting anywhere.

Each REQ also creates process-wide state in NostrRouter.received_subscription_events (tasks.py:41-42) that stop() does not reclaim (router.py:62-78), so a client can open many subscriptions, disconnect, and leave the memory behind.

Impact: an anonymous client can publish arbitrary events to all of the operator's relays through the operator's IP (spam/abuse attributed to us, relay bans), read through our connections, and exhaust memory. The missing limits also apply to the private endpoint.

Fix direction: keep public_ws off by default and document the exposure; add per-connection caps (max frame bytes, max open subscriptions, max concurrent connections, a message-rate limit) and reply NOTICE/CLOSED instead of growing state; validate EVENT payloads (well-formed, signed, bounded size) before fan-out; clean up per-connection buffers on stop() (see the ClassVar item below).

Found during reforge run #1 (sandbox nostrclient#3).

`views_api.py:116-140` (`ws_relay`) carries no auth dependency; with `public_ws` enabled (`models.py:49`, default false) any client reaches the multiplexer. Once connected, `router.py`: - `:86` `receive_text()` with no frame size bound, then `json.loads` (`:194`); - `:226-239` every `REQ` stores an entry in `original_subscription_ids` and pushes filters to all relays, with no cap on subscriptions per connection and no cap on connections (`all_routers`, `views_api.py:140`); - `:209-224` every `EVENT` is fanned out raw to every configured relay via `publish_message(json_str)` with no validation beyond "has an id"; - no rate limiting anywhere. Each `REQ` also creates process-wide state in `NostrRouter.received_subscription_events` (`tasks.py:41-42`) that `stop()` does not reclaim (`router.py:62-78`), so a client can open many subscriptions, disconnect, and leave the memory behind. Impact: an anonymous client can publish arbitrary events to all of the operator's relays through the operator's IP (spam/abuse attributed to us, relay bans), read through our connections, and exhaust memory. The missing limits also apply to the private endpoint. Fix direction: keep `public_ws` off by default and document the exposure; add per-connection caps (max frame bytes, max open subscriptions, max concurrent connections, a message-rate limit) and reply `NOTICE`/`CLOSED` instead of growing state; validate `EVENT` payloads (well-formed, signed, bounded size) before fan-out; clean up per-connection buffers on `stop()` (see the ClassVar item below). Found during reforge run #1 (sandbox nostrclient#3).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nostrclient#7
No description provided.