tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 0724cfa2b10fe91d507f6567995909a5d88b20bf
parent 24ad6759170d85c9752c9e866cc9548d51fa8a79
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 13:49:24 -0700

security: add abuse test suite

- add live relay abuse tests for malformed input, invalid events, auth replay, and limits
- verify blocked, hidden, and deleted listings stay off public surfaces
- apply configured runtime limits to relay REQ handling
- mark address-deleted listings deleted in the public listing projection

Diffstat:
Acrates/tangle/tests/abuse_conformance.rs | 379+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle/tests/support/mod.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle_core/src/lib.rs | 26++++++++++++++++++++++++--
Mcrates/tangle_runtime/src/lib.rs | 5++++-
Mcrates/tangle_store_surreal/src/lib.rs | 3+++
5 files changed, 459 insertions(+), 6 deletions(-)

diff --git a/crates/tangle/tests/abuse_conformance.rs b/crates/tangle/tests/abuse_conformance.rs @@ -0,0 +1,379 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::fs; +use support::{ + RelayHarness, assert_ok, connect_client, http_get, http_post_json, reopen_store, send_auth, + send_event, send_req, send_text, +}; +use tangle_protocol::{Event, event_to_value}; +use tangle_test_support::{ + FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, + valid_public_listing_spec, +}; + +#[tokio::test] +async fn abuse_conformance_rejects_malformed_invalid_and_replayed_messages() { + let seller = FixtureKey::Seller.public_key(); + let harness = RelayHarness::start( + "abuse_invalid_messages", + serde_json::json!({ + "approved_sellers": [seller.as_str()] + }), + ); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let auth = build_fixture_event(&auth_event_spec()).expect("auth"); + let mut client = connect_client(harness.port).await; + + let malformed = send_text(&mut client, "not json").await; + assert_eq!(malformed[0], "NOTICE"); + assert!( + malformed[1] + .as_str() + .expect("notice") + .contains("client message JSON is invalid") + ); + let bad_id = send_text(&mut client, r#"["REQ","bad-id",{"ids":["bad"]}]"#).await; + assert_eq!(bad_id[0], "NOTICE"); + assert!(bad_id[1].as_str().expect("notice").contains("event id")); + + assert_ok(&send_auth(&mut client, &auth).await, true); + let replay = send_auth(&mut client, &auth).await; + assert_ok(&replay, false); + assert!( + replay[3] + .as_str() + .expect("replay rejection") + .contains("auth challenge is missing") + ); + + let mut bad_sig = event_to_value(&listing); + bad_sig["sig"] = serde_json::Value::String("f".repeat(128)); + let rejected_sig = send_text( + &mut client, + &serde_json::json!(["EVENT", bad_sig]).to_string(), + ) + .await; + assert_ok(&rejected_sig, false); + assert!( + rejected_sig[3] + .as_str() + .expect("signature rejection") + .contains("crypto") + ); + let mut bad_event_id = event_to_value(&listing); + bad_event_id["id"] = serde_json::Value::String("f".repeat(64)); + let rejected_id = send_text( + &mut client, + &serde_json::json!(["EVENT", bad_event_id]).to_string(), + ) + .await; + assert_ok(&rejected_id, false); + + let store_config = harness.store_config(); + let root = harness.root.clone(); + drop(client); + harness.stop(); + let store = reopen_store(&store_config).await; + assert!( + store + .raw_event_row(listing.id()) + .await + .expect("raw listing") + .is_none() + ); + drop(store); + fs::remove_dir_all(root).expect("remove runtime root"); +} + +#[tokio::test] +async fn abuse_conformance_enforces_runtime_limits_for_events_subscriptions_and_search() { + let seller = FixtureKey::Seller.public_key(); + let harness = RelayHarness::start_with_runtime_limits( + "abuse_runtime_limits", + serde_json::json!({ + "approved_sellers": [seller.as_str()] + }), + serde_json::json!({ + "max_tags_per_event": 1, + "max_filters_per_subscription": 1, + "max_subscriptions_per_connection": 1, + "max_search_tokens": 1 + }), + ); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let auth = build_fixture_event(&auth_event_spec()).expect("auth"); + + let mut writer = connect_client(harness.port).await; + assert_ok(&send_auth(&mut writer, &auth).await, true); + let too_many_tags = send_event(&mut writer, &listing).await; + assert_ok(&too_many_tags, false); + assert!( + too_many_tags[3] + .as_str() + .expect("tag rejection") + .contains("tags per event") + ); + drop(writer); + + let mut reader = connect_client(harness.port).await; + let too_many_filters = send_text( + &mut reader, + r#"["REQ","too-many-filters",{"ids":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},{"ids":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]}]"#, + ) + .await; + assert_eq!(too_many_filters[0], "CLOSED"); + assert!( + too_many_filters[2] + .as_str() + .expect("filter rejection") + .contains("filters per subscription") + ); + let first_subscription = send_req( + &mut reader, + "sub-one", + serde_json::json!({ + "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + }), + ) + .await; + assert_eq!(first_subscription[0], "EOSE"); + let too_many_subscriptions = send_req( + &mut reader, + "sub-two", + serde_json::json!({ + "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + }), + ) + .await; + assert_eq!(too_many_subscriptions[0], "CLOSED"); + assert!( + too_many_subscriptions[2] + .as_str() + .expect("subscription rejection") + .contains("subscriptions per connection") + ); + drop(reader); + + let mut searcher = connect_client(harness.port).await; + let abusive_search = send_req( + &mut searcher, + "search-abuse", + serde_json::json!({ + "search": "fresh carrots", + "limit": 5 + }), + ) + .await; + assert_eq!(abusive_search[0], "CLOSED"); + assert!( + abusive_search[2] + .as_str() + .expect("search rejection") + .contains("search tokens") + ); + + let store_config = harness.store_config(); + let root = harness.root.clone(); + drop(searcher); + harness.stop(); + let store = reopen_store(&store_config).await; + assert!( + store + .raw_event_row(listing.id()) + .await + .expect("raw listing") + .is_none() + ); + drop(store); + fs::remove_dir_all(root).expect("remove runtime root"); +} + +#[tokio::test] +async fn abuse_conformance_excludes_blocked_pubkey_writes_from_public_surfaces() { + let seller = FixtureKey::Seller.public_key(); + let harness = RelayHarness::start( + "abuse_blocked_pubkey", + serde_json::json!({ + "approved_sellers": [seller.as_str()], + "blocked_pubkeys": [seller.as_str()] + }), + ); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let auth = build_fixture_event(&auth_event_spec()).expect("auth"); + let mut client = connect_client(harness.port).await; + + assert_ok(&send_auth(&mut client, &auth).await, true); + assert_ok(&send_event(&mut client, &listing).await, true); + assert!(!http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str())); + + let store_config = harness.store_config(); + let root = harness.root.clone(); + drop(client); + harness.stop(); + let store = reopen_store(&store_config).await; + assert!( + store + .raw_event_row(listing.id()) + .await + .expect("raw listing") + .is_some() + ); + let listing_key = format!("30402:{}:listing-a", seller.as_str()); + assert!( + store + .listing_current_row(&listing_key) + .await + .expect("listing row") + .is_none() + ); + assert!( + store + .search_document_row(&listing_key) + .await + .expect("search row") + .is_none() + ); + drop(store); + fs::remove_dir_all(root).expect("remove runtime root"); +} + +#[tokio::test] +async fn abuse_conformance_excludes_hidden_and_deleted_listings_from_public_surfaces() { + let hidden = publish_listing_for_visibility("abuse_hidden_listing").await; + let hide = http_post_json( + hidden.harness.port, + &format!("/api/admin/events/{}/hide", hidden.listing.id().as_str()), + Some(hidden.admin.as_str()), + serde_json::json!({ + "reason": "abuse visibility test" + }), + ); + assert!(hide.contains("200 OK")); + assert!(hide.contains("\"status\":\"hidden\"")); + assert!( + !http_get(hidden.harness.port, "/api/listings?limit=5") + .contains(hidden.listing.id().as_str()) + ); + let hidden_store_config = hidden.harness.store_config(); + let hidden_root = hidden.harness.root.clone(); + drop(hidden.client); + hidden.harness.stop(); + let store = reopen_store(&hidden_store_config).await; + let listing_key = format!("30402:{}:listing-a", hidden.seller.as_str()); + assert_eq!( + store + .raw_event_row(hidden.listing.id()) + .await + .expect("raw row") + .expect("raw row exists")["hidden"], + true + ); + assert_eq!( + store + .listing_current_row(&listing_key) + .await + .expect("listing row") + .expect("listing row exists")["hidden"], + true + ); + assert_eq!( + store + .search_document_row(&listing_key) + .await + .expect("search row") + .expect("search row exists")["visible"], + false + ); + drop(store); + fs::remove_dir_all(hidden_root).expect("remove hidden runtime root"); + + let mut deleted = publish_listing_for_visibility("abuse_deleted_listing").await; + let listing_key = format!("30402:{}:listing-a", deleted.seller.as_str()); + let deletion = build_fixture_event_from_parts( + FixtureKey::Seller, + 1_714_124_460, + 5, + vec![vec!["a".to_owned(), listing_key.clone()]], + "delete listing", + ) + .expect("deletion"); + assert_ok(&send_event(&mut deleted.client, &deletion).await, true); + let deleted_lookup = send_req( + &mut deleted.client, + "deleted-listing", + serde_json::json!({ + "ids": [deleted.listing.id().as_str()] + }), + ) + .await; + assert_eq!(deleted_lookup[0], "EOSE"); + assert!( + !http_get(deleted.harness.port, "/api/listings?limit=5") + .contains(deleted.listing.id().as_str()) + ); + let deleted_store_config = deleted.harness.store_config(); + let deleted_root = deleted.harness.root.clone(); + drop(deleted.client); + deleted.harness.stop(); + let store = reopen_store(&deleted_store_config).await; + assert_eq!( + store + .raw_event_row(deleted.listing.id()) + .await + .expect("raw row") + .expect("raw row exists")["deleted"], + true + ); + assert_eq!( + store + .search_document_row(&listing_key) + .await + .expect("search row") + .expect("search row exists")["visible"], + false + ); + assert_eq!( + store + .deletion_marker_rows(deletion.id()) + .await + .expect("markers")[0]["target_type"], + "address" + ); + drop(store); + fs::remove_dir_all(deleted_root).expect("remove deleted runtime root"); +} + +struct VisibilityHarness { + harness: RelayHarness, + client: support::RelayClient, + listing: Event, + seller: tangle_protocol::PublicKeyHex, + admin: tangle_protocol::PublicKeyHex, +} + +async fn publish_listing_for_visibility(namespace: &str) -> VisibilityHarness { + let seller = FixtureKey::Seller.public_key(); + let admin = FixtureKey::Relay.public_key(); + let harness = RelayHarness::start( + namespace, + serde_json::json!({ + "admin_pubkeys": [admin.as_str()], + "approved_sellers": [seller.as_str()] + }), + ); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let auth = build_fixture_event(&auth_event_spec()).expect("auth"); + let mut client = connect_client(harness.port).await; + assert_ok(&send_auth(&mut client, &auth).await, true); + assert_ok(&send_event(&mut client, &listing).await, true); + assert!(http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str())); + VisibilityHarness { + harness, + client, + listing, + seller, + admin, + } +} diff --git a/crates/tangle/tests/support/mod.rs b/crates/tangle/tests/support/mod.rs @@ -25,6 +25,14 @@ pub struct RelayHarness { impl RelayHarness { pub fn start(namespace: &str, policy: Value) -> Self { + Self::start_with_runtime_limits(namespace, policy, serde_json::json!({})) + } + + pub fn start_with_runtime_limits( + namespace: &str, + policy: Value, + runtime_limits: Value, + ) -> Self { let port = free_port(); let root = std::env::temp_dir().join(format!( "tangle-conformance-{namespace}-{}-{port}", @@ -33,7 +41,14 @@ impl RelayHarness { let db_path = root.join("surrealdb"); let config_path = root.join("runtime.json"); fs::create_dir_all(&root).expect("runtime root"); - write_runtime_config(&config_path, &db_path, port, namespace, policy); + write_runtime_config( + &config_path, + &db_path, + port, + namespace, + policy, + runtime_limits, + ); let mut child = Command::new(env!("CARGO_BIN_EXE_tangle")) .args(["run", "--config"]) .arg(&config_path) @@ -191,6 +206,29 @@ pub fn http_get_admin(port: u16, path: &str, admin_pubkey: &str) -> String { response } +pub fn http_post_json(port: u16, path: &str, admin_pubkey: Option<&str>, body: Value) -> String { + let body = body.to_string(); + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("write timeout"); + let admin_header = admin_pubkey + .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n")) + .unwrap_or_default(); + write!( + stream, + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{admin_header}Connection: close\r\n\r\n{body}", + body.len() + ) + .expect("http post"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("http read"); + response +} + pub async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore { let started = Instant::now(); loop { @@ -205,7 +243,14 @@ pub async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore { } } -fn write_runtime_config(path: &Path, db_path: &Path, port: u16, namespace: &str, policy: Value) { +fn write_runtime_config( + path: &Path, + db_path: &Path, + port: u16, + namespace: &str, + policy: Value, + runtime_limits: Value, +) { let config = serde_json::json!({ "server": { "listen_addr": format!("127.0.0.1:{port}"), @@ -224,7 +269,8 @@ fn write_runtime_config(path: &Path, db_path: &Path, port: u16, namespace: &str, "message_rate_limit": { "limit": 120, "window_seconds": 60 - } + }, + "runtime": runtime_limits }, "policy": policy }); diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs @@ -2128,7 +2128,7 @@ impl NostrFilterCompiler { .map_err(NostrFilterCompileError::RuntimeLimit)?; let branches = filters .iter() - .map(compile_filter_branch) + .map(|filter| compile_filter_branch(filter, self.limits)) .collect::<Result<Vec<_>, _>>()?; let source = if branches.iter().any(|branch| branch.search().is_some()) { QuerySource::SearchDocuments @@ -2894,12 +2894,18 @@ pub enum RateLimitErrorKind { CostExceedsLimit, } -fn compile_filter_branch(filter: &Filter) -> Result<QueryPlanBranch, NostrFilterCompileError> { +fn compile_filter_branch( + filter: &Filter, + limits: RuntimeLimits, +) -> Result<QueryPlanBranch, NostrFilterCompileError> { let tag_filters = compile_filter_tag_constraints(filter).map_err(NostrFilterCompileError::QueryPlan)?; let search = filter .search() .map(|raw| { + limits + .validate_search_query(raw) + .map_err(NostrFilterCompileError::RuntimeLimit)?; QuerySearch::new( raw, raw.split_whitespace() @@ -5106,6 +5112,17 @@ mod tests { QueryExecutionMode::Historical, ) .expect_err("blank search"); + let too_many_search_tokens = NostrFilterCompiler::new(limits_with(|values| { + values.max_search_tokens = 1; + })) + .compile( + &[ + filter_from_value(&serde_json::json!({ "search": "fresh carrots" })) + .expect("filter"), + ], + QueryExecutionMode::Historical, + ) + .expect_err("search tokens"); let empty_tag = NostrFilterCompiler::default() .compile( &[filter_from_value(&serde_json::json!({ "#t": [""] })).expect("filter")], @@ -5123,6 +5140,10 @@ mod tests { NostrFilterCompileErrorKind::RuntimeLimit ); assert_eq!(blank_search.kind(), NostrFilterCompileErrorKind::QueryPlan); + assert_eq!( + too_many_search_tokens.kind(), + NostrFilterCompileErrorKind::RuntimeLimit + ); assert_eq!(empty_tag.kind(), NostrFilterCompileErrorKind::QueryPlan); assert_eq!( empty_filters.to_string(), @@ -5134,6 +5155,7 @@ mod tests { blank_search.to_string(), "query plan: search query must include terms" ); + assert!(too_many_search_tokens.to_string().contains("search tokens")); assert_eq!( empty_tag.to_string(), "query plan: tag filter `t` values must not be empty" diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -1890,7 +1890,10 @@ async fn handle_websocket(mut socket: WebSocket, state: RuntimeRelayState) { let event_handler = EventMessageHandler::new(state.store.clone(), state.validator()) .with_durable_write_rate_limit(state.config.durable_write_rate_limit()); let auth_handler = AuthMessageHandler; - let req_handler = ReqMessageHandler::new(state.store.clone(), NostrFilterCompiler::default()); + let req_handler = ReqMessageHandler::new( + state.store.clone(), + NostrFilterCompiler::new(state.config.limits()), + ); let close_handler = CloseMessageHandler; let fanout = LiveEventFanout; let challenge = auth_handler.issue_challenge( diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -4398,6 +4398,9 @@ UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_even "UPDATE event_current SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;", ) .query( + "UPDATE listing_current SET deleted = true WHERE listing_key = $address_key AND seller_pubkey = $author_pubkey;", + ) + .query( "UPDATE long_form_current SET deleted = true WHERE long_form_key = $address_key AND author_pubkey = $author_pubkey;", ) .query("UPDATE long_form_topic SET deleted = true WHERE long_form_key = $address_key;")