From 80222caf5fedf70e542f06406eab9e8d79741221 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 5 Aug 2026 12:45:19 +0100 Subject: [PATCH] feat(grasp-audit): harden discovered-server probes Add an opt-in read-only network policy for scheduled probes of untrusted GRASP endpoints. Validate public wss/https targets, pin WebSocket and HTTP connections to checked addresses, reject redirects and cross-origin clone URLs, and bound DNS and response bodies.\n\nLeave hardening disabled by default so local ws:// development remains supported. --- grasp-audit/README.md | 10 + grasp-audit/src/bin/grasp-audit.rs | 21 +- grasp-audit/src/client.rs | 51 +++ grasp-audit/src/lib.rs | 5 +- grasp-audit/src/pinned_proxy.rs | 225 +++++++++++ grasp-audit/src/probe.rs | 586 ++++++++++++++++++++++++++--- 6 files changed, 847 insertions(+), 51 deletions(-) create mode 100644 grasp-audit/src/pinned_proxy.rs diff --git a/grasp-audit/README.md b/grasp-audit/README.md index c68695e..2604e67 100644 --- a/grasp-audit/README.md +++ b/grasp-audit/README.md @@ -43,12 +43,22 @@ grasp-audit probe --relay wss://relay.ngit.dev --watch 10 # Pipe JSON output to a log file grasp-audit probe --relay wss://relay.ngit.dev --json --watch 10 >> probe.log +# Harden a scheduled read-only probe against untrusted network targets +grasp-audit probe --relay wss://relay.ngit.dev --json --harden-network + # Full write-path check: publish events, git push, verify refs match state # (requires write access; use --nsec for whitelisted relays) grasp-audit probe --relay wss://relay.ngit.dev --create-repo grasp-audit probe --relay wss://relay.ngit.dev --create-repo --nsec nsec1... ``` +`--harden-network` is opt-in so `ws://localhost` remains available for local +development. Scheduled probes of discovered servers should enable it. Hardened +probes require `wss://`, pin validated public addresses for WebSocket and HTTP +connections, refuse redirects and cross-origin clone URLs, and bound response +bodies. They are deliberately read-only and cannot be combined with +`--create-repo`. + **Probe checks (read-only):** | Check | What it verifies | diff --git a/grasp-audit/src/bin/grasp-audit.rs b/grasp-audit/src/bin/grasp-audit.rs index cf10ec5..6eb69f8 100644 --- a/grasp-audit/src/bin/grasp-audit.rs +++ b/grasp-audit/src/bin/grasp-audit.rs @@ -54,6 +54,10 @@ enum Commands { /// Requires write access; use --nsec for whitelisted relays. #[arg(long, default_value_t = false)] create_repo: bool, + + /// Restrict read-only probes to pinned public wss/https targets. + #[arg(long, default_value_t = false)] + harden_network: bool, }, /// Run audit tests against a server @@ -108,6 +112,7 @@ async fn main() -> Result<()> { watch, keys, create_repo, + harden_network, } => { let relay = match relay { Some(r) => r, @@ -131,6 +136,7 @@ async fn main() -> Result<()> { Some(interval) => interval.min(20), None => 20, }; + let options = ProbeOptions { harden_network }; if let Some(interval) = watch { let mut run = 1u64; @@ -138,12 +144,13 @@ async fn main() -> Result<()> { if !json { println!("\n[Run {}]", run); } - let report = grasp_audit::probe::run_probe( + let report = grasp_audit::probe::run_probe_with_options( &relay, keys.clone(), read_only, timeout, overall_secs, + options, ) .await; if json { @@ -155,9 +162,15 @@ async fn main() -> Result<()> { tokio::time::sleep(Duration::from_secs(interval)).await; } } else { - let report = - grasp_audit::probe::run_probe(&relay, keys, read_only, timeout, overall_secs) - .await; + let report = grasp_audit::probe::run_probe_with_options( + &relay, + keys, + read_only, + timeout, + overall_secs, + options, + ) + .await; if json { report.print_json(); } else { diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index 9e10c75..b62067d 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs @@ -197,6 +197,57 @@ impl AuditClient { }) } + /// Create a keyed audit client whose relay connection is routed through a + /// caller-supplied proxy. + pub(crate) async fn new_with_keys_and_proxy( + relay_url: &str, + config: AuditConfig, + keys: Keys, + proxy: Proxy, + ) -> Result { + let maintainer_keys = Keys::generate(); + let recursive_maintainer_keys = Keys::generate(); + let pr_author_keys = Keys::generate(); + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .proxy(proxy) + .build(); + + client.add_relay(relay_url).await?; + client.connect().await; + + let mut attempts = 0; + let mut connected = false; + while attempts < 50 { + tokio::time::sleep(Duration::from_millis(100)).await; + + let relays = client.relays().await; + connected = relays.values().any(|relay| relay.status().is_connected()); + if connected { + break; + } + + attempts += 1; + } + + if !connected { + return Err(anyhow!("Failed to connect to relay at '{}'", relay_url)); + } + + tokio::time::sleep(Duration::from_millis(200)).await; + + Ok(Self { + client, + config, + keys, + maintainer_keys, + recursive_maintainer_keys, + pr_author_keys, + fixture_cache: Arc::new(Mutex::new(HashMap::new())), + typed_fixture_cache: Arc::new(Mutex::new(HashMap::new())), + }) + } + /// Get the fixture cache for TestContext usage /// /// This cache is shared across all TestContext instances created from this client. diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index e3e99d4..e9f60be 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -33,6 +33,7 @@ pub mod client; pub mod fixtures; pub mod git; pub mod isolation; +mod pinned_proxy; pub mod probe; pub mod report; pub mod result; @@ -65,7 +66,9 @@ pub use fixtures::{ RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, }; pub use git::{git_command, GRASP_AUDIT_NSEC_ENV}; -pub use probe::{run_probe, ProbeCheck, ProbeCheckName, ProbeReport}; +pub use probe::{ + run_probe, run_probe_with_options, ProbeCheck, ProbeCheckName, ProbeOptions, ProbeReport, +}; pub use report::{ AuditOutcome, AuditSpec, AuditSuite, ResultCounts, SpecReport, TestOutcome, TestReport, }; diff --git a/grasp-audit/src/pinned_proxy.rs b/grasp-audit/src/pinned_proxy.rs new file mode 100644 index 0000000..ea2a50b --- /dev/null +++ b/grasp-audit/src/pinned_proxy.rs @@ -0,0 +1,225 @@ +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::{JoinHandle, JoinSet}; + +/// A loopback-only SOCKS5 proxy that connects exclusively to pre-resolved +/// addresses for one expected origin. +pub(crate) struct PinnedSocksProxy { + address: SocketAddr, + task: JoinHandle<()>, +} + +impl PinnedSocksProxy { + pub(crate) async fn start( + expected_host: String, + expected_port: u16, + addresses: Vec, + ) -> io::Result { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let address = listener.local_addr()?; + let expected_host: Arc = expected_host.into(); + let addresses = Arc::new(addresses); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let Ok((stream, _)) = accepted else { + break; + }; + let expected_host = Arc::clone(&expected_host); + let addresses = Arc::clone(&addresses); + connections.spawn(async move { + let _ = proxy_connection( + stream, + &expected_host, + expected_port, + &addresses, + ) + .await; + }); + } + Some(_) = connections.join_next(), if !connections.is_empty() => {} + } + } + }); + + Ok(Self { address, task }) + } + + pub(crate) fn address(&self) -> SocketAddr { + self.address + } +} + +impl Drop for PinnedSocksProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn proxy_connection( + mut client: TcpStream, + expected_host: &str, + expected_port: u16, + addresses: &[SocketAddr], +) -> io::Result<()> { + let mut greeting = [0_u8; 2]; + client.read_exact(&mut greeting).await?; + if greeting[0] != 5 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported SOCKS version", + )); + } + + let mut methods = vec![0_u8; greeting[1] as usize]; + client.read_exact(&mut methods).await?; + if !methods.contains(&0) { + client.write_all(&[5, 0xff]).await?; + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "SOCKS client did not offer no-auth", + )); + } + client.write_all(&[5, 0]).await?; + + let mut request = [0_u8; 4]; + client.read_exact(&mut request).await?; + if request[..3] != [5, 1, 0] { + send_reply(&mut client, 7).await?; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported SOCKS request", + )); + } + + let requested_host = match request[3] { + 1 => { + let mut octets = [0_u8; 4]; + client.read_exact(&mut octets).await?; + IpAddr::from(octets).to_string() + } + 3 => { + let length = client.read_u8().await? as usize; + let mut bytes = vec![0_u8; length]; + client.read_exact(&mut bytes).await?; + String::from_utf8(bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid SOCKS domain"))? + } + 4 => { + let mut octets = [0_u8; 16]; + client.read_exact(&mut octets).await?; + IpAddr::from(octets).to_string() + } + _ => { + send_reply(&mut client, 8).await?; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported SOCKS address type", + )); + } + }; + let requested_port = client.read_u16().await?; + + if !requested_host.eq_ignore_ascii_case(expected_host) || requested_port != expected_port { + send_reply(&mut client, 2).await?; + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "SOCKS target does not match hardened relay", + )); + } + + let mut upstream = None; + for address in addresses { + if let Ok(stream) = TcpStream::connect(address).await { + upstream = Some(stream); + break; + } + } + let Some(mut upstream) = upstream else { + send_reply(&mut client, 5).await?; + return Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + "all pinned relay addresses failed", + )); + }; + + send_reply(&mut client, 0).await?; + tokio::io::copy_bidirectional(&mut client, &mut upstream).await?; + Ok(()) +} + +async fn send_reply(stream: &mut TcpStream, status: u8) -> io::Result<()> { + stream.write_all(&[5, status, 0, 1, 0, 0, 0, 0, 0, 0]).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn connects_only_to_the_pinned_address() { + let upstream = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let upstream_address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (mut stream, _) = upstream.accept().await.unwrap(); + let byte = stream.read_u8().await.unwrap(); + stream.write_u8(byte + 1).await.unwrap(); + }); + + let proxy = PinnedSocksProxy::start("relay.example".into(), 443, vec![upstream_address]) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.address()).await.unwrap(); + client.write_all(&[5, 1, 0]).await.unwrap(); + let mut method = [0_u8; 2]; + client.read_exact(&mut method).await.unwrap(); + assert_eq!(method, [5, 0]); + + let host = b"relay.example"; + client + .write_all(&[5, 1, 0, 3, host.len() as u8]) + .await + .unwrap(); + client.write_all(host).await.unwrap(); + client.write_u16(443).await.unwrap(); + let mut reply = [0_u8; 10]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply[1], 0); + + client.write_u8(41).await.unwrap(); + assert_eq!(client.read_u8().await.unwrap(), 42); + upstream_task.await.unwrap(); + } + + #[tokio::test] + async fn rejects_a_different_origin() { + let proxy = PinnedSocksProxy::start( + "relay.example".into(), + 443, + vec!["127.0.0.1:9".parse().unwrap()], + ) + .await + .unwrap(); + let mut client = TcpStream::connect(proxy.address()).await.unwrap(); + client.write_all(&[5, 1, 0]).await.unwrap(); + let mut method = [0_u8; 2]; + client.read_exact(&mut method).await.unwrap(); + + let host = b"metadata.internal"; + client + .write_all(&[5, 1, 0, 3, host.len() as u8]) + .await + .unwrap(); + client.write_all(host).await.unwrap(); + client.write_u16(443).await.unwrap(); + let mut reply = [0_u8; 10]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply[1], 2); + } +} diff --git a/grasp-audit/src/probe.rs b/grasp-audit/src/probe.rs index c8abff1..3cf0023 100644 --- a/grasp-audit/src/probe.rs +++ b/grasp-audit/src/probe.rs @@ -7,9 +7,14 @@ use crate::audit::AuditConfig; use crate::client::AuditClient; use crate::fixtures::{create_commit, init_local_repo, try_push}; +use crate::pinned_proxy::PinnedSocksProxy; use nostr_sdk::prelude::*; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::time::{Duration, Instant}; +const MAX_NIP11_BODY_BYTES: usize = 256 * 1024; +const MAX_GIT_ADVERTISEMENT_BYTES: usize = 1024 * 1024; + /// Stable identifiers for probe checks, in execution order. /// /// The enum is the only catalog of check names. Probe JSON retains its @@ -234,6 +239,189 @@ fn now_iso8601() -> String { format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mo, d, h, m, s) } +/// Optional network policy for probes. +#[derive(Debug, Clone, Copy, Default)] +pub struct ProbeOptions { + /// Reject non-public targets, cross-origin clone URLs, redirects, and + /// oversized HTTP bodies. Disabled by default so local development + /// targets remain usable. + pub harden_network: bool, +} + +async fn validate_public_url(url: &reqwest::Url, deadline: Instant) -> Result<(), String> { + if url.query().is_some() || url.fragment().is_some() { + return Err("URL query strings and fragments are not allowed".to_string()); + } + resolve_public_url(url, deadline).await.map(|_| ()) +} + +async fn resolve_public_url( + url: &reqwest::Url, + deadline: Instant, +) -> Result, String> { + if !matches!(url.scheme(), "wss" | "https") { + return Err("network hardening requires wss/https".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("URL credentials are not allowed".to_string()); + } + let host = url + .host_str() + .ok_or_else(|| "URL has no host".to_string())?; + let port = url + .port_or_known_default() + .ok_or_else(|| "URL has no usable port".to_string())?; + + if let Ok(ip) = host.parse::() { + if !is_public_ip(ip) { + return Err(format!("non-public target address is not allowed: {ip}")); + } + return Ok(vec![SocketAddr::new(ip, port)]); + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!("overall timeout before resolving {host}")); + } + let timeout = remaining.min(Duration::from_secs(5)); + let resolved = tokio::time::timeout(timeout, tokio::net::lookup_host((host, port))) + .await + .map_err(|_| { + if Instant::now() >= deadline { + format!("overall timeout while resolving {host}") + } else { + format!("timed out resolving {host}") + } + })? + .map_err(|error| format!("failed to resolve {host}: {error}"))?; + let addresses: Vec<_> = resolved.collect(); + if addresses.is_empty() { + return Err(format!("{host} resolved to no addresses")); + } + if let Some(address) = addresses.iter().find(|address| !is_public_ip(address.ip())) { + return Err(format!( + "{host} resolved to prohibited address {}", + address.ip() + )); + } + Ok(addresses) +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4_mapped() { + return is_public_ipv4(v4); + } + + // Public relay targets must be in the currently allocated global-unicast + // space (2000::/3) and outside every special-purpose range within it. + // This intentionally fails closed for future allocations. Keep this list + // aligned with the IANA IPv6 Special-Purpose Address Registry. + const SPECIAL_PURPOSE: &[(u128, u8)] = &[ + (0x2001_0000_0000_0000_0000_0000_0000_0000, 23), // IETF protocols + (0x2001_0db8_0000_0000_0000_0000_0000_0000, 32), // documentation + (0x2002_0000_0000_0000_0000_0000_0000_0000, 16), // 6to4 + (0x2620_004f_8000_0000_0000_0000_0000_0000, 48), // AS112 + (0x3fff_0000_0000_0000_0000_0000_0000_0000, 20), // documentation + ]; + + ipv6_has_prefix(ip, 0x2000_0000_0000_0000_0000_0000_0000_0000, 3) + && !SPECIAL_PURPOSE + .iter() + .any(|(network, prefix)| ipv6_has_prefix(ip, *network, *prefix)) +} + +fn ipv6_has_prefix(ip: Ipv6Addr, network: u128, prefix_len: u8) -> bool { + let shift = 128 - u32::from(prefix_len); + (u128::from(ip) >> shift) == (network >> shift) +} + +fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { + left.scheme() == right.scheme() + && left.host_str() == right.host_str() + && left.port_or_known_default() == right.port_or_known_default() +} + +async fn probe_http_client( + url: &reqwest::Url, + harden_network: bool, + deadline: Instant, +) -> Result { + let mut builder = reqwest::Client::builder(); + if harden_network { + let addresses = resolve_public_url(url, deadline).await?; + let host = url + .host_str() + .ok_or_else(|| "URL has no host".to_string())?; + builder = builder.redirect(reqwest::redirect::Policy::none()); + builder = builder.resolve_to_addrs(host, &addresses); + } + builder.build().map_err(|error| error.to_string()) +} + +async fn read_limited_body( + mut response: reqwest::Response, + max_bytes: usize, +) -> Result, String> { + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(format!("response exceeds {max_bytes} byte limit")); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|error| error.to_string())? { + if body.len().saturating_add(chunk.len()) > max_bytes { + return Err(format!("response exceeds {max_bytes} byte limit")); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn read_probe_body( + response: reqwest::Response, + max_bytes: Option, + timeout: Duration, +) -> Result, String> { + tokio::time::timeout(timeout, async { + match max_bytes { + Some(max_bytes) => read_limited_body(response, max_bytes).await, + None => response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| error.to_string()), + } + }) + .await + .map_err(|_| "timeout reading response body".to_string())? +} + // ============================================================ // Main probe function // ============================================================ @@ -253,12 +441,109 @@ pub async fn run_probe( read_only: bool, timeout_secs: u64, overall_secs: u64, +) -> ProbeReport { + run_probe_with_options( + relay_url, + keys, + read_only, + timeout_secs, + overall_secs, + ProbeOptions::default(), + ) + .await +} + +/// Run a probe with an explicit network policy. +pub async fn run_probe_with_options( + relay_url: &str, + keys: Option, + read_only: bool, + timeout_secs: u64, + overall_secs: u64, + options: ProbeOptions, ) -> ProbeReport { let total_start = Instant::now(); let deadline = total_start + Duration::from_secs(overall_secs); let timestamp = now_iso8601(); let mut checks: Vec = Vec::new(); + if options.harden_network && !read_only { + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed: false, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks: vec![ProbeCheck { + name: ProbeCheckName::Prepare.as_str(), + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some("--harden-network cannot be combined with --create-repo".to_string()), + }], + }; + } + + let pinned_proxy = if options.harden_network { + match reqwest::Url::parse(relay_url) { + Ok(url) => { + let result = async { + if url.query().is_some() || url.fragment().is_some() { + return Err("URL query strings and fragments are not allowed".to_string()); + } + let addresses = resolve_public_url(&url, deadline).await?; + let host = url + .host_str() + .ok_or_else(|| "URL has no host".to_string())?; + let port = url + .port_or_known_default() + .ok_or_else(|| "URL has no usable port".to_string())?; + PinnedSocksProxy::start(host.to_string(), port, addresses) + .await + .map_err(|error| format!("failed to start hardened connection: {error}")) + } + .await; + match result { + Ok(proxy) => Some(proxy), + Err(error) => { + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed: false, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks: vec![ProbeCheck { + name: ProbeCheckName::Prepare.as_str(), + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some(error), + }], + }; + } + } + } + Err(error) => { + return ProbeReport { + relay_url: relay_url.to_string(), + timestamp, + all_passed: false, + total_duration_ms: total_start.elapsed().as_millis() as u64, + checks: vec![ProbeCheck { + name: ProbeCheckName::Prepare.as_str(), + passed: false, + skipped: false, + duration_ms: 0, + detail: None, + error: Some(format!("invalid relay URL: {error}")), + }], + }; + } + } + } else { + None + }; + /// Fill all check names not yet present in `checks` as skipped with the /// given reason, then return a finished ProbeReport. /// @@ -414,12 +699,24 @@ pub async fn run_probe( ); } let step1_start = Instant::now(); - let client_result = tokio::time::timeout( - deadline.saturating_duration_since(Instant::now()), - AuditClient::new_with_keys(relay_url, config.clone(), keys.clone()), - ) - .await - .unwrap_or_else(|_| Err(anyhow::anyhow!("overall timeout"))); + let connect = async { + match pinned_proxy.as_ref() { + Some(proxy) => { + AuditClient::new_with_keys_and_proxy( + relay_url, + config.clone(), + keys.clone(), + Proxy::all(proxy.address()), + ) + .await + } + None => AuditClient::new_with_keys(relay_url, config.clone(), keys.clone()).await, + } + }; + let client_result = + tokio::time::timeout(deadline.saturating_duration_since(Instant::now()), connect) + .await + .unwrap_or_else(|_| Err(anyhow::anyhow!("overall timeout"))); let step1_ms = step1_start.elapsed().as_millis() as u64; let client = match client_result { @@ -473,15 +770,23 @@ pub async fn run_probe( ); } let step2_start = Instant::now(); - let http_client = reqwest::Client::new(); + let http_url = reqwest::Url::parse(&http_base); + let http_client = match http_url { + Ok(ref url) => probe_http_client(url, options.harden_network, deadline).await, + Err(error) => Err(format!("invalid NIP-11 URL: {error}")), + }; let nip11_result = tokio::time::timeout( deadline .saturating_duration_since(Instant::now()) .min(Duration::from_secs(timeout_secs)), - http_client - .get(&http_base) - .header("Accept", "application/nostr+json") - .send(), + async { + http_client? + .get(&http_base) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|error| error.to_string()) + }, ) .await; @@ -489,28 +794,59 @@ pub async fn run_probe( match nip11_result { Ok(Ok(resp)) if resp.status().is_success() => { - let detail = resp.json::().await.ok().map(|v| { - let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); - // software is typically a repo URL; take the last path segment - let software = v - .get("software") - .and_then(|s| s.as_str()) - .map(|s| s.trim_end_matches('/').rsplit('/').next().unwrap_or(s)) - .unwrap_or("unknown"); - let version = v - .get("version") - .and_then(|ver| ver.as_str()) - .unwrap_or("unknown"); - format!("{} ({} v{})", name, software, version) - }); - checks.push(ProbeCheck { - name: ProbeCheckName::Nip11Fetch.as_str(), - passed: true, - skipped: false, - duration_ms: step2_ms, - detail, - error: None, - }); + let body = read_probe_body( + resp, + options.harden_network.then_some(MAX_NIP11_BODY_BYTES), + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(timeout_secs)), + ) + .await; + match body { + Ok(body) => { + let detail = + serde_json::from_slice::(&body) + .ok() + .map(|value| { + let name = value + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or("unknown"); + let software = value + .get("software") + .and_then(|software| software.as_str()) + .map(|software| { + software + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(software) + }) + .unwrap_or("unknown"); + let version = value + .get("version") + .and_then(|version| version.as_str()) + .unwrap_or("unknown"); + format!("{name} ({software} v{version})") + }); + checks.push(ProbeCheck { + name: ProbeCheckName::Nip11Fetch.as_str(), + passed: true, + skipped: false, + duration_ms: step2_ms, + detail, + error: None, + }); + } + Err(error) => checks.push(ProbeCheck { + name: ProbeCheckName::Nip11Fetch.as_str(), + passed: false, + skipped: false, + duration_ms: step2_ms, + detail: None, + error: Some(error), + }), + } } Ok(Ok(resp)) => { checks.push(ProbeCheck { @@ -1016,12 +1352,40 @@ pub async fn run_probe( } let step6_start = Instant::now(); let refs_url = format!("{}/info/refs?service=git-upload-pack", fetch_url); - let http_client = reqwest::Client::new(); + let fetch_url_parsed = reqwest::Url::parse(&fetch_url); + let refs_url_parsed = reqwest::Url::parse(&refs_url); + let http_base_parsed = reqwest::Url::parse(&http_base); + let http_client = match (&fetch_url_parsed, &refs_url_parsed, &http_base_parsed) { + (Ok(fetch), Ok(url), Ok(base)) + if !options.harden_network || same_origin(fetch, base) => + { + if options.harden_network { + match validate_public_url(fetch, deadline).await { + Ok(()) => probe_http_client(url, true, deadline).await, + Err(error) => Err(error), + } + } else { + probe_http_client(url, false, deadline).await + } + } + (Ok(_), Ok(_), Ok(_)) => { + Err("announcement clone URL leaves audited service origin".to_string()) + } + (Err(error), _, _) => Err(format!("invalid clone URL: {error}")), + (_, Err(error), _) => Err(format!("invalid Git URL: {error}")), + (_, _, Err(error)) => Err(format!("invalid service URL: {error}")), + }; let refs_result = tokio::time::timeout( deadline .saturating_duration_since(Instant::now()) .min(Duration::from_secs(timeout_secs)), - http_client.get(&refs_url).send(), + async { + http_client? + .get(&refs_url) + .send() + .await + .map_err(|error| error.to_string()) + }, ) .await; let step6_ms = step6_start.elapsed().as_millis() as u64; @@ -1029,16 +1393,41 @@ pub async fn run_probe( // Capture body for git_refs_match_state if fetch succeeds let refs_body_fallback: Option = match refs_result { Ok(Ok(resp)) if resp.status().is_success() => { - let body = resp.text().await.unwrap_or_default(); - checks.push(ProbeCheck { - name: ProbeCheckName::GitFetchRefs.as_str(), - passed: true, - skipped: false, - duration_ms: step6_ms, - detail: None, - error: None, - }); - Some(body) + let body = read_probe_body( + resp, + options + .harden_network + .then_some(MAX_GIT_ADVERTISEMENT_BYTES), + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(timeout_secs)), + ) + .await + .map(|body| String::from_utf8_lossy(&body).into_owned()); + match body { + Ok(body) => { + checks.push(ProbeCheck { + name: ProbeCheckName::GitFetchRefs.as_str(), + passed: true, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: None, + }); + Some(body) + } + Err(error) => { + checks.push(ProbeCheck { + name: ProbeCheckName::GitFetchRefs.as_str(), + passed: false, + skipped: false, + duration_ms: step6_ms, + detail: None, + error: Some(error), + }); + None + } + } } Ok(Ok(resp)) => { checks.push(ProbeCheck { @@ -1226,7 +1615,7 @@ pub async fn run_probe( #[cfg(test)] mod tests { - use super::ProbeCheckName; + use super::*; #[test] fn probe_check_catalog_preserves_public_names_and_order() { @@ -1252,4 +1641,109 @@ mod tests { "\"git_repo_initialised\"" ); } + + #[test] + fn hardening_rejects_private_and_special_addresses() { + for address in [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "192.168.0.1", + "169.254.1.1", + "192.0.2.1", + "::1", + "fc00::1", + "fe80::1", + "fec0::1", + "2001:db8::1", + "64:ff9b::8.8.8.8", + "64:ff9b:1::1", + "100::1", + "100:0:0:1::1", + "2001::1", + "2002:0808:0808::1", + "2620:4f:8000::1", + "3fff::1", + "5f00::1", + "4000::1", + "::8.8.8.8", + ] { + assert!( + !is_public_ip(address.parse().unwrap()), + "{address} must be rejected" + ); + } + assert!(is_public_ip("8.8.8.8".parse().unwrap())); + assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap())); + } + + #[tokio::test] + async fn hardened_urls_reject_queries_and_fragments() { + for url in ["wss://8.8.8.8/?query=true", "wss://8.8.8.8/#fragment"] { + assert!(validate_public_url( + &reqwest::Url::parse(url).unwrap(), + Instant::now() + Duration::from_secs(5), + ) + .await + .is_err()); + } + } + + #[tokio::test] + async fn hardened_http_requests_allow_generated_git_query() { + let url = reqwest::Url::parse("https://8.8.8.8/info/refs?service=git-upload-pack").unwrap(); + assert!( + probe_http_client(&url, true, Instant::now() + Duration::from_secs(5)) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn hardened_resolution_honours_an_expired_overall_deadline() { + let url = reqwest::Url::parse("https://example.com").unwrap(); + let error = resolve_public_url(&url, Instant::now()).await.unwrap_err(); + + assert!(error.contains("overall timeout")); + } + + #[tokio::test] + async fn hardening_rejects_write_probes_before_connecting() { + let report = run_probe_with_options( + "wss://example.com", + None, + false, + 30, + 60, + ProbeOptions { + harden_network: true, + }, + ) + .await; + + assert!(!report.all_passed); + assert_eq!(report.checks.len(), 1); + assert_eq!(report.checks[0].name, ProbeCheckName::Prepare.as_str()); + assert_eq!( + report.checks[0].error.as_deref(), + Some("--harden-network cannot be combined with --create-repo") + ); + } + + #[test] + fn origin_comparison_includes_scheme_host_and_port() { + let base = reqwest::Url::parse("https://example.com/repo").unwrap(); + assert!(same_origin( + &base, + &reqwest::Url::parse("https://example.com/other").unwrap() + )); + assert!(!same_origin( + &base, + &reqwest::Url::parse("https://example.net/repo").unwrap() + )); + assert!(!same_origin( + &base, + &reqwest::Url::parse("https://example.com:8443/repo").unwrap() + )); + } }