mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
A public mirror gains nothing from dialing a GRASP-08 private service: the service will never admit it, and even attempting the connection performs an AUTH exchange with a relay that only wants members. Worse, retry machinery would hammer it indefinitely. Approach: public instances fetch the NIP-11 document before the WebSocket dial (`preflight_limit_hints`). When it advertises GRASP-08, the worker returns a new `ConnectAttemptOutcome::PrivateService` without dialing. The actor parks the relay in `private_service_relays` (warn once, stable message tests grep for), retires all sync state without the re-registration path, and both `register_relay` and `schedule_connect_relay` refuse parked targets thereafter. Private instances treat GRASP-08 peers as ordinary sync targets and skip the probe. Session limit hints deliberately keep coming from the existing post-connect fetch: reusing the pre-dial probe for hints would remove the post-connect setup window whose stale-success handling (disconnect during NIP-11 setup) is separately guaranteed and tested. Correctness assumptions: the pre-dial NIP-11 probe is an outbound TCP connection, so `preflight_limit_hints` re-runs the resolved outbound target policy for event-directed URLs and skips the HTTP request entirely on rejection - `connect()` then fails with the same policy rejection through its own gate (tests/outbound_policy.rs stays green). The park set is in-memory by design: a service that stops being private becomes reachable again after a process restart at the latest. Deliberately excluded: private-instance behavior toward GRASP-08 peers (NIP-98 credentials on git fetches) lands separately. Test infrastructure: `wait_for_log_line` moved from outbound_policy.rs into the shared sync helpers; TestRelay gained a sync constructor with identity publication disabled so log assertions about the bootstrap connection are not confounded by user-index traffic. SetupDropRelay now answers pre-dial NIP-11 probes directly and only runs its drop-during-setup choreography for a fetch that arrives during a live WebSocket session; the naughty-list scheduling test accounts for the probe as a second accepted connection on the first attempt. Validation: new tests/sync/outbound_auth.rs proves the park warning appears exactly once and that, across a 2s observation window, the private bootstrap relay is never connected to and no NIP-42 authentication occurs. cargo test --test sync -- sync::outbound_auth sync::stale_connect_result sync::naughty_list_scheduling and --test outbound_policy pass.
417 lines
15 KiB
Rust
417 lines
15 KiB
Rust
//! Outbound Target Policy Regression Tests
|
|
//!
|
|
//! Repository announcements, state events, and PR events are untrusted input,
|
|
//! but their `relays` and `clone` tags feed proactive sync's outbound
|
|
//! WebSocket connections and `git fetch` subprocesses. These tests prove that
|
|
//! event-directed URLs pointing at loopback, private, link-local, or
|
|
//! local-name targets are rejected before any outbound connection is made
|
|
//! (SSRF protection), while the operator-configured bootstrap relay keeps
|
|
//! working even when it is local.
|
|
//!
|
|
//! Unlike the rest of the suite, these tests run the relay with the
|
|
//! production outbound target policy (the `TestRelay` fixture normally sets
|
|
//! `NGIT_SYNC_ALLOW_NON_GLOBAL_TARGETS=true` because the whole test
|
|
//! infrastructure lives on loopback).
|
|
//!
|
|
//! Observability: rejected targets produce `Rejecting event-directed ...`
|
|
//! warnings in the relay subprocess log (a bounded observable condition), and
|
|
//! attacker endpoints are real TCP listeners that count every accepted
|
|
//! connection, proving no dial or fetch reached them.
|
|
|
|
mod common;
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use common::{create_state_event, wait_for_log_line, MockRelay, TestClient, TestRelay};
|
|
use nostr_sdk::prelude::*;
|
|
|
|
/// A commit hash that exists nowhere, keeping state events in purgatory so
|
|
/// the git sync loop must try the announced clone URLs.
|
|
const MISSING_COMMIT: &str = "1111111111111111111111111111111111111111";
|
|
|
|
/// Deadline for log-based observable conditions. Generous for loaded CI.
|
|
const LOG_DEADLINE: Duration = Duration::from_secs(30);
|
|
|
|
/// Start a TCP listener standing in for attacker infrastructure on loopback.
|
|
///
|
|
/// Returns the port and a counter of accepted connections. Any accepted
|
|
/// connection means the relay dialled an event-directed forbidden target.
|
|
async fn start_counting_listener() -> (u16, Arc<AtomicUsize>) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind counting listener");
|
|
let port = listener.local_addr().expect("listener addr").port();
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let task_count = Arc::clone(&count);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
if listener.accept().await.is_ok() {
|
|
task_count.fetch_add(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
});
|
|
(port, count)
|
|
}
|
|
|
|
/// Build a repository announcement with explicit clone and relay URL lists.
|
|
fn announcement_with_urls(
|
|
keys: &Keys,
|
|
identifier: &str,
|
|
clone_urls: Vec<String>,
|
|
relay_urls: Vec<String>,
|
|
) -> Event {
|
|
announcement_with_urls_at(keys, identifier, clone_urls, relay_urls, Timestamp::now())
|
|
}
|
|
|
|
/// Same as [`announcement_with_urls`] with an explicit `created_at`, so a
|
|
/// test can publish a genuine replacement (newer timestamp, different id).
|
|
fn announcement_with_urls_at(
|
|
keys: &Keys,
|
|
identifier: &str,
|
|
clone_urls: Vec<String>,
|
|
relay_urls: Vec<String>,
|
|
created_at: Timestamp,
|
|
) -> Event {
|
|
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
.tags(vec![
|
|
Tag::identifier(identifier),
|
|
Tag::custom("clone", clone_urls),
|
|
Tag::custom("relays", relay_urls),
|
|
])
|
|
.custom_created_at(created_at)
|
|
.finalize(keys)
|
|
.expect("sign announcement")
|
|
}
|
|
|
|
/// Clone URL for the relay's own service, so announcements are admitted.
|
|
fn own_clone_url(relay: &TestRelay, keys: &Keys, identifier: &str) -> String {
|
|
let npub = keys.public_key().to_bech32().expect("npub");
|
|
format!("http://{}/{}/{}.git", relay.domain(), npub, identifier)
|
|
}
|
|
|
|
/// Scenario 1: an announcement listing a loopback relay URL must not cause an
|
|
/// outbound WebSocket connection.
|
|
#[tokio::test]
|
|
async fn event_directed_loopback_relay_is_rejected_without_connection() {
|
|
let relay = TestRelay::start_with_sync_enforced_outbound_policy(None).await;
|
|
let (attacker_port, connection_count) = start_counting_listener().await;
|
|
let attacker_relay = format!("ws://127.0.0.1:{attacker_port}");
|
|
|
|
let keys = Keys::generate();
|
|
let identifier = "loopback-relay-target";
|
|
let announcement = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![own_clone_url(&relay, &keys, identifier)],
|
|
vec![format!("ws://{}", relay.domain()), attacker_relay.clone()],
|
|
);
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
client
|
|
.send_event(&announcement)
|
|
.await
|
|
.expect("announcement listing the service must be admitted");
|
|
|
|
let rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting event-directed sync relay target")
|
|
&& line.contains(&attacker_relay)
|
|
})
|
|
.await;
|
|
assert!(
|
|
rejected,
|
|
"relay must log rejection of the loopback event-directed relay target"
|
|
);
|
|
assert_eq!(
|
|
connection_count.load(Ordering::SeqCst),
|
|
0,
|
|
"no outbound connection may reach the loopback relay target"
|
|
);
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
}
|
|
|
|
/// Scenario 2: an announcement listing a loopback git clone URL must not
|
|
/// cause a `git fetch` (or any request) to that endpoint.
|
|
#[tokio::test]
|
|
async fn event_directed_loopback_git_url_is_rejected_without_fetch() {
|
|
let relay = TestRelay::start_with_sync_enforced_outbound_policy(None).await;
|
|
let (attacker_port, connection_count) = start_counting_listener().await;
|
|
let attacker_git = format!("http://127.0.0.1:{attacker_port}/repo.git");
|
|
|
|
let keys = Keys::generate();
|
|
let identifier = "loopback-git-target";
|
|
let own_url = own_clone_url(&relay, &keys, identifier);
|
|
let own_relay = format!("ws://{}", relay.domain());
|
|
|
|
// Backdated so the later republication is a genuine replacement.
|
|
let announcement = announcement_with_urls_at(
|
|
&keys,
|
|
identifier,
|
|
vec![own_url.clone(), attacker_git.clone()],
|
|
vec![own_relay.clone()],
|
|
Timestamp::from(Timestamp::now().as_secs().saturating_sub(5)),
|
|
);
|
|
// A state event whose commit exists nowhere keeps the identifier pending,
|
|
// so the purgatory git sync loop must try the announced clone URLs.
|
|
let state = create_state_event(
|
|
&keys,
|
|
identifier,
|
|
&[("main", MISSING_COMMIT)],
|
|
&[],
|
|
&[own_url.as_str(), attacker_git.as_str()],
|
|
&[own_relay.as_str()],
|
|
)
|
|
.expect("build state event");
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
client
|
|
.send_event(&announcement)
|
|
.await
|
|
.expect("announcement listing the service must be admitted");
|
|
client
|
|
.send_event(&state)
|
|
.await
|
|
.expect("state event must be admitted to purgatory");
|
|
|
|
// A directly submitted state event waits three minutes before the git
|
|
// sync loop tries its clone URLs. Republishing the announcement while
|
|
// events are pending re-enqueues the identifier for immediate sync,
|
|
// which is the ordinary "author updates the announcement" flow.
|
|
let refreshed = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![own_url.clone(), attacker_git.clone()],
|
|
vec![own_relay.clone()],
|
|
);
|
|
client
|
|
.send_event(&refreshed)
|
|
.await
|
|
.expect("republished announcement must be admitted");
|
|
|
|
let rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting event-directed git fetch target") && line.contains(&attacker_git)
|
|
})
|
|
.await;
|
|
assert!(
|
|
rejected,
|
|
"relay must log rejection of the loopback event-directed git fetch target"
|
|
);
|
|
assert_eq!(
|
|
connection_count.load(Ordering::SeqCst),
|
|
0,
|
|
"no git fetch or request may reach the loopback git target"
|
|
);
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
}
|
|
|
|
/// Scenario 3: private, link-local, CGNAT, unspecified, and multicast literal
|
|
/// IP addresses are all rejected for event-directed relay targets.
|
|
/// `100.125.184.46` reproduces the address observed in production logs.
|
|
#[tokio::test]
|
|
async fn event_directed_non_global_ip_relays_are_rejected() {
|
|
let relay = TestRelay::start_with_sync_enforced_outbound_policy(None).await;
|
|
|
|
let forbidden_relays = [
|
|
"ws://10.11.12.13:4444".to_string(),
|
|
"ws://169.254.9.9:4444".to_string(),
|
|
"ws://100.125.184.46:7334".to_string(),
|
|
"ws://0.0.0.0:4444".to_string(),
|
|
"ws://224.0.0.1:4444".to_string(),
|
|
];
|
|
|
|
let keys = Keys::generate();
|
|
let identifier = "non-global-ip-targets";
|
|
let mut relay_urls = vec![format!("ws://{}", relay.domain())];
|
|
relay_urls.extend(forbidden_relays.iter().cloned());
|
|
let announcement = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![own_clone_url(&relay, &keys, identifier)],
|
|
relay_urls,
|
|
);
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
client
|
|
.send_event(&announcement)
|
|
.await
|
|
.expect("announcement listing the service must be admitted");
|
|
|
|
for target in &forbidden_relays {
|
|
let rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting event-directed sync relay target") && line.contains(target)
|
|
})
|
|
.await;
|
|
assert!(rejected, "{target} must be rejected as a sync relay target");
|
|
}
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
}
|
|
|
|
/// Scenario 4: local hostnames and URLs with embedded credentials are
|
|
/// rejected for event-directed targets.
|
|
#[tokio::test]
|
|
async fn event_directed_local_hostname_and_credential_urls_are_rejected() {
|
|
let relay = TestRelay::start_with_sync_enforced_outbound_policy(None).await;
|
|
let (local_port, connection_count) = start_counting_listener().await;
|
|
let localhost_relay = format!("ws://localhost:{local_port}");
|
|
let credential_relay = "wss://user:secret@203.0.113.9:4444".to_string();
|
|
|
|
let keys = Keys::generate();
|
|
let identifier = "local-name-and-credentials";
|
|
let announcement = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![own_clone_url(&relay, &keys, identifier)],
|
|
vec![
|
|
format!("ws://{}", relay.domain()),
|
|
localhost_relay.clone(),
|
|
credential_relay.clone(),
|
|
],
|
|
);
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
client
|
|
.send_event(&announcement)
|
|
.await
|
|
.expect("announcement listing the service must be admitted");
|
|
|
|
let localhost_rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting event-directed sync relay target")
|
|
&& line.contains(&localhost_relay)
|
|
})
|
|
.await;
|
|
assert!(
|
|
localhost_rejected,
|
|
"localhost relay target must be rejected"
|
|
);
|
|
// The credential URL may be refused by URL canonicalization or by the
|
|
// outbound policy; either way it must show up as rejected, not connected.
|
|
let credentials_rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting") && line.contains("203.0.113.9")
|
|
})
|
|
.await;
|
|
assert!(
|
|
credentials_rejected,
|
|
"credential-bearing relay target must be rejected"
|
|
);
|
|
assert_eq!(
|
|
connection_count.load(Ordering::SeqCst),
|
|
0,
|
|
"no outbound connection may reach the localhost relay target"
|
|
);
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
}
|
|
|
|
/// Scenarios 5 and 6: the exact operator-configured bootstrap relay remains
|
|
/// usable even though it is local, while an event-provided URL that resembles
|
|
/// it (same port, `localhost` spelling) is still rejected.
|
|
#[tokio::test]
|
|
async fn operator_bootstrap_relay_allowed_but_resembling_event_url_rejected() {
|
|
let mock = MockRelay::start().await;
|
|
let mock_url = mock.url().to_string();
|
|
let mock_port = mock_url
|
|
.rsplit(':')
|
|
.next()
|
|
.and_then(|port| port.parse::<u16>().ok())
|
|
.expect("mock relay port");
|
|
|
|
let relay = TestRelay::start_with_sync_enforced_outbound_policy(Some(mock_url.clone())).await;
|
|
|
|
// Operator-configured bootstrap: connects despite being loopback.
|
|
let connected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Connected to relay") && line.contains(&mock_url)
|
|
})
|
|
.await;
|
|
assert!(
|
|
connected,
|
|
"operator-configured loopback bootstrap relay must stay connectable"
|
|
);
|
|
|
|
// Event-provided URL resembling the bootstrap exception (same port,
|
|
// localhost spelling) must not inherit the exception.
|
|
let resembling_relay = format!("ws://localhost:{mock_port}");
|
|
let keys = Keys::generate();
|
|
let identifier = "resembles-bootstrap";
|
|
let announcement = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![own_clone_url(&relay, &keys, identifier)],
|
|
vec![format!("ws://{}", relay.domain()), resembling_relay.clone()],
|
|
);
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
client
|
|
.send_event(&announcement)
|
|
.await
|
|
.expect("announcement listing the service must be admitted");
|
|
|
|
let rejected = wait_for_log_line(&relay.log_path(), LOG_DEADLINE, |line| {
|
|
line.contains("Rejecting event-directed sync relay target")
|
|
&& line.contains(&resembling_relay)
|
|
})
|
|
.await;
|
|
assert!(
|
|
rejected,
|
|
"an event URL resembling the bootstrap relay must still be rejected"
|
|
);
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
mock.stop().await;
|
|
}
|
|
|
|
/// Scenario 7: service admission compares parsed hostname and port, so an
|
|
/// announcement embedding our domain in a path or as a suffix of another
|
|
/// host must not be admitted as "listing the service".
|
|
#[tokio::test]
|
|
async fn announcement_embedding_domain_in_foreign_urls_is_not_admitted() {
|
|
// The permissive fixture is fine here: service admission is independent
|
|
// of the outbound reachability policy.
|
|
let relay = TestRelay::start().await;
|
|
|
|
let keys = Keys::generate();
|
|
let identifier = "substring-admission";
|
|
// Old substring matching admitted both of these because they contain the
|
|
// configured domain; exact host:port comparison must not.
|
|
let announcement = announcement_with_urls(
|
|
&keys,
|
|
identifier,
|
|
vec![format!(
|
|
"http://evil.example/{}/{}.git",
|
|
relay.domain(),
|
|
identifier
|
|
)],
|
|
vec![format!("ws://evil.example/{}", relay.domain())],
|
|
);
|
|
|
|
let client = TestClient::new(relay.url(), keys.clone())
|
|
.await
|
|
.expect("connect to relay");
|
|
let result = client.send_event(&announcement).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"announcement embedding the service domain in foreign URLs must be rejected"
|
|
);
|
|
|
|
client.disconnect().await;
|
|
relay.stop().await;
|
|
}
|