commit f843314651da545a95ab5a19bda98c8a27d58d68
parent 99e9842b4bc4156c8017faa6b110840a33423d03
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 06:40:51 -0700
limits: enforce query complexity
- Add base relay query complexity scoring for REQ and COUNT validation paths.
- Derive runtime query complexity budget from the existing limit contract instead of adding a new production config key.
- Update runtime, session, benchmark, and integration helper limit construction.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.
Diffstat:
6 files changed, 113 insertions(+), 0 deletions(-)
diff --git a/crates/tangle_bench/src/lib.rs b/crates/tangle_bench/src/lib.rs
@@ -917,6 +917,7 @@ fn relay_limits(max_pending_events: usize) -> BaseRelayLimits {
max_subscriptions: 512,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,
diff --git a/crates/tangle_runtime/src/config.rs b/crates/tangle_runtime/src/config.rs
@@ -256,12 +256,20 @@ impl BaseRelayRuntimeLimitsConfig {
max_subscriptions: self.max_subscriptions_per_connection,
max_filters_per_request: self.max_filters_per_request,
max_tag_values_per_filter: self.max_tag_values_per_filter,
+ max_query_complexity: self.query_complexity_budget(),
max_event_tags: self.max_event_tags,
max_content_length: self.max_content_length,
max_limit: self.max_limit,
default_limit: self.default_limit,
})
}
+
+ fn query_complexity_budget(self) -> usize {
+ usize::try_from(self.max_limit)
+ .unwrap_or(usize::MAX)
+ .saturating_add(self.max_tag_values_per_filter)
+ .saturating_add(self.max_filters_per_request)
+ }
}
#[derive(Debug, Deserialize)]
diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs
@@ -79,6 +79,7 @@ pub struct BaseRelayLimits {
max_subscriptions: usize,
max_filters_per_request: usize,
max_tag_values_per_filter: usize,
+ max_query_complexity: usize,
max_event_tags: usize,
max_content_length: usize,
max_limit: u64,
@@ -92,6 +93,7 @@ pub struct BaseRelayLimitSettings {
pub max_subscriptions: usize,
pub max_filters_per_request: usize,
pub max_tag_values_per_filter: usize,
+ pub max_query_complexity: usize,
pub max_event_tags: usize,
pub max_content_length: usize,
pub max_limit: u64,
@@ -105,6 +107,7 @@ impl BaseRelayLimits {
let max_subscriptions = settings.max_subscriptions;
let max_filters_per_request = settings.max_filters_per_request;
let max_tag_values_per_filter = settings.max_tag_values_per_filter;
+ let max_query_complexity = settings.max_query_complexity;
let max_event_tags = settings.max_event_tags;
let max_content_length = settings.max_content_length;
let max_limit = settings.max_limit;
@@ -134,6 +137,11 @@ impl BaseRelayLimits {
"runtime max tag values per filter must be greater than zero",
));
}
+ if max_query_complexity == 0 {
+ return Err(BaseRelayError::invalid(
+ "runtime max query complexity must be greater than zero",
+ ));
+ }
if max_event_tags == 0 {
return Err(BaseRelayError::invalid(
"runtime max event tags must be greater than zero",
@@ -159,12 +167,18 @@ impl BaseRelayLimits {
"runtime default filter limit must not exceed max filter limit",
));
}
+ if usize::try_from(default_limit).is_ok_and(|limit| limit > max_query_complexity) {
+ return Err(BaseRelayError::invalid(
+ "runtime default filter limit must not exceed max query complexity",
+ ));
+ }
Ok(Self {
max_pending_events,
max_subscription_id_length,
max_subscriptions,
max_filters_per_request,
max_tag_values_per_filter,
+ max_query_complexity,
max_event_tags,
max_content_length,
max_limit,
@@ -192,6 +206,10 @@ impl BaseRelayLimits {
self.max_tag_values_per_filter
}
+ pub fn max_query_complexity(self) -> usize {
+ self.max_query_complexity
+ }
+
pub fn max_event_tags(self) -> usize {
self.max_event_tags
}
@@ -265,12 +283,44 @@ impl BaseRelayLimits {
)));
}
}
+ self.validate_query_complexity(filters)?;
Ok(())
}
fn effective_filter_limit(self, filter: &Filter) -> usize {
usize::try_from(filter.limit().unwrap_or(self.default_limit)).unwrap_or(usize::MAX)
}
+
+ fn validate_query_complexity(&self, filters: &[Filter]) -> Result<(), BaseRelayError> {
+ let score = filters
+ .iter()
+ .map(|filter| self.filter_complexity(filter))
+ .fold(0_usize, usize::saturating_add);
+ if score > self.max_query_complexity {
+ return Err(BaseRelayError::invalid(format!(
+ "query complexity {score} exceeds runtime max_query_complexity {}",
+ self.max_query_complexity
+ )));
+ }
+ Ok(())
+ }
+
+ fn filter_complexity(&self, filter: &Filter) -> usize {
+ let tag_score = filter
+ .tag_filters()
+ .values()
+ .map(|values| 1_usize.saturating_add(values.len()))
+ .fold(0_usize, usize::saturating_add);
+ 1_usize
+ .saturating_add(filter.ids().len())
+ .saturating_add(filter.authors().len())
+ .saturating_add(filter.kinds().len())
+ .saturating_add(tag_score)
+ .saturating_add(usize::from(filter.since().is_some()))
+ .saturating_add(usize::from(filter.until().is_some()))
+ .saturating_add(filter.search().map(str::len).unwrap_or(0))
+ .saturating_add(self.effective_filter_limit(filter))
+ }
}
impl BaseRelay {
@@ -932,6 +982,7 @@ mod tests {
max_subscriptions: 1,
max_filters_per_request: 1,
max_tag_values_per_filter: 1,
+ max_query_complexity: 4,
max_event_tags: 1,
max_content_length: 4,
max_limit: 2,
@@ -1033,6 +1084,53 @@ mod tests {
}
#[test]
+ fn base_relay_rejects_over_budget_req_and_count() {
+ let config = test_store_config("base-relay-query-complexity");
+ let mut relay = BaseRelay::open(
+ &config,
+ BaseRelayLimits::new(BaseRelayLimitSettings {
+ max_pending_events: 4,
+ max_subscription_id_length: 64,
+ max_subscriptions: 64,
+ max_filters_per_request: 10,
+ max_tag_values_per_filter: 10,
+ max_query_complexity: 4,
+ max_event_tags: 200,
+ max_content_length: 65_536,
+ max_limit: 10,
+ default_limit: 1,
+ })
+ .expect("limits"),
+ )
+ .expect("relay");
+ let complex = filter_from_value(&serde_json::json!({
+ "kinds": [1],
+ "#t": ["market"],
+ "limit": 2
+ }))
+ .expect("filter");
+
+ assert!(
+ relay
+ .handle_req(
+ SubscriptionId::new("req").expect("sub"),
+ vec![complex.clone()]
+ )
+ .expect_err("req complexity")
+ .prefixed_message()
+ .contains("max_query_complexity 4")
+ );
+ assert_eq!(relay.active_subscription_count(), 0);
+ assert!(
+ relay
+ .handle_count(SubscriptionId::new("cnt").expect("sub"), vec![complex])
+ .expect_err("count complexity")
+ .prefixed_message()
+ .contains("max_query_complexity 4")
+ );
+ }
+
+ #[test]
fn base_relay_count_dedupes_overlapping_visible_filters() {
let mut relay = test_relay("base-relay-count-dedupe", 8);
let market_tag = Tag::from_parts("t", &["market"]).expect("tag");
@@ -2121,6 +2219,7 @@ mod tests {
max_subscriptions: 64,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,
@@ -2136,6 +2235,7 @@ mod tests {
max_subscriptions: 1,
max_filters_per_request: 1,
max_tag_values_per_filter: 1,
+ max_query_complexity: 4,
max_event_tags: 1,
max_content_length: 4,
max_limit: 2,
diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs
@@ -638,6 +638,7 @@ mod tests {
max_subscriptions: 64,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,
diff --git a/crates/tangle_runtime/src/session.rs b/crates/tangle_runtime/src/session.rs
@@ -621,6 +621,7 @@ mod tests {
max_subscriptions: 64,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,
@@ -644,6 +645,7 @@ mod tests {
max_subscriptions: 64,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,
diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs
@@ -1788,6 +1788,7 @@ fn relay_limits(max_pending_events: usize) -> BaseRelayLimits {
max_subscriptions: 64,
max_filters_per_request: 10,
max_tag_values_per_filter: 100,
+ max_query_complexity: 610,
max_event_tags: 200,
max_content_length: 65_536,
max_limit: 500,