commit 34a871959983e573f0ee4b66dd74b42cb04494e6
parent e39fe060df73a71f64482a1f4f2861e90fb111ab
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 07:27:15 +0000
config: enforce strict relay urls
Diffstat:
4 files changed, 95 insertions(+), 31 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5093,6 +5093,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"radroots_app_models",
+ "radroots_local_events",
"radroots_runtime_paths",
"serde",
"serde_json",
@@ -5100,7 +5101,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
- "url",
]
[[package]]
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -4857,18 +4857,9 @@ fn current_runtime_time_ms() -> Result<i64, AppSqliteError> {
fn normalized_app_sync_relay_urls(
relay_urls: &[String],
) -> Result<Vec<String>, AppSyncTransportError> {
- let mut seen = BTreeSet::new();
- let normalized = relay_urls
- .iter()
- .filter_map(|relay| {
- let relay = relay.trim();
- if relay.is_empty() || !seen.insert(relay.to_owned()) {
- None
- } else {
- Some(relay.to_owned())
- }
- })
- .collect::<Vec<_>>();
+ let normalized = radroots_local_events::normalize_relay_urls(relay_urls).map_err(|error| {
+ AppSyncTransportError::failed(format!("invalid direct relay app sync relay url: {error}"))
+ })?;
if normalized.is_empty() {
return Err(AppSyncTransportError::unavailable(
"direct relay app sync requires at least one configured relay",
@@ -7349,7 +7340,6 @@ mod tests {
" ws://127.0.0.1:8081 ".to_owned(),
"ws://127.0.0.1:8080".to_owned(),
"ws://127.0.0.1:8081".to_owned(),
- " ".to_owned(),
])
.expect("relay set should normalize");
@@ -7360,6 +7350,24 @@ mod tests {
}
#[test]
+ fn runtime_direct_relay_transport_rejects_invalid_configured_relay_urls() {
+ for relay_url in [
+ " ",
+ "https://relay.example",
+ "wss://",
+ "wss://user@relay.example",
+ "wss://relay.example:abc",
+ ] {
+ let error = super::normalized_app_sync_relay_urls(&[relay_url.to_owned()])
+ .expect_err("invalid app sync relay url");
+ assert!(
+ error.to_string().contains("relay url"),
+ "unexpected error for {relay_url}: {error}"
+ );
+ }
+ }
+
+ #[test]
fn order_request_listing_pointer_prefers_configured_listing_relay() {
let selected = super::selected_listing_relay(
&[
diff --git a/crates/shared/core/Cargo.toml b/crates/shared/core/Cargo.toml
@@ -10,6 +10,7 @@ publish = false
[dependencies]
chrono.workspace = true
radroots_app_models.workspace = true
+radroots_local_events.workspace = true
radroots_runtime_paths.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -17,7 +18,6 @@ thiserror.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
-url = "2"
[lints]
workspace = true
diff --git a/crates/shared/core/src/runtime.rs b/crates/shared/core/src/runtime.rs
@@ -1,9 +1,9 @@
use std::{
- collections::BTreeSet,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
+use radroots_local_events::normalize_relay_url;
use serde::Serialize;
use thiserror::Error;
@@ -223,15 +223,23 @@ fn parse_relay_url_set(
field: &'static str,
value: String,
) -> Result<Vec<String>, AppRuntimeConfigError> {
- let mut seen = BTreeSet::new();
let mut relays = Vec::new();
for relay in value.split(',') {
let relay = relay.trim();
- if !relay.is_empty() {
- validate_relay_url(field, relay)?;
- if seen.insert(relay.to_owned()) {
- relays.push(relay.to_owned());
+ if relay.is_empty() {
+ return Err(AppRuntimeConfigError::InvalidRelayUrl {
+ field,
+ value: relay.to_owned(),
+ });
+ }
+ let normalized = normalize_app_relay_url(field, relay).map_err(|_| {
+ AppRuntimeConfigError::InvalidRelayUrl {
+ field,
+ value: relay.to_owned(),
}
+ })?;
+ if !relays.iter().any(|existing| existing == &normalized) {
+ relays.push(normalized);
}
}
@@ -242,18 +250,14 @@ fn parse_relay_url_set(
Ok(relays)
}
-fn validate_relay_url(field: &'static str, relay: &str) -> Result<(), AppRuntimeConfigError> {
- let url = url::Url::parse(relay).map_err(|_| AppRuntimeConfigError::InvalidRelayUrl {
+fn normalize_app_relay_url(
+ field: &'static str,
+ relay: &str,
+) -> Result<String, AppRuntimeConfigError> {
+ normalize_relay_url(relay).map_err(|_| AppRuntimeConfigError::InvalidRelayUrl {
field,
value: relay.to_owned(),
- })?;
- if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() {
- return Err(AppRuntimeConfigError::InvalidRelayUrl {
- field,
- value: relay.to_owned(),
- });
- }
- Ok(())
+ })
}
fn require_path_value(
@@ -504,6 +508,58 @@ mod tests {
}
#[test]
+ fn runtime_config_rejects_malformed_nostr_relay_authority() {
+ for relay_url in [
+ "wss://user@relay.example",
+ "wss://relay.example:abc",
+ "wss://2001:db8::1",
+ "wss://relay.example,,wss://relay-two.example",
+ ] {
+ let env = BTreeMap::from([
+ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()),
+ (APP_NOSTR_RELAY_URLS_ENV, relay_url.to_owned()),
+ ]);
+ let error = AppRuntimeConfig::from_env_with(
+ |name| env.get(name).cloned(),
+ Some(PathBuf::from("/tmp/default-logs")),
+ )
+ .expect_err("malformed relay authority should fail");
+
+ assert!(
+ matches!(
+ error,
+ AppRuntimeConfigError::InvalidRelayUrl {
+ field: APP_NOSTR_RELAY_URLS_ENV,
+ ..
+ }
+ ),
+ "unexpected error for {relay_url}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn runtime_config_accepts_bracketed_ipv6_nostr_relay_urls() {
+ let env = BTreeMap::from([
+ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()),
+ (
+ APP_NOSTR_RELAY_URLS_ENV,
+ " wss://[2001:db8::1]:443/relay ".to_owned(),
+ ),
+ ]);
+ let config = AppRuntimeConfig::from_env_with(
+ |name| env.get(name).cloned(),
+ Some(PathBuf::from("/tmp/default-logs")),
+ )
+ .expect("ipv6 relay url should resolve");
+
+ assert_eq!(
+ config.nostr_relay_urls,
+ vec!["wss://[2001:db8::1]:443/relay"]
+ );
+ }
+
+ #[test]
fn runtime_config_defaults_local_log_root_from_runtime_paths() {
let env = test_runtime_env();
let config = AppRuntimeConfig::from_env_with(