commit 75e1483797adb413d2f815cebeeb790f2a77fe84
parent 33456558a86995abb9775bed8ef639e8b6659c69
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 07:57:40 +0000
transport: bound relay fetch controls
- derive relay fetch limits from shared transport authority
- cap relay targets filters compact JSON and total deadlines
- reject oversized controls before unbounded collection growth
- prove exact limits and feature profiles without relay I/O
Diffstat:
5 files changed, 253 insertions(+), 14 deletions(-)
diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml
@@ -52,7 +52,7 @@ radroots_outbox = { workspace = true, optional = true, default-features = false,
"sqlite",
"runtime-tokio",
] }
-radroots_transport = { workspace = true, default-features = false }
+radroots_transport = { workspace = true, default-features = false, features = ["serde"] }
futures = { workspace = true }
hex = { workspace = true }
nostr = { workspace = true }
@@ -69,5 +69,15 @@ radroots_event_codec = { workspace = true, features = ["serde_json"] }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
+[[test]]
+name = "phase1_outbox_publication"
+path = "tests/phase1_outbox_publication.rs"
+required-features = ["runtime-tokio"]
+
+[[test]]
+name = "transport"
+path = "tests/transport.rs"
+required-features = ["runtime-tokio"]
+
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs
@@ -7,12 +7,17 @@ use futures::{StreamExt, future::BoxFuture};
use nostr::{JsonUtil, filter::MatchEventOptions};
use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES;
use radroots_event_store::{
- RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventAdmissionStatus, RadrootsEventIngest,
- RadrootsEventPersistence, RadrootsEventStore, RadrootsEventVisibility,
- RadrootsTransportObservation, RadrootsTransportObservationType,
+ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventPersistence,
+ RadrootsEventStore, RadrootsEventVisibility, RadrootsTransportObservation,
+ RadrootsTransportObservationType,
};
use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter};
-use radroots_transport::{RadrootsTransportKind, RadrootsTransportTarget};
+use radroots_transport::{
+ RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES,
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES,
+ RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES,
+ RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS, RadrootsTransportKind, RadrootsTransportTarget,
+};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, PoisonError};
@@ -20,10 +25,17 @@ use std::sync::{Arc, Mutex, PoisonError};
const DEFAULT_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000;
const DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER: usize = 64;
pub const RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX: usize =
- RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize;
+ RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT;
pub const RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX: usize =
- RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX * DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER;
-pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize = 64 * 1024 * 1024;
+ RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT;
+pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize =
+ RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES;
+pub const RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX: usize = RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT;
+pub const RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX: usize =
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES;
+pub const RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX: usize =
+ RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES;
+pub const RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX: u64 = RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RadrootsRelayFetchMode {
@@ -41,7 +53,41 @@ impl RadrootsRelayFetchFilters {
where
I: IntoIterator<Item = RadrootsNostrFilter>,
{
- let filters = filters.into_iter().collect::<Vec<_>>();
+ let mut bounded_filters = Vec::new();
+ let mut aggregate_json_bytes = 0usize;
+ for filter in filters {
+ if bounded_filters.len() == RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_count",
+ max: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX,
+ actual: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1,
+ });
+ }
+ let filter_json_bytes = filter.as_json().len();
+ if filter_json_bytes > RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_json_bytes",
+ max: RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX,
+ actual: filter_json_bytes,
+ });
+ }
+ aggregate_json_bytes = aggregate_json_bytes.checked_add(filter_json_bytes).ok_or(
+ RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_set_json_bytes",
+ max: RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX,
+ actual: usize::MAX,
+ },
+ )?;
+ if aggregate_json_bytes > RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_set_json_bytes",
+ max: RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX,
+ actual: aggregate_json_bytes,
+ });
+ }
+ bounded_filters.push(filter);
+ }
+ let filters = bounded_filters;
if filters.is_empty() {
return Err(RadrootsRelayTransportError::EmptyFetchFilters);
}
@@ -244,6 +290,13 @@ fn ensure_positive_timeout(
if value == 0 {
return Err(RadrootsRelayTransportError::InvalidFetchLimit { field });
}
+ if value > RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field,
+ max: RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize,
+ actual: usize::try_from(value).unwrap_or(usize::MAX),
+ });
+ }
Ok(())
}
diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs
@@ -15,8 +15,10 @@ pub use error::RadrootsRelayTransportError;
pub use fetch::fetch_relay_events_blocking;
#[cfg(feature = "storage")]
pub use fetch::{
- RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX,
- RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter,
+ RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX, RadrootsMockRelayFetchAdapter,
RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchEventAdmission,
RadrootsRelayFetchEventReceipt, RadrootsRelayFetchEventValidStream,
RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility,
diff --git a/crates/transport_nostr/src/relay.rs b/crates/transport_nostr/src/relay.rs
@@ -1,7 +1,10 @@
#![forbid(unsafe_code)]
use crate::RadrootsRelayTransportError;
-use radroots_transport::RadrootsTransportTarget;
+use radroots_transport::{
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ RadrootsTransportTarget,
+};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
@@ -34,6 +37,16 @@ impl RadrootsRelayUrl {
policy: RadrootsRelayUrlPolicy,
) -> Result<Self, RadrootsRelayTransportError> {
let original = value.as_ref();
+ if original.len() > RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES {
+ return Err(RadrootsRelayTransportError::RelayUrlParse {
+ url: "<oversized>".to_owned(),
+ reason: format!(
+ "relay URL exceeds {} UTF-8 bytes: observed {}",
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ original.len()
+ ),
+ });
+ }
let parsed =
Url::parse(original).map_err(|error| RadrootsRelayTransportError::RelayUrlParse {
url: original.to_owned(),
@@ -265,6 +278,13 @@ impl RadrootsRelayTargetSet {
{
let mut ordered_relays = Vec::new();
for relay in relays {
+ if ordered_relays.len() == RADROOTS_TRANSPORT_TARGET_MAX_COUNT {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "relay_target_count",
+ max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1,
+ });
+ }
let relay = RadrootsRelayUrl::parse(relay, policy)?;
if ordered_relays.iter().any(|existing| existing == &relay) {
return Err(RadrootsRelayTransportError::DuplicateRelayUrl {
@@ -283,6 +303,13 @@ impl RadrootsRelayTargetSet {
pub fn from_urls(relays: Vec<RadrootsRelayUrl>) -> Result<Self, RadrootsRelayTransportError> {
let mut ordered_relays = Vec::new();
for relay in relays {
+ if ordered_relays.len() == RADROOTS_TRANSPORT_TARGET_MAX_COUNT {
+ return Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "relay_target_count",
+ max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1,
+ });
+ }
if ordered_relays.iter().any(|existing| existing == &relay) {
return Err(RadrootsRelayTransportError::DuplicateRelayUrl {
url: relay.into_string(),
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -19,6 +19,7 @@ use radroots_outbox::{
RadrootsOutboxOperationStatus,
};
use radroots_transport::{
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt,
RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
@@ -28,8 +29,10 @@ use radroots_transport::{
RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet,
};
use radroots_transport_nostr::{
- RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX,
- RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter,
+ RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX, RadrootsMockRelayFetchAdapter,
RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsOutboxPublishPolicy,
RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream,
RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility,
@@ -606,6 +609,21 @@ fn unsupported_relay_fetch_filter(limit: usize) -> RadrootsNostrFilter {
.limit(limit)
}
+fn relay_fetch_filter_with_json_bytes(target_bytes: usize) -> RadrootsNostrFilter {
+ let base = radroots_nostr_filter_tag(RadrootsNostrFilter::new(), "t", vec!["x".to_owned()])
+ .expect("base relay fetch filter");
+ let base_bytes = base.as_json().len();
+ assert!(target_bytes >= base_bytes);
+ let filter = radroots_nostr_filter_tag(
+ RadrootsNostrFilter::new(),
+ "t",
+ vec!["x".repeat(target_bytes - base_bytes + 1)],
+ )
+ .expect("sized relay fetch filter");
+ assert_eq!(filter.as_json().len(), target_bytes);
+ filter
+}
+
fn fixture_relay_targets() -> RadrootsRelayTargetSet {
RadrootsRelayTargetSet::new(
[RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS],
@@ -888,6 +906,69 @@ fn relay_url_validation_and_target_normalization() {
}
#[test]
+fn relay_urls_and_target_sets_enforce_resource_bounds() {
+ let prefix = "wss://relay.example.com/";
+ let exact = format!(
+ "{prefix}{}",
+ "x".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES - prefix.len())
+ );
+ let relay = RadrootsRelayUrl::parse(&exact, RadrootsRelayUrlPolicy::Public)
+ .expect("maximum-length relay URL");
+ assert_eq!(
+ relay.as_str().len(),
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES
+ );
+
+ let one_over = format!("{exact}x");
+ assert!(matches!(
+ RadrootsRelayUrl::parse(one_over, RadrootsRelayUrlPolicy::Public),
+ Err(RadrootsRelayTransportError::RelayUrlParse { url, reason })
+ if url == "<oversized>"
+ && reason.contains(&RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES.to_string())
+ ));
+
+ let exact_targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT)
+ .map(|index| format!("wss://relay-{index}.example.com"))
+ .collect::<Vec<_>>();
+ let targets = RadrootsRelayTargetSet::new(
+ exact_targets.iter().map(String::as_str),
+ RadrootsRelayUrlPolicy::Public,
+ )
+ .expect("maximum relay target set");
+ assert_eq!(targets.len(), RADROOTS_TRANSPORT_TARGET_MAX_COUNT);
+
+ let one_over_targets = (0..=RADROOTS_TRANSPORT_TARGET_MAX_COUNT)
+ .map(|index| format!("wss://relay-{index}.example.com"))
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ RadrootsRelayTargetSet::new(
+ one_over_targets.iter().map(String::as_str),
+ RadrootsRelayUrlPolicy::Public,
+ ),
+ Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "relay_target_count",
+ max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ actual,
+ }) if actual == RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1
+ ));
+
+ let parsed_one_over = one_over_targets
+ .iter()
+ .map(|url| {
+ RadrootsRelayUrl::parse(url, RadrootsRelayUrlPolicy::Public).expect("bounded relay URL")
+ })
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ RadrootsRelayTargetSet::from_urls(parsed_one_over),
+ Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "relay_target_count",
+ max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ actual,
+ }) if actual == RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1
+ ));
+}
+
+#[test]
fn transport_target_and_relay_adapter_share_canonical_url_identity() {
for (raw, policy) in [
(
@@ -1820,6 +1901,53 @@ fn fetch_requests_reject_empty_filter_sets() {
}
#[test]
+fn fetch_filters_enforce_count_and_compact_json_bounds() {
+ let maximum_filter =
+ relay_fetch_filter_with_json_bytes(RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX);
+ let exact_filters = vec![maximum_filter.clone(); RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX];
+ let filters = RadrootsRelayFetchFilters::new(exact_filters).expect("maximum filter set");
+ assert_eq!(
+ filters.as_slice().len(),
+ RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX
+ );
+ assert_eq!(
+ filters
+ .as_slice()
+ .iter()
+ .map(|filter| filter.as_json().len())
+ .sum::<usize>(),
+ RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX
+ );
+ assert_eq!(
+ RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX * RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX,
+ RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX
+ );
+
+ assert!(matches!(
+ RadrootsRelayFetchFilters::new(vec![
+ post_relay_fetch_filter(1);
+ RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1
+ ]),
+ Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_count",
+ max: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX,
+ actual,
+ }) if actual == RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1
+ ));
+
+ let oversized_filter =
+ relay_fetch_filter_with_json_bytes(RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX + 1);
+ assert!(matches!(
+ RadrootsRelayFetchFilters::new([oversized_filter]),
+ Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "filter_json_bytes",
+ max: RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX,
+ actual,
+ }) if actual == RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX + 1
+ ));
+}
+
+#[test]
fn fetch_requests_reject_zero_limits_and_timeouts() {
let filter = post_relay_fetch_filter(1);
let filters = RadrootsRelayFetchFilters::new([filter.clone()]).expect("filters");
@@ -1853,6 +1981,25 @@ fn fetch_requests_reject_zero_limits_and_timeouts() {
request.clone().with_timeout_ms(0),
Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "timeout_ms"
));
+ let maximum_timeout = request
+ .clone()
+ .with_timeout_ms(RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX)
+ .expect("maximum timeout");
+ assert_eq!(
+ maximum_timeout.timeout_ms(),
+ RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX
+ );
+ assert!(matches!(
+ request
+ .clone()
+ .with_timeout_ms(RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX + 1),
+ Err(RadrootsRelayTransportError::FetchLimitTooLarge {
+ field: "timeout_ms",
+ max,
+ actual,
+ }) if max == RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize
+ && actual == RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize + 1
+ ));
assert!(matches!(
request.clone().with_raw_event_scan_limit(0),
Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_raw_events"