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:
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.