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.
This commit is contained in:
DanConwayDev
2026-08-05 12:45:19 +01:00
parent 96190bef52
commit 80222caf5f
6 changed files with 847 additions and 51 deletions
+10
View File
@@ -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 |
+17 -4
View File
@@ -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 {
+51
View File
@@ -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<Self> {
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.
+4 -1
View File
@@ -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,
};
+225
View File
@@ -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<SocketAddr>,
) -> io::Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let address = listener.local_addr()?;
let expected_host: Arc<str> = 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);
}
}
+540 -46
View File
@@ -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<Vec<SocketAddr>, 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::<IpAddr>() {
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<reqwest::Client, String> {
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<Vec<u8>, 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<usize>,
timeout: Duration,
) -> Result<Vec<u8>, 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<Keys>,
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<ProbeCheck> = 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::<serde_json::Value>().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::<serde_json::Value>(&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<String> = 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()
));
}
}