app

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

commit 98510d323653d38696e3815bd03397862b295cfa
parent a10e42ada30a69a3712fefe922303c98e62afdb6
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:
Mcore/Cargo.lock | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/Cargo.toml | 1+
Mcore/crates/domain/Cargo.toml | 1+
Mcore/crates/domain/src/lib.rs | 1+
Mcore/crates/domain/src/relay.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
6 files changed, 514 insertions(+), 1 deletion(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -3,6 +3,168 @@ version = 4 [[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] name = "radroots-studio-application" version = "0.1.0-alpha.0" @@ -11,6 +173,7 @@ name = "radroots-studio-domain" version = "0.1.0-alpha.0" dependencies = [ "secrecy", + "url", ] [[package]] @@ -39,7 +202,198 @@ dependencies = [ ] [[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -25,4 +25,5 @@ pedantic = "deny" [workspace.dependencies] secrecy = "=0.10.3" +url = "=2.5.8" zeroize = "=1.9.0" diff --git a/core/crates/domain/Cargo.toml b/core/crates/domain/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] secrecy.workspace = true +url.workspace = true [lints] workspace = true diff --git a/core/crates/domain/src/lib.rs b/core/crates/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/core/crates/domain/src/relay.rs b/core/crates/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/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -460,7 +460,7 @@ handoff commit sequence. - [x] 05. Implement Nostr public key value object. - [x] 06. Implement secret input boundary and redacted secret wrapper. - [x] 07. Add NIP-19 public/secret display contract types. -- [ ] 08. Implement relay URL parser and policy. +- [x] 08. Implement relay URL parser and policy. - [ ] 09. Add account public metadata value types. - [ ] 10. Add profile metadata model and kind-0 selection rules. - [ ] 11. Define immutable AppSnapshot and state enums.