mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user