Skip to content

relay: HTTP bridge /query and /count don't cap filter count — filter-amplification DoS (WS door does) #4985

Description

@shani-singh1

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

  1. Generate a throwaway keypair. require_relay_membership defaults to false, and the open-relay path admits any authenticated key, so no registration is needed.
  2. Build a ~1 MB body of empty filters: [{},{},{}, …] — each empty filter is ~3 bytes, so ~349,000 fit under the 1 MB RequestBodyLimitLayer.
  3. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions