relay: HTTP bridge /query and /count don't cap filter count — filter-amplification DoS (WS door does)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## 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`):
```rust
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` 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 /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.)*
Contributor guide
Assessment
This issue has not been assessed yet.