commit 0448b20daf02fa8c4e852ccba672540494698817
parent db26c45ceb130e5fd337707c0252896ca5a3f07e
Author: triesap <tyson@radroots.org>
Date: Mon, 25 May 2026 04:40:46 +0000
runtime: validate relay urls
Diffstat:
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(