block / block/buzz

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

Open
#4,985 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.