Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `DELETE /api/v1/payment-methods/{id}` — remove a saved method.
- **Breaking:** the `nwc_connection_string` field on `GET`/`PATCH /api/v1/account` has been removed. Existing NWC connections are migrated into `user_payment_method` (provider `nwc`); manage NWC via the new payment-methods endpoints instead.


### Security

- **Nostr domain activation can no longer be turned into an internal-network fetch.** The worker's path-activation check (`http://<domain>/.well-known/nostr.json?name=<hash>`, domain is customer-supplied) now resolves each hop itself, refuses loopback/private/link-local addresses, connects to the validated address while presenting the real hostname (so DNS rebinding cannot swap it), and follows redirects manually with a 3-hop bound instead of trusting the HTTP client's redirect policy. Legitimate http→https upgrades on the customer's own host keep working; a redirect to a non-public address (or a non-http(s) scheme) fails activation. New `redirect_target`/`pinned_activation_client` helpers with unit tests.

## [0.4.0] - 2026-07-13

### Added
Expand Down
221 changes: 192 additions & 29 deletions lnvps_api/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,43 @@ pub const NODE_QUIET_AFTER: chrono::Duration = chrono::Duration::minutes(10);
const NODE_QUIET_SWEEP: chrono::Duration = chrono::Duration::minutes(5);
pub const PROBE_FAILURES_TO_CORDON: u64 = 3;

/// Build a one-shot client pinned to the first public address of `host:port`.
///
/// Every hop of an activation check connects to the address that was just
/// validated (DNS rebinding cannot swap it for an internal one) while TLS and
/// the Host header still name the real host. Redirects are followed manually
/// by the caller, one validated hop at a time.
async fn pinned_activation_client(host: &str, port: u16) -> anyhow::Result<reqwest::Client> {
let ip = tokio::net::lookup_host((host, port))
.await?
.map(|a| a.ip())
.find(|ip| !is_non_public(ip))
.ok_or_else(|| anyhow::anyhow!("no public address for {host}"))?;
Ok(reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.resolve(host, std::net::SocketAddr::new(ip, port))
.build()?)
}

/// Resolve a `Location` header against the current hop's base URL into the
/// next `(scheme, host, port, path?query)`. Returns `None` for anything that
/// is not an http(s) URL (the caller refuses without a valid next hop).
fn redirect_target(base: &str, location: &str) -> Option<(String, String, u16, String)> {
let next = reqwest::Url::parse(base).ok()?.join(location).ok()?;
let scheme = next.scheme().to_string();
if scheme != "http" && scheme != "https" {
return None;
}
let host = next.host_str()?.to_string();
let port = next.port_or_known_default()?;
let path = match next.query() {
Some(q) => format!("{}?{}", next.path(), q),
None => next.path().to_string(),
};
Some((scheme, host, port, path))
}

impl Worker {
const CHECK_VMS_SECONDS: u64 = 30;

Expand Down Expand Up @@ -750,7 +787,7 @@ fn format_days(days: i64) -> String {
/// Grace period (days) for a subscription, tiered by subscription age. Lives in
/// `lnvps_api_common` so the API layer can surface the resulting deletion date;
/// re-exported here for the worker/subscription callers.
pub use lnvps_api_common::{grace_period_days_for_sub, grace_period_for_sub};
pub use lnvps_api_common::{grace_period_days_for_sub, grace_period_for_sub, is_non_public};

/// Approximate duration of one billing interval for a subscription.
///
Expand Down Expand Up @@ -3125,31 +3162,45 @@ impl Worker {
return Ok(false);
};

// Build the activation URL: http://<domain>/.well-known/nostr.json?name=<hash>
let activation_url = format!(
"http://{}/.well-known/nostr.json?name={}",
domain.name, activation_hash
);

debug!(
"Checking path activation for domain {} at {}",
domain.name, activation_url
);

match self.http_client.get(&activation_url).send().await {
Ok(response) => {
if !response.status().is_success() {
// The domain is customer-supplied, so this fetch is an SSRF primitive
// unless every hop is pinned to an address we validated: each hop
// resolves its host and refuses non-public addresses, then connects to
// the validated address while presenting the hostname (SNI/Host stay
// right, so an http->https upgrade on the customer's own host keeps
// working and DNS rebinding cannot swap in an internal address).
let mut host = domain.name.clone();
let mut port: u16 = 80;
let mut path = format!("/.well-known/nostr.json?name={}", activation_hash);

for hop in 0..3 {
let client = match pinned_activation_client(&host, port).await {
Ok(c) => c,
Err(e) => {
debug!(
"Path activation check failed for domain {} - {} (hop {})",
domain.name, e, hop
);
return Ok(false);
}
};
let scheme = if port == 443 { "https" } else { "http" };
let url = format!("{}://{}:{}{}", scheme, host, port, path);
let response = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
debug!(
"Path activation check failed for domain {} - got status {}",
domain.name,
response.status()
"Path activation check failed for domain {} - error: {}",
domain.name, e
);
return Ok(false);
}
};

if response.status().is_success() {
// Verify the body is valid NIP-05 JSON with an empty `names` map.
// The lnvps_nostr server returns `{"names":{},"relays":{}}` when
// the activation hash matches, rather than a real handle lookup.
match response.json::<serde_json::Value>().await {
let activated = match response.json::<serde_json::Value>().await {
Ok(body) => {
let names_empty = body
.get("names")
Expand All @@ -3158,32 +3209,68 @@ impl Worker {
.unwrap_or(false);
if names_empty {
debug!("Path activation check succeeded for domain {}", domain.name);
Ok(true)
true
} else {
debug!(
"Path activation check failed for domain {} - unexpected body",
domain.name
);
Ok(false)
false
}
}
Err(e) => {
debug!(
"Path activation check failed for domain {} - invalid JSON: {}",
domain.name, e
);
Ok(false)
false
}
}
}
Err(e) => {
};
return Ok(activated);
}

// Follow redirects one validated hop at a time (bounded): a plain
// no-redirect policy would break the common http->https upgrade on
// the customer's own host, but an unguarded follow would let a
// customer bounce the worker at internal services.
let location = if response.status().is_redirection() {
response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(String::from)
} else {
None
};
let Some(location) = location else {
debug!(
"Path activation check failed for domain {} - error: {}",
domain.name, e
"Path activation check failed for domain {} - got status {}",
domain.name,
response.status()
);
Ok(false)
}
return Ok(false);
};
let base = format!("{}://{}:{}", scheme, host, port);
let Some((next_scheme, next_host, next_port, next_path)) =
redirect_target(&base, &location)
else {
debug!(
"Path activation check failed for domain {} - unsupported redirect to {}",
domain.name, location
);
return Ok(false);
};
let _ = next_scheme;
host = next_host;
port = next_port;
path = next_path;
let _ = hop;
}
debug!(
"Path activation check failed for domain {} - too many redirects",
domain.name
);
Ok(false)
}

/// Check all nostr domains for DNS records - enable disabled domains with DNS records, disable active domains without DNS records
Expand Down Expand Up @@ -9114,4 +9201,80 @@ mod tests {
assert_eq!(template.network_mbps, None, "an uncapped VM stays uncapped");
Ok(())
}

/// The redirect resolver extends relative locations on the current hop,
/// accepts absolute http(s) targets (validated per hop by the pinned
/// client), and refuses non-http schemes and garbage.
#[test]
fn redirect_targets_are_resolved_and_constrained() {
assert_eq!(
redirect_target("http://a.example.com/x/y", "/b?c=d"),
Some((
"http".to_string(),
"a.example.com".to_string(),
80,
"/b?c=d".to_string()
))
);
// the common http->https upgrade on the customer's own host
assert_eq!(
redirect_target("http://a.example.com/x", "https://a.example.com/b"),
Some((
"https".to_string(),
"a.example.com".to_string(),
443,
"/b".to_string()
))
);
// an absolute target on another host is returned for validation
// downstream (the pinned client refuses non-public addresses)
assert_eq!(
redirect_target("http://a.example.com/", "http://b.example.com:8080/c?d=1"),
Some((
"http".to_string(),
"b.example.com".to_string(),
8080,
"/c?d=1".to_string()
))
);
for refused in ["ftp://a.example.com/x", "file:///etc/passwd"] {
assert_eq!(
redirect_target("http://a.example.com/", refused),
None,
"{refused} must be refused"
);
}
// a non-URL string is a relative reference: it resolves onto the
// current validated hop, which is safe by construction
assert_eq!(
redirect_target("http://a.example.com/x", "::not a url::"),
Some((
"http".to_string(),
"a.example.com".to_string(),
80,
"/::not%20a%20url::".to_string()
))
);
}

/// Regression (audit LNVM-008): path-based activation fetches a
/// customer-supplied "domain". A name that resolves only into loopback /
/// private / link-local space must be refused before any request leaves
/// the process — otherwise activation doubles as an internal-network
/// probe from the worker host.
#[tokio::test]
async fn path_activation_never_fetches_a_non_public_address() -> Result<()> {
let db = Arc::new(MockDb::default());
let worker = setup_worker_with_delete_after(db, 3).await?;
let domain = lnvps_db::NostrDomain {
name: "localhost".to_string(),
activation_hash: Some("somethash".to_string()),
..Default::default()
};

let activated = worker.check_path_activation(&domain).await?;

assert!(!activated, "a loopback-resolving domain must not activate");
Ok(())
}
}
58 changes: 58 additions & 0 deletions lnvps_api_common/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1010,3 +1010,61 @@ mod tests {
);
}
}

/// Whether `ip` is a loopback / private / link-local / otherwise non-public
/// address that an outbound fetch of user-supplied hostnames must not be able
/// to reach (SSRF guard: metadata services, internal services, localhost).
pub fn is_non_public(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
// unique local (fc00::/7)
|| (v6.segments()[0] & 0xfe00) == 0xfc00
// link local (fe80::/10)
|| (v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}

#[cfg(test)]
mod ssrf_guard_tests {
use super::*;

#[test]
fn private_and_special_ranges_are_rejected() {
for ip in [
"127.0.0.1",
"10.0.0.4",
"192.168.1.1",
"172.16.0.9",
"169.254.169.254",
"0.0.0.0",
"255.255.255.255",
"192.0.2.1",
"::1",
"fe80::1",
"fd00::1",
"::",
] {
let ip: IpAddr = ip.parse().unwrap();
assert!(is_non_public(&ip), "{ip} must be non-public");
}
}

#[test]
fn public_addresses_pass() {
for ip in ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] {
let ip: IpAddr = ip.parse().unwrap();
assert!(!is_non_public(&ip), "{ip} must be public");
}
}
}