Skip to content

fix(relay): saturate filter limits instead of casting - #9230

Draft
ThePumpingLemma wants to merge 1 commit into
mainfrom
dgrochowski/clamped-filter-limit
Draft

ThePumpingLemma wants to merge 1 commit into
mainfrom
dgrochowski/clamped-filter-limit

Conversation

@ThePumpingLemma

@ThePumpingLemma ThePumpingLemma commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Four request paths converted a client-supplied usize filter limit with an inline as cast before clamping: (l as i64).min(max) (REQ query params, bridge feed) and (l as u32).min(max) (WS NIP-50 search, bridge channel window).

  • A limit above i64::MAX turns negative (usize::MAX as i64 == -1). On REQ and the bridge /query general path it reaches Postgres as LIMIT -1, which fails the query. On the bridge feed it trips feed_count >= limit, so the feed comes back empty.
  • A limit that is a multiple of 2^32 wraps to 0 through as u32. That returns no rows (one row in the window, via .max(1)) instead of the page max.

Bridge thread and HTTP search already clamped before casting. COUNT drops the row limit, so it was not affected.

Change

  • Add buzz_core::filter::clamp_limit(limit, default, max) -> u32. It returns default when the limit is absent; otherwise it saturates the request to u32 and then caps it at max.
  • Use it at every filter-limit site: filter_to_query_params and WS search in handlers/req.rs; channel window, feed, thread and HTTP search in api/bridge.rs. The inline casts are deleted, and BRIDGE_FEED_MAX_LIMIT is now u32.
  • Each site keeps its default, max and limit: 0 behaviour. In-range limits are unchanged.

Testing

Failing before the fix (cargo test -p buzz-relay --lib huge_filter_limit):

assertion `left == right` failed
  left: Some(-1)
 right: Some(1000)
test result: FAILED. 0 passed; 1 failed

After:

  • cargo test -p buzz-core --lib clamp_limit → 1 passed (table test covering absent, 0, in-range, at/over max, the u32 boundary and usize::MAX)
  • cargo test -p buzz-relay --lib -- huge_filter_limit req:: bridge → 152 passed, 0 failed (135 Postgres tests ignored)
  • cargo clippy -p buzz-core -p buzz-relay --all-targets -- -D warnings → clean; cargo fmt --all → clean
  • pre-push hooks (incl. rust-tests) passed

Not covered

No Postgres-backed tests were run locally. The bridge call sites rely on the shared helper's table test; they have no per-handler tests.

Review

Every server-side read of Filter.limit now goes through clamp_limit. The per-site default, max and limit: 0 behaviour is unchanged. No inline casts are left. Scores: minimalism 9, elegance 9, correctness 9.

Blockers: none.

Fixed in review:

  • PAGE_LIMIT_MAX had been inserted between filter_to_query_params's doc comment and the function, so the doc comment ended up on the const. Moved the const above it.
  • Corrected the problem statement. The bridge feed returned an empty feed rather than a Postgres error, COUNT was never affected, and thread and HTTP search were already safe.

Optional:

  • WS search, bridge window and bridge feed have no per-handler regression test. Reverting one of them to an inline cast would not fail any test.
  • bridge.rs now has two functions called clamp_limit: the local moderation clamp_limit(Option<i64>) and buzz_core::filter::clamp_limit. They don't collide, but they are easy to confuse.

Exercised on a local relay

Commit tested: 88d76f7f1. Dev profile, isolated harness relay on :3030, authenticated channel member. Human rate limits were raised so seeding wasn't throttled. I seeded 1,100 kind-9 events in one channel. The filters were sent as raw JSON so the large integers arrived exactly.

Path limit 18446744073709551615 limit 4294967296 limit 50 (control)
WS REQ EOSE after 1000 EOSE after 1000 EOSE after 50
WS REQ with search EOSE after 1000 EOSE after 1000 EOSE after 50
HTTP /query 200, 1000 events 200, 1000 200, 50
HTTP /query with search 200, 500 200, 500 200, 50
HTTP /query top_level (channel window) 200, 201 (200 + bounds event) 200, 201 200, 51
HTTP /query feed_types 200, 100 200, 100 200, 50

Every huge limit came back as that path's maximum. None of them produced a database error, and none came back empty. The relay log has no error lines, and the access log records the raw limit as 18446744073709551615 with outcome:"served".

Postgres-gated tests: the PR adds or modifies none. As a related check I ran cargo test -p buzz-relay --lib -- --ignored limit. Run in parallel, it gave 10 passed and 1 failed: rate_limited_query_logs_one_denied_line. That test passes on its own, and the whole set gives 11 passed with --test-threads=1. It looks like interference between tests that share the access-log capture, not a problem with this change.

Not exercised:

  • Bridge thread path.
  • A before/after comparison against main.

Problem: four request paths turned a client-supplied `usize` filter limit
into the query limit with an inline `as` cast before clamping:
`(l as i64).min(max)` (REQ query params, bridge feed) and
`(l as u32).min(max)` (WS NIP-50 search, bridge channel window). A limit
above i64::MAX becomes negative (`usize::MAX as i64 == -1`): on REQ it
reaches Postgres as `LIMIT -1` and fails the query with a database error;
on the bridge feed it trips `feed_count >= limit` and returns an empty
feed. A limit that is a multiple of 2^32 wraps to 0 through `as u32`,
silently returning no rows (or, in the window, one row via `.max(1)`)
instead of the page max. Bridge thread and HTTP search already clamped
before casting.

Change: add `buzz_core::filter::clamp_limit(limit, default, max) -> u32`,
which saturates the request to u32 before applying the per-site max, and
use it at every filter-limit site (REQ/COUNT query params, WS search,
bridge window, feed, thread, HTTP search). The inline casts are deleted.
`BRIDGE_FEED_MAX_LIMIT` becomes u32 to match.

Why safe: each site keeps its default, max, and `limit: 0` behaviour
(Some(0) still yields 0; the window still applies `.max(1)`); in-range
limits produce the same values as before. COUNT drops the row limit
before querying, so its result is unchanged.

Tests: `clamp_limit_table` covers absent, zero, in-range, at-max,
over-max, u32 boundary and usize::MAX. `huge_filter_limit_is_clamped_to_page_max`
drives `filter_to_query_params` with `usize::MAX`; before the fix it
yielded `Some(-1)` instead of `Some(1000)`.

Signed-off-by: David Grochowski <dgrochowski@squareup.com>
@ThePumpingLemma
ThePumpingLemma force-pushed the dgrochowski/clamped-filter-limit branch from 8043281 to 88d76f7 Compare November 7, 2026 04:55

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant