Summary
The HTTP bridge's POST /query and POST /count parse a NIP-01 filter list from the request body with no cap on the number of filters, while the WebSocket door enforces MAX_FILTERS_PER_REQ = 10 — the exact max_filters the relay advertises in NIP-11. Each filter becomes an independent DB query (or, for /count, an unbounded aggregate scan), so a single ≤1 MB request expands into hundreds of thousands of queries against the shared Postgres pool. One authenticated request per rate-limit tick is enough to monopolise the database.
Found on main @ 2ea9385.
The asymmetry
WebSocket REQ/COUNT reject over-long lists (crates/buzz-relay/src/protocol.rs:93, :131):
if filter_values.len() > MAX_FILTERS_PER_REQ { // = 10
return Err(RelayError::InvalidMessage(format!(
"REQ contains {} filters, maximum is {MAX_FILTERS_PER_REQ}", …)));
}
The HTTP bridge, which reaches the same query_events / count_events machinery, has no such check:
crates/buzz-relay/src/api/bridge.rs:974 (/query): serde_json::from_slice(body) → Vec<Value> with no length guard.
crates/buzz-relay/src/api/bridge.rs:1413 (/count): same.
NIP-11 advertises max_filters: Some(10) (crates/buzz-relay/src/nip11.rs:110), so the bridge violates the relay's own advertised limit.
Reproduction
- Generate a throwaway keypair.
require_relay_membership defaults to false, and the open-relay path admits any authenticated key, so no registration is needed.
- Build a ~1 MB body of empty filters:
[{},{},{}, …] — each empty filter is ~3 bytes, so ~349,000 fit under the 1 MB RequestBodyLimitLayer.
- Sign a NIP-98
kind:27235 event for POST <relay>/query and send it.
enforce_http_admission counts this as one API call against human_api_calls_per_min (default 300), so ~300 such requests/key/minute are allowed — and keys are free.
/query phase 1 builds ~349k EventQuery values; phase 2 runs them .buffered(FILTER_QUERY_CONCURRENCY) — bounded concurrency but unbounded total work — monopolising the pool; results are accumulated with no cross-filter dedupe. /count is cheaper still per filter: each fully-pushable filter runs an unbounded count_events aggregate, ~349k of them.
Impact
- MEDIUM DoS: a single ≤1 MB authenticated request drives ~10⁵–10⁶ DB operations, starving the shared pool and degrading the relay for the whole community. Amplification factor ~10⁵ per request; ~300 requests/key/min permitted.
- Cross-tenant blast radius is bounded (queries are community-scoped) but a single community's relay can be wedged by any of its authenticated participants.
Suggested fix
Apply the same cap the WS door defines, at both bridge parse sites — the relay already advertises it. PR attached.
(Originally surfaced during a security read of the relay's HTTP surface; the WS/bridge parity gap is the one reachable amplification I could confirm there.)
Summary
The HTTP bridge's
POST /queryandPOST /countparse a NIP-01 filter list from the request body with no cap on the number of filters, while the WebSocket door enforcesMAX_FILTERS_PER_REQ = 10— the exactmax_filtersthe relay advertises in NIP-11. Each filter becomes an independent DB query (or, for/count, an unbounded aggregate scan), so a single ≤1 MB request expands into hundreds of thousands of queries against the shared Postgres pool. One authenticated request per rate-limit tick is enough to monopolise the database.Found on
main@2ea9385.The asymmetry
WebSocket REQ/COUNT reject over-long lists (
crates/buzz-relay/src/protocol.rs:93,:131):The HTTP bridge, which reaches the same
query_events/count_eventsmachinery, has no such check:crates/buzz-relay/src/api/bridge.rs:974(/query):serde_json::from_slice(body)→Vec<Value>with no length guard.crates/buzz-relay/src/api/bridge.rs:1413(/count): same.NIP-11 advertises
max_filters: Some(10)(crates/buzz-relay/src/nip11.rs:110), so the bridge violates the relay's own advertised limit.Reproduction
require_relay_membershipdefaults tofalse, and the open-relay path admits any authenticated key, so no registration is needed.[{},{},{}, …]— each empty filter is ~3 bytes, so ~349,000 fit under the 1 MBRequestBodyLimitLayer.kind:27235event forPOST <relay>/queryand send it.enforce_http_admissioncounts this as one API call againsthuman_api_calls_per_min(default 300), so ~300 such requests/key/minute are allowed — and keys are free./queryphase 1 builds ~349kEventQueryvalues; phase 2 runs them.buffered(FILTER_QUERY_CONCURRENCY)— bounded concurrency but unbounded total work — monopolising the pool; results are accumulated with no cross-filter dedupe./countis cheaper still per filter: each fully-pushable filter runs an unboundedcount_eventsaggregate, ~349k of them.Impact
Suggested fix
Apply the same cap the WS door defines, at both bridge parse sites — the relay already advertises it. PR attached.
(Originally surfaced during a security read of the relay's HTTP surface; the WS/bridge parity gap is the one reachable amplification I could confirm there.)