commit 8f50bc2ff1cb2ea293edb5c37506f93046cf7b5a
parent 3e48a9b4f4dd7a915b9ce97afe5b559529b3c408
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 20:53:16 +0000
publish-proxy: validate resolved relay destinations
- resolve public relay hostnames before daemon publish or discovery use
- reject forbidden resolved addresses as unattempted relay outcomes
- record DNS lookup failures as retryable unattempted outcomes
- add deterministic resolver tests for public and localhost policies
Diffstat:
1 file changed, 359 insertions(+), 61 deletions(-)
diff --git a/src/core/publish_proxy/mod.rs b/src/core/publish_proxy/mod.rs
@@ -1,6 +1,9 @@
use std::collections::BTreeMap;
use std::fmt;
+use std::future::Future;
+use std::net::IpAddr;
use std::path::{Path, PathBuf};
+use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -65,6 +68,7 @@ pub struct PublishProxy {
pub config: PublishProxyConfig,
pub store: PublishProxyStore,
publisher: Option<Arc<dyn RadrootsRelayPublishAdapter>>,
+ resolver: Arc<dyn PublishRelayResolver>,
}
impl PublishProxy {
@@ -74,6 +78,7 @@ impl PublishProxy {
config,
store,
publisher: None,
+ resolver: Arc::new(SystemPublishRelayResolver),
})
}
@@ -83,6 +88,7 @@ impl PublishProxy {
config,
store,
publisher: None,
+ resolver: Arc::new(SystemPublishRelayResolver),
})
}
@@ -91,6 +97,12 @@ impl PublishProxy {
self
}
+ #[cfg(test)]
+ fn with_relay_resolver(mut self, resolver: Arc<dyn PublishRelayResolver>) -> Self {
+ self.resolver = resolver;
+ self
+ }
+
pub async fn publish_event(
&self,
principal: &PublishPrincipal,
@@ -149,10 +161,10 @@ impl PublishProxy {
request: &PublishEventRequest,
) -> Result<PublishRelayResolution, PublishProxyError> {
match request.relay_policy {
- PublishRelayPolicy::ExplicitOnly => self.resolve_request_relays(&request.relays),
+ PublishRelayPolicy::ExplicitOnly => self.resolve_request_relays(&request.relays).await,
PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault => {
if !request.relays.is_empty() {
- self.resolve_request_relays(&request.relays)
+ self.resolve_request_relays(&request.relays).await
} else {
self.resolve_author_or_default_relays(pubkey).await
}
@@ -160,7 +172,7 @@ impl PublishProxy {
PublishRelayPolicy::AuthorWriteThenDaemonDefault => {
self.resolve_author_or_default_relays(pubkey).await
}
- PublishRelayPolicy::DaemonDefaultOnly => self.resolve_daemon_default_relays(),
+ PublishRelayPolicy::DaemonDefaultOnly => self.resolve_daemon_default_relays().await,
}
}
@@ -168,15 +180,17 @@ impl PublishProxy {
&self,
pubkey: &str,
) -> Result<PublishRelayResolution, PublishProxyError> {
- let author_relays = self.resolve_author_write_relays(pubkey).await?;
- if !author_relays.targets.is_empty() {
- Ok(author_relays)
+ let mut author_relays = self.resolve_author_write_relays(pubkey).await?;
+ if author_relays.targets.is_empty() {
+ let mut daemon_defaults = self.resolve_daemon_default_relays().await?;
+ daemon_defaults.outcomes.append(&mut author_relays.outcomes);
+ Ok(daemon_defaults)
} else {
- self.resolve_daemon_default_relays()
+ Ok(author_relays)
}
}
- fn resolve_request_relays(
+ async fn resolve_request_relays(
&self,
relays: &[String],
) -> Result<PublishRelayResolution, PublishProxyError> {
@@ -184,7 +198,15 @@ impl PublishProxy {
let mut outcomes = Vec::new();
for relay in relays {
match RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)) {
- Ok(url) => push_resolved_relay(&mut targets, url, PublishRelaySource::Request),
+ Ok(url) => {
+ self.push_checked_relay_target(
+ &mut targets,
+ &mut outcomes,
+ url,
+ PublishRelaySource::Request,
+ )
+ .await;
+ }
Err(error) => outcomes.push(PublishRelayOutcome {
relay_url: relay.trim().to_owned(),
source: PublishRelaySource::Request,
@@ -203,75 +225,121 @@ impl PublishProxy {
pubkey: &str,
) -> Result<PublishRelayResolution, PublishProxyError> {
let cached = self.store.cached_author_write_relays(pubkey)?;
- let cached_targets = self.resolve_author_relay_inputs(&cached)?;
- if !cached_targets.is_empty() {
- return Ok(PublishRelayResolution {
- targets: cached_targets,
- outcomes: Vec::new(),
- });
+ let cached_resolution = self.resolve_author_relay_inputs(&cached).await?;
+ if !cached_resolution.targets.is_empty() {
+ return Ok(cached_resolution);
}
if self.config.author_relay_discovery_relays.is_empty() {
- return Ok(PublishRelayResolution::empty());
+ return Ok(cached_resolution);
+ }
+ let discovery_targets = self
+ .resolve_config_relays(
+ &self.config.author_relay_discovery_relays,
+ PublishRelaySource::DaemonDefault,
+ )
+ .await?;
+ if discovery_targets.targets.is_empty() {
+ return Ok(discovery_targets);
}
- let discovery_targets = self.resolve_config_relays(
- &self.config.author_relay_discovery_relays,
- PublishRelaySource::DaemonDefault,
- "publish_proxy author_relay_discovery_relays",
- )?;
let discovered = self
- .fetch_author_write_relays(pubkey, discovery_targets)
+ .fetch_author_write_relays(pubkey, discovery_targets.targets)
.await?;
self.store.cache_author_write_relays(pubkey, &discovered)?;
- let targets = self.resolve_author_relay_inputs(&discovered)?;
- Ok(PublishRelayResolution {
- targets,
- outcomes: Vec::new(),
- })
+ self.resolve_author_relay_inputs(&discovered).await
}
- fn resolve_author_relay_inputs(
+ async fn resolve_author_relay_inputs(
&self,
relays: &[String],
- ) -> Result<Vec<ResolvedPublishRelay>, PublishProxyError> {
+ ) -> Result<PublishRelayResolution, PublishProxyError> {
let mut targets = Vec::new();
+ let mut outcomes = Vec::new();
for relay in relays {
if let Ok(url) = RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)) {
- push_resolved_relay(&mut targets, url, PublishRelaySource::AuthorWrite);
+ self.push_checked_relay_target(
+ &mut targets,
+ &mut outcomes,
+ url,
+ PublishRelaySource::AuthorWrite,
+ )
+ .await;
}
}
- Ok(targets)
+ Ok(PublishRelayResolution { targets, outcomes })
}
- fn resolve_daemon_default_relays(&self) -> Result<PublishRelayResolution, PublishProxyError> {
- let targets = self.resolve_config_relays(
+ async fn resolve_daemon_default_relays(
+ &self,
+ ) -> Result<PublishRelayResolution, PublishProxyError> {
+ self.resolve_config_relays(
&self.config.daemon_default_publish_relays,
PublishRelaySource::DaemonDefault,
- "publish_proxy daemon_default_publish_relays",
- )?;
- Ok(PublishRelayResolution {
- targets,
- outcomes: Vec::new(),
- })
+ )
+ .await
}
- fn resolve_config_relays(
+ async fn resolve_config_relays(
&self,
relays: &[String],
source: PublishRelaySource,
- label: &str,
- ) -> Result<Vec<ResolvedPublishRelay>, PublishProxyError> {
+ ) -> Result<PublishRelayResolution, PublishProxyError> {
let mut targets = Vec::new();
+ let mut outcomes = Vec::new();
for relay in relays {
- let url = RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)).map_err(
- |error| {
- PublishProxyError::InvalidScope(format!(
- "{label} contains invalid relay URL: {error}"
- ))
- },
- )?;
- push_resolved_relay(&mut targets, url, source);
+ match RadrootsRelayUrl::parse(relay, relay_url_policy(&self.config)) {
+ Ok(url) => {
+ self.push_checked_relay_target(&mut targets, &mut outcomes, url, source)
+ .await;
+ }
+ Err(error) => outcomes.push(PublishRelayOutcome {
+ relay_url: relay.trim().to_owned(),
+ source,
+ attempted: false,
+ outcome_kind: PublishRelayOutcomeKind::RelayUrlRejected,
+ message: Some(error.to_string()),
+ latency_ms: None,
+ }),
+ }
+ }
+ Ok(PublishRelayResolution { targets, outcomes })
+ }
+
+ async fn push_checked_relay_target(
+ &self,
+ targets: &mut Vec<ResolvedPublishRelay>,
+ outcomes: &mut Vec<PublishRelayOutcome>,
+ url: RadrootsRelayUrl,
+ source: PublishRelaySource,
+ ) {
+ if relay_url_policy(&self.config) == RadrootsRelayUrlPolicy::Localhost {
+ push_resolved_relay(targets, url, source);
+ return;
+ }
+ match self.resolver.resolve(&url).await {
+ Ok(addresses) if addresses.is_empty() => {
+ outcomes.push(relay_resolution_connection_failure(
+ url.as_str(),
+ source,
+ "dns lookup returned no addresses",
+ ));
+ }
+ Ok(addresses) => match url.validate_public_resolved_ip_addrs(addresses) {
+ Ok(()) => push_resolved_relay(targets, url, source),
+ Err(error) => outcomes.push(PublishRelayOutcome {
+ relay_url: url.as_str().to_owned(),
+ source,
+ attempted: false,
+ outcome_kind: PublishRelayOutcomeKind::RelayUrlRejected,
+ message: Some(error.to_string()),
+ latency_ms: None,
+ }),
+ },
+ Err(error) => outcomes.push(relay_resolution_connection_failure(
+ url.as_str(),
+ source,
+ format!("dns lookup failed: {error}"),
+ )),
}
- Ok(targets)
}
async fn fetch_author_write_relays(
@@ -316,11 +384,25 @@ impl PublishProxy {
resolution: PublishRelayResolution,
) -> Result<PublishJobView, PublishProxyError> {
if resolution.targets.is_empty() {
+ let status = if resolution
+ .outcomes
+ .iter()
+ .any(|outcome| outcome.outcome_kind.is_retryable())
+ {
+ PublishJobStatus::DeliveryUnsatisfiedRetryable
+ } else {
+ PublishJobStatus::Rejected
+ };
+ let last_error = if status == PublishJobStatus::DeliveryUnsatisfiedRetryable {
+ "delivery_unsatisfied"
+ } else {
+ "no_publish_relays"
+ };
self.store.complete_publish_job(
job_id,
- PublishJobStatus::Rejected,
+ status,
resolution.outcomes,
- Some("no_publish_relays".to_owned()),
+ Some(last_error.to_owned()),
)?;
return self.store.job_by_id(job_id);
}
@@ -507,13 +589,6 @@ pub struct PublishRelayResolution {
}
impl PublishRelayResolution {
- fn empty() -> Self {
- Self {
- targets: Vec::new(),
- outcomes: Vec::new(),
- }
- }
-
fn source_by_relay(&self) -> BTreeMap<String, PublishRelaySource> {
self.targets
.iter()
@@ -522,6 +597,26 @@ impl PublishRelayResolution {
}
}
+type PublishRelayResolveFuture<'a> =
+ Pin<Box<dyn Future<Output = Result<Vec<IpAddr>, std::io::Error>> + Send + 'a>>;
+
+trait PublishRelayResolver: Send + Sync {
+ fn resolve<'a>(&'a self, url: &'a RadrootsRelayUrl) -> PublishRelayResolveFuture<'a>;
+}
+
+#[derive(Debug)]
+struct SystemPublishRelayResolver;
+
+impl PublishRelayResolver for SystemPublishRelayResolver {
+ fn resolve<'a>(&'a self, url: &'a RadrootsRelayUrl) -> PublishRelayResolveFuture<'a> {
+ Box::pin(async move {
+ let (host, port) = relay_socket_target(url)?;
+ let addrs = tokio::net::lookup_host((host.as_str(), port)).await?;
+ Ok(addrs.map(|addr| addr.ip()).collect())
+ })
+ }
+}
+
impl PublishProxyStore {
pub fn open(path: PathBuf) -> Result<Self, PublishProxyError> {
if let Some(parent) = path
@@ -1337,6 +1432,43 @@ fn push_resolved_relay(
}
}
+fn relay_resolution_connection_failure(
+ relay_url: impl Into<String>,
+ source: PublishRelaySource,
+ message: impl Into<String>,
+) -> PublishRelayOutcome {
+ PublishRelayOutcome {
+ relay_url: relay_url.into(),
+ source,
+ attempted: false,
+ outcome_kind: PublishRelayOutcomeKind::ConnectionFailed,
+ message: Some(message.into()),
+ latency_ms: None,
+ }
+}
+
+fn relay_socket_target(url: &RadrootsRelayUrl) -> Result<(String, u16), std::io::Error> {
+ let parsed = url::Url::parse(url.as_str())
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
+ let host = parsed
+ .host_str()
+ .filter(|host| !host.is_empty())
+ .ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "relay URL must include a DNS host",
+ )
+ })?
+ .to_owned();
+ let port = parsed.port_or_known_default().ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "relay URL scheme must have a default port",
+ )
+ })?;
+ Ok((host, port))
+}
+
fn relay_url_policy(config: &PublishProxyConfig) -> RadrootsRelayUrlPolicy {
match config.relay_url_policy {
crate::app::config::PublishProxyRelayUrlPolicy::Public => RadrootsRelayUrlPolicy::Public,
@@ -1546,7 +1678,7 @@ mod tests {
PublishProxy, PublishProxyError, PublishProxyStore, generate_bearer_token,
hash_bearer_token, parse_relay_policy,
};
- use crate::app::config::PublishProxyConfig;
+ use crate::app::config::{PublishProxyConfig, PublishProxyRelayUrlPolicy};
use nostr::JsonUtil;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
@@ -1557,6 +1689,8 @@ mod tests {
PublishRelayPolicy, PublishRelaySource, SignedNostrEventWire,
};
use radroots_relay_transport::{RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome};
+ use std::collections::BTreeMap;
+ use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
const RELAY_PRIMARY: &str = "wss://relay.example.com";
@@ -1618,9 +1752,17 @@ mod tests {
fn publish_proxy(
config: PublishProxyConfig,
) -> (PublishProxy, RadrootsMockRelayPublishAdapter) {
+ publish_proxy_with_resolver(config, Arc::new(StaticPublishRelayResolver::new()))
+ }
+
+ fn publish_proxy_with_resolver(
+ config: PublishProxyConfig,
+ resolver: Arc<dyn super::PublishRelayResolver>,
+ ) -> (PublishProxy, RadrootsMockRelayPublishAdapter) {
let adapter = RadrootsMockRelayPublishAdapter::new();
let proxy = PublishProxy::memory(config)
.expect("proxy")
+ .with_relay_resolver(resolver)
.with_publisher(Arc::new(adapter.clone()));
(proxy, adapter)
}
@@ -1654,6 +1796,42 @@ mod tests {
}
}
+ #[derive(Default)]
+ struct StaticPublishRelayResolver {
+ results: BTreeMap<String, Result<Vec<IpAddr>, String>>,
+ }
+
+ impl StaticPublishRelayResolver {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn with_addresses(mut self, url: &str, addresses: Vec<IpAddr>) -> Self {
+ self.results.insert(url.to_owned(), Ok(addresses));
+ self
+ }
+
+ fn with_failure(mut self, url: &str, error: &str) -> Self {
+ self.results.insert(url.to_owned(), Err(error.to_owned()));
+ self
+ }
+ }
+
+ impl super::PublishRelayResolver for StaticPublishRelayResolver {
+ fn resolve<'a>(
+ &'a self,
+ url: &'a radroots_relay_transport::RadrootsRelayUrl,
+ ) -> super::PublishRelayResolveFuture<'a> {
+ Box::pin(async move {
+ match self.results.get(url.as_str()) {
+ Some(Ok(addresses)) => Ok(addresses.clone()),
+ Some(Err(error)) => Err(std::io::Error::other(error.clone())),
+ None => Ok(vec![IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))]),
+ }
+ })
+ }
+ }
+
#[test]
fn token_generation_and_hashing_do_not_store_plaintext() {
let token = generate_bearer_token();
@@ -2012,6 +2190,126 @@ mod tests {
}
#[tokio::test]
+ async fn publish_event_rejects_forbidden_public_dns_destination_before_publish() {
+ let identity = RadrootsIdentity::generate();
+ let resolver = StaticPublishRelayResolver::new()
+ .with_addresses(RELAY_PRIMARY, vec![IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))]);
+ let (proxy, adapter) = publish_proxy_with_resolver(
+ config_with_defaults(vec![RELAY_PRIMARY]),
+ Arc::new(resolver),
+ );
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::DaemonDefaultOnly],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::DaemonDefaultOnly,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(response.job.status, PublishJobStatus::Rejected);
+ assert_eq!(response.job.relays.len(), 1);
+ assert_eq!(
+ response.job.relays[0].outcome_kind,
+ PublishRelayOutcomeKind::RelayUrlRejected
+ );
+ assert!(!response.job.relays[0].attempted);
+ assert!(adapter.captured_raw_events().is_empty());
+ }
+
+ #[tokio::test]
+ async fn publish_event_records_dns_failure_as_unattempted_retryable_outcome() {
+ let identity = RadrootsIdentity::generate();
+ let resolver = StaticPublishRelayResolver::new().with_failure(RELAY_PRIMARY, "no records");
+ let (proxy, adapter) = publish_proxy_with_resolver(
+ config_with_defaults(vec![RELAY_PRIMARY]),
+ Arc::new(resolver),
+ );
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::DaemonDefaultOnly],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::DaemonDefaultOnly,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(
+ response.job.status,
+ PublishJobStatus::DeliveryUnsatisfiedRetryable
+ );
+ assert_eq!(
+ response.job.last_error.as_deref(),
+ Some("delivery_unsatisfied")
+ );
+ assert_eq!(response.job.relays.len(), 1);
+ assert_eq!(
+ response.job.relays[0].outcome_kind,
+ PublishRelayOutcomeKind::ConnectionFailed
+ );
+ assert!(!response.job.relays[0].attempted);
+ assert!(adapter.captured_raw_events().is_empty());
+ }
+
+ #[tokio::test]
+ async fn publish_event_localhost_policy_skips_public_dns_guard() {
+ let identity = RadrootsIdentity::generate();
+ let mut config = config_with_defaults(vec!["ws://localhost:7777"]);
+ config.relay_url_policy = PublishProxyRelayUrlPolicy::Localhost;
+ let resolver = StaticPublishRelayResolver::new()
+ .with_failure("ws://localhost:7777", "localhost resolution should not run");
+ let (proxy, adapter) = publish_proxy_with_resolver(config, Arc::new(resolver));
+ let principal = principal(
+ &proxy,
+ identity.public_key_hex(),
+ vec![PublishRelayPolicy::DaemonDefaultOnly],
+ false,
+ PublishJobVisibility::Own,
+ );
+ let response = proxy
+ .publish_event(
+ &principal,
+ publish_request(
+ signed_event(&identity, "{}"),
+ Vec::new(),
+ PublishRelayPolicy::DaemonDefaultOnly,
+ PublishDeliveryPolicy::Any,
+ None,
+ ),
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(response.job.status, PublishJobStatus::DeliverySatisfied);
+ assert_eq!(response.job.relays[0].relay_url, "ws://localhost:7777");
+ assert!(!adapter.captured_raw_events().is_empty());
+ }
+
+ #[tokio::test]
async fn publish_event_deduplicates_same_intent_and_conflicts_different_intent() {
let identity = RadrootsIdentity::generate();
let (proxy, _adapter) = publish_proxy(config_with_defaults(vec![RELAY_PRIMARY]));