app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 0448b20daf02fa8c4e852ccba672540494698817
parent db26c45ceb130e5fd337707c0252896ca5a3f07e
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 04:40:46 +0000

runtime: validate relay urls

Diffstat:
MCargo.lock | 1+
Mcrates/shared/core/Cargo.toml | 1+
Mcrates/shared/core/src/runtime.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 95 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5100,6 +5100,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", ] [[package]] diff --git a/crates/shared/core/Cargo.toml b/crates/shared/core/Cargo.toml @@ -17,6 +17,7 @@ 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 @@ -91,6 +91,8 @@ pub enum AppRuntimeConfigError { UnsupportedRuntimeMode(String), #[error("missing required runtime config field: {0}")] MissingField(&'static str), + #[error("invalid runtime relay url in {field}: {value}")] + InvalidRelayUrl { field: &'static str, value: String }, } impl AppRuntimeConfig { @@ -225,8 +227,11 @@ fn parse_relay_url_set( let mut relays = Vec::new(); for relay in value.split(',') { let relay = relay.trim(); - if !relay.is_empty() && seen.insert(relay.to_owned()) { - relays.push(relay.to_owned()); + if !relay.is_empty() { + validate_relay_url(field, relay)?; + if seen.insert(relay.to_owned()) { + relays.push(relay.to_owned()); + } } } @@ -237,6 +242,20 @@ 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 { + 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( field: &'static str, value: String, @@ -413,6 +432,78 @@ mod tests { } #[test] + fn runtime_config_rejects_malformed_nostr_relay_urls() { + let env = BTreeMap::from([ + (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), + (APP_NOSTR_RELAY_URLS_ENV, "not-a-url".to_owned()), + ]); + let error = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect_err("malformed relay url should fail"); + + assert!( + matches!( + error, + AppRuntimeConfigError::InvalidRelayUrl { + field: APP_NOSTR_RELAY_URLS_ENV, + ref value + } if value == "not-a-url" + ), + "unexpected error: {error}" + ); + } + + #[test] + fn runtime_config_rejects_non_websocket_nostr_relay_urls() { + let env = BTreeMap::from([ + (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), + (APP_NOSTR_RELAY_URLS_ENV, "https://relay.example".to_owned()), + ]); + let error = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect_err("non-websocket relay url should fail"); + + assert!( + matches!( + error, + AppRuntimeConfigError::InvalidRelayUrl { + field: APP_NOSTR_RELAY_URLS_ENV, + ref value + } if value == "https://relay.example" + ), + "unexpected error: {error}" + ); + } + + #[test] + fn runtime_config_rejects_hostless_nostr_relay_urls() { + let env = BTreeMap::from([ + (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), + (APP_NOSTR_RELAY_URLS_ENV, "wss://".to_owned()), + ]); + let error = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect_err("hostless relay url should fail"); + + assert!( + matches!( + error, + AppRuntimeConfigError::InvalidRelayUrl { + field: APP_NOSTR_RELAY_URLS_ENV, + ref value + } if value == "wss://" + ), + "unexpected error: {error}" + ); + } + + #[test] fn runtime_config_defaults_local_log_root_from_runtime_paths() { let env = test_runtime_env(); let config = AppRuntimeConfig::from_env_with(