lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit f9bf4528dd28855682707953f0190d11da454c0b
parent 9ecc199f73ac13c32843acee241ec35ebc1d29eb
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:07:16 +0000

core(domain): add relay URL validation

- normalize explicit WebSocket relay URLs
- restrict plaintext transport to exact loopback hosts
- reject credentials, fragments, controls, and server schemes
- preserve first-seen relay order while removing duplicates

Diffstat:
Mcrates/studio_domain/Cargo.toml | 1+
Mcrates/studio_domain/src/lib.rs | 1+
Mcrates/studio_domain/src/relay.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mimports/studio_workspace/Cargo.toml | 1+
4 files changed, 159 insertions(+), 0 deletions(-)

diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] secrecy.workspace = true +url.workspace = true [lints] workspace = true diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -9,3 +9,4 @@ pub mod time; pub use error::{SafeError, SafeErrorCode, SafeMessage}; pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; +pub use relay::{RelayUrl, normalize_relay_urls}; diff --git a/crates/studio_domain/src/relay.rs b/crates/studio_domain/src/relay.rs @@ -1 +1,157 @@ //! Validated Nostr relay values. + +use std::collections::HashSet; +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use url::{Host, Url}; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RelayUrl(String); + +impl RelayUrl { + /// Parses and normalizes an allowed WebSocket relay URL. + /// + /// # Errors + /// + /// Returns a safe configuration error for empty or malformed input, + /// forbidden schemes, credentials, fragments, or non-loopback `ws://`. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) { + return Err(invalid_relay()); + } + + let parsed = Url::parse(trimmed).map_err(|_| invalid_relay())?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err(invalid_relay()); + } + + match parsed.scheme() { + "wss" => {} + "ws" if is_loopback(&parsed) => {} + _ => return Err(invalid_relay()), + } + + if parsed.host().is_none() { + return Err(invalid_relay()); + } + + Ok(Self(parsed.to_string())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for RelayUrl { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl FromStr for RelayUrl { + type Err = SafeError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +/// Parses relay values and removes duplicates without changing first-seen order. +/// +/// # Errors +/// +/// Returns the first safe relay validation error. +pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<RelayUrl>, SafeError> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let mut seen = HashSet::new(); + let mut relays = Vec::new(); + for value in values { + let relay = RelayUrl::parse(value.as_ref())?; + if seen.insert(relay.clone()) { + relays.push(relay); + } + } + Ok(relays) +} + +fn is_loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(domain)) => domain == "localhost", + Some(Host::Ipv4(address)) => address.octets()[0] == 127, + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +const fn invalid_relay() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidRelayConfiguration, + SafeMessage::new("The Nostr relay URL is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use super::{RelayUrl, normalize_relay_urls}; + use crate::SafeErrorCode; + + #[test] + fn relay_accepts_secure_remote_and_loopback_development_urls() { + for (input, expected) in [ + (" wss://Relay.Example/path ", "wss://relay.example/path"), + ("ws://localhost:8080", "ws://localhost:8080/"), + ("ws://127.42.1.9:8080", "ws://127.42.1.9:8080/"), + ("ws://[::1]:8080", "ws://[::1]:8080/"), + ] { + let relay = RelayUrl::parse(input).expect("allowed relay"); + assert_eq!(relay.as_str(), expected); + assert_eq!(relay.to_string(), expected); + } + } + + #[test] + fn relay_rejects_non_websocket_credentials_fragments_and_remote_plaintext() { + for input in [ + "", + "https://relay.example", + "http://localhost:8080", + "wss://user:password@relay.example", + "wss://relay.example/#fragment", + "ws://relay.example", + "ws://192.168.1.2:8080", + "ws://localhost.evil.example:8080", + "wss://relay.example/\nunsafe", + ] { + let error = RelayUrl::parse(input).expect_err("forbidden relay"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + } + + #[test] + fn relay_deduplication_preserves_normalized_first_seen_order() { + let relays = normalize_relay_urls([ + "wss://relay.example", + " wss://second.example/path ", + "wss://RELAY.example/", + "wss://second.example/path", + ]) + .expect("valid relays"); + + assert_eq!( + relays.iter().map(RelayUrl::as_str).collect::<Vec<_>>(), + vec!["wss://relay.example/", "wss://second.example/path"] + ); + } +} diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -25,4 +25,5 @@ pedantic = "deny" [workspace.dependencies] secrecy = "=0.10.3" +url = "=2.5.8" zeroize = "=1.9.0"